feat(ads): client banner rotation, fade UX & live toggle (PR2)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s

Consume the server-driven banner block (PR1) in the UI and retire the gate.

- banner.ts: createScheduler — a smooth weighted round-robin over campaigns (each
  appears its weight share per cycle, evenly interleaved) with round-robin over a
  campaign's messages; the rotator drives fade-in -> hold/scroll -> fade-out -> gap
  -> fade-in, a lone message stays put, reduce-motion swaps instantly without scroll.
- model.ts/codec.ts: Profile.banner (Banner/BannerCampaign/BannerTimings) decoded
  from the fbs block.
- Screen.svelte: drop the compile-time SHOW_AD_BANNER; render AdBanner from
  app.profile.banner (campaigns + timings + reduceMotion).
- AdBanner.svelte: opacity-driven fades + scroll host; the rotator is recreated when
  the campaigns/timings change (a `banner` notify re-fetch swaps them in place).
- app.svelte.ts: on the `notify` `banner` sub-kind, refreshProfile() so the banner
  shows/hides in place.
- tests: scheduler distribution + round-robin, the fade sequence, single-message,
  reduce-motion, stop(); codec banner decode. UI_DESIGN.md + trackers updated.
This commit is contained in:
Ilia Denisov
2026-06-15 23:25:27 +02:00
parent a5f066224c
commit cb4a31a860
11 changed files with 399 additions and 165 deletions
+44 -33
View File
@@ -1,43 +1,57 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import {
createBannerRotator,
defaultBannerConfig,
linkify,
mockBanners,
type BannerConfig,
type BannerItem,
} from '../lib/banner';
import { createBannerRotator, defaultBannerTimings, linkify } from '../lib/banner';
import type { BannerCampaign, BannerTimings } from '../lib/model';
import { onExternalLinkClick } from '../lib/telegram';
let { items = mockBanners(), config = defaultBannerConfig }: { items?: BannerItem[]; config?: BannerConfig } =
$props();
let {
campaigns,
timings = defaultBannerTimings,
reduceMotion = false,
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
let current = $state(0);
let current = $state('');
let opacity = $state(0);
let opDur = $state(0); // opacity transition duration, ms (fade-in / fade-out)
let tx = $state(0);
let txDur = $state(0);
let track = $state<HTMLElement>();
let viewport = $state<HTMLElement>();
let rotator: ReturnType<typeof createBannerRotator> | null = null;
onMount(() => {
rotator = createBannerRotator(items, {
overflowPx: () => Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0)),
show: (i) => {
current = i;
// Recreate the rotator whenever the campaigns, timings or reduce-motion change (a
// `banner` notify re-fetches the profile, swapping the campaigns in place). Under
// reduce-motion the fade/gap timings collapse to 0 (instant swap) and overflowPx
// reports 0 (no scroll).
$effect(() => {
const cfg: BannerTimings = reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings;
const rotator = createBannerRotator(campaigns, {
show: (md) => {
current = md;
tx = 0;
txDur = 0;
opDur = 0;
opacity = 0;
requestAnimationFrame(() => {
opDur = cfg.fadeInMs;
opacity = 1;
});
},
resetScroll: () => {
tx = 0;
txDur = 0;
},
hide: (durationMs) => {
opDur = durationMs;
opacity = 0;
},
overflowPx: () => (reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0))),
scrollTo: (toPx, durationMs) => {
txDur = durationMs;
tx = -toPx;
},
}, config);
}, cfg);
rotator.start();
return () => rotator.stop();
});
onDestroy(() => rotator?.stop());
</script>
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
@@ -45,16 +59,13 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
{#key current}
<div
class="track"
bind:this={track}
in:fade={{ duration: config.fadeMs }}
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
>
{@html linkify(items[current]?.md ?? '')}
</div>
{/key}
<div
class="track"
bind:this={track}
style="transform:translateX({tx}px); opacity:{opacity}; transition:transform {txDur}ms linear, opacity {opDur}ms ease"
>
{@html linkify(current)}
</div>
</div>
<style>
@@ -76,7 +87,7 @@
/* The side inset lives on the track (not the clipping .ad) so the scroll distance
(scrollWidth - viewport.clientWidth) reaches the very end of a long message. */
padding: 0 var(--pad);
will-change: transform;
will-change: transform, opacity;
}
.track :global(a) {
color: var(--accent);
+8 -7
View File
@@ -2,6 +2,7 @@
import type { Snippet } from 'svelte';
import Header from './Header.svelte';
import AdBanner from './AdBanner.svelte';
import { app } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
@@ -27,12 +28,6 @@
column?: boolean;
} = $props();
// The promotional banner is feature-gated OFF until it is polished after release. The flag is
// a compile-time `false`, so the {#if} branch — and with it the AdBanner import and its
// banner.ts logic — is dead-code-eliminated from the production bundle. Flip to
// true to bring it back.
const SHOW_AD_BANNER = false;
// Edge-swipe back: a rightward drag begun in the left band returns to `back`, the standard
// mobile gesture (instant on release — the route slide plays the animation). Listened at the
// window in the CAPTURE phase so the board's own pointer handlers (which capture/stop the
@@ -94,7 +89,13 @@
<div class="screen">
<Header {title} {back} grow={growNav} />
{#if SHOW_AD_BANNER}<AdBanner />{/if}
{#if app.profile?.banner && app.profile.banner.campaigns.length}
<AdBanner
campaigns={app.profile.banner.campaigns}
timings={app.profile.banner.timings}
reduceMotion={app.reduceMotion}
/>
{/if}
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
{#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav>