feat(ads): banner under the header, continuous across navigation, robust fades
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s

Banner UX refinements (owner feedback):
- Position: render the banner inside Header (under the title) instead of in
  Screen, so it sits in the same place on every screen. In the game the grown
  nav's spare height now falls below the banner (banner under title, board
  pinned to the bottom) — it no longer jumps to the game area.
- Continuity: move the rotation into a persistent module engine
  (lib/bannerEngine) — the scheduler + timer live outside the components, so a
  navigation (which remounts the view) continues the cycle instead of restarting
  it. Each AdBanner only attaches as the DOM host and resyncs to the live message.
- Fades: a long, scrolling message now fades at both ends. The fade is a
  {#if} transition:fade layer, independent of the scroll (the inner track's
  transform), so the two no longer interfere.

Verified live (mock + Playwright): same position in lobby and game; the cycle
continues across lobby↔game; opacity sampling shows fade-out + fade-in for the
long message. Engine continuity unit-tested.
This commit is contained in:
Ilia Denisov
2026-06-16 00:35:11 +02:00
parent 9e72e2c799
commit 3b20abe0bd
6 changed files with 230 additions and 75 deletions
+29 -22
View File
@@ -12,12 +12,13 @@ runtime; opened outside Telegram, the `/telegram/` path redirects to the site ro
## Layout shell (`components/Screen.svelte`) ## Layout shell (`components/Screen.svelte`)
A full-height flex column: the nav bar, the announcement strip, the content, and an A full-height flex column: the nav bar, the content, and an optional bottom tab bar (the
optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens tab bar always sits at the screen bottom). The **advertising strip** is docked **inside the
the nav is minimal and the **content fills** between nav and tab bar. **Only in the nav** (`Header`), directly under the title, so it sits in the same place on every screen. On
game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned), most screens the nav is minimal and the **content fills** between nav and tab bar. **Only in
pinning the board and controls to the **bottom** for thumb reach. Every screen except the game** (`growNav`) does the nav bar grow to absorb spare height, so the strip stays under
Login uses `Screen`. the title while the board and controls pin to the **bottom** for thumb reach. Every screen
except Login uses `Screen`.
## Navigation ## Navigation
@@ -195,24 +196,30 @@ Login uses `Screen`.
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble". display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
## Advertising banner (`components/AdBanner.svelte`, `lib/banner.ts`) ## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token — A one-line inset strip docked inside the nav bar, directly under the title (so its position never
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter jumps between screens), drawn on a dedicated `--ad-bg` token — a subtle accent, a touch darker than
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is the surroundings in the light theme and a touch lighter in the dark theme, mapped to Telegram's
minimal markdown (text + links, escaped + linkified). It is **server-driven**: the campaigns and `secondary_bg_color` inside the Mini App. Content is minimal markdown (text + links, escaped +
display timings ride the `profile.get` response (`app.profile.banner`, present only for an eligible linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
viewer — see ARCHITECTURE §10), so `Screen` renders the strip only when that block is present, and response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
a `notify` `banner` event re-fetches the profile to show or hide it in place. `Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches
the profile to show or hide it in place.
A **smooth weighted round-robin** (`createScheduler`) picks the next message: campaigns compete by The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
their weight (each appears its weight share per cycle, evenly interleaved, not at random), and a live outside the components, so navigating between screens (which remounts the view) **continues the
campaign's own messages advance round-robin. The **rotator** then drives one message: it fades in cycle** rather than restarting it — each mounted `AdBanner` only attaches as the DOM host and resyncs
(`fadeInMs`), holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its to the live message. A **smooth weighted round-robin** (`createScheduler`) picks the next message:
right edge at `scrollPxPerSec`, pauses, and repeats while under `holdMs`), then — when more than one campaigns compete by their weight (each appears its weight share per cycle, evenly interleaved, not
message exists — fades out (`fadeOutMs`), waits `gapMs`, and fades the next in. A lone message stays at random), and a campaign's own messages advance round-robin. One message fades in (`fadeInMs`),
put (a long one keeps scrolling). All timings are operator-set (`/_gm/banner-settings`). Under holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its right edge at
**reduce-motion** the fades collapse to an instant swap and a long message does not scroll. `scrollPxPerSec`, pauses, and repeats while under `holdMs`), then — with more than one message
fades out (`fadeOutMs`), waits `gapMs`, and the next fades in. The fade (a `{#if}` `transition:fade`
layer) is independent of the scroll (the inner track's transform), so a long, scrolling message
still fades at both ends. A lone message stays put (a long one keeps scrolling). All timings are
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
and a long message does not scroll.
## Result / status iconography (`lib/result.ts`) ## Result / status iconography (`lib/result.ts`)
+62 -44
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { createBannerRotator, defaultBannerTimings, linkify } from '../lib/banner'; import { fade } from 'svelte/transition';
import { attachBannerHost, configureBanner, detachBannerHost } from '../lib/bannerEngine';
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
import type { BannerCampaign, BannerTimings } from '../lib/model'; import type { BannerCampaign, BannerTimings } from '../lib/model';
import { onExternalLinkClick } from '../lib/telegram'; import { onExternalLinkClick } from '../lib/telegram';
@@ -9,48 +11,57 @@
reduceMotion = false, reduceMotion = false,
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props(); }: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
let visible = $state(false);
let current = $state(''); let current = $state('');
let opacity = $state(0);
let opDur = $state(0); // opacity transition duration, ms (fade-in / fade-out)
let tx = $state(0); let tx = $state(0);
let txDur = $state(0); let txDur = $state(0);
let track = $state<HTMLElement>(); let track = $state<HTMLElement>();
let viewport = $state<HTMLElement>(); let viewport = $state<HTMLElement>();
// Recreate the rotator whenever the campaigns, timings or reduce-motion change (a // Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
// `banner` notify re-fetches the profile, swapping the campaigns in place). Under // scroll. The rotation engine is configured with the same effective timings, so the fade
// reduce-motion the fade/gap timings collapse to 0 (instant swap) and overflowPx // transition durations here stay in step with the rotator's hold/transition scheduling.
// reports 0 (no scroll). const eff = $derived<BannerTimings>(
reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
);
// The DOM host the rotation engine drives. The fade lives on the {#if visible} layer (Svelte's
// transition:fade — reliable and independent of the scroll), the scroll on the inner track's
// transform, so a long message's scroll never blocks its fade in/out.
const host: BannerHost = {
show(md) {
current = md;
tx = 0;
txDur = 0;
visible = true;
},
resetScroll() {
tx = 0;
txDur = 0;
},
hide() {
visible = false;
},
overflowPx() {
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
},
scrollTo(toPx, durationMs) {
txDur = durationMs;
tx = -toPx;
},
};
// Attach this view to the persistent engine on mount (and resync to the live message); detach on
// unmount without stopping the engine, so navigation continues the cycle. Declared before the
// configure effect so the host is attached when the rotator first measures overflow.
$effect(() => { $effect(() => {
const cfg: BannerTimings = reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings; attachBannerHost(host);
const rotator = createBannerRotator(campaigns, { return () => detachBannerHost(host);
show: (md) => { });
current = md; // (Re)configure the engine when the campaigns or effective timings change; a no-op when
tx = 0; // unchanged, so a remount does not restart the rotation.
txDur = 0; $effect(() => {
opDur = 0; configureBanner(campaigns, eff);
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;
},
}, cfg);
rotator.start();
return () => rotator.stop();
}); });
</script> </script>
@@ -59,13 +70,17 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}> <div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
<div {#if visible}
class="track" <div class="fadewrap" in:fade={{ duration: eff.fadeInMs }} out:fade={{ duration: eff.fadeOutMs }}>
bind:this={track} <div
style="transform:translateX({tx}px); opacity:{opacity}; transition:transform {txDur}ms linear, opacity {opDur}ms ease" class="track"
> bind:this={track}
{@html linkify(current)} style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
</div> >
{@html linkify(current)}
</div>
</div>
{/if}
</div> </div>
<style> <style>
@@ -81,13 +96,16 @@
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
user-select: none; user-select: none;
/* A stable height so the strip does not collapse during the fade gap (the message layer is
removed between fade-out and fade-in). */
min-height: calc(0.85rem * 1.2 + 12px);
} }
.track { .track {
display: inline-block; display: inline-block;
/* The side inset lives on the track (not the clipping .ad) so the scroll distance /* 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. */ (scrollWidth - viewport.clientWidth) reaches the very end of a long message. */
padding: 0 var(--pad); padding: 0 var(--pad);
will-change: transform, opacity; will-change: transform;
} }
.track :global(a) { .track :global(a) {
color: var(--accent); color: var(--accent);
+12
View File
@@ -3,7 +3,9 @@
import { insideTelegram } from '../lib/telegram'; import { insideTelegram } from '../lib/telegram';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
import AdBanner from './AdBanner.svelte';
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props(); let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
@@ -29,6 +31,16 @@
<!-- A right-hand spacer balances the back button so the title stays centred. --> <!-- A right-hand spacer balances the back button so the title stays centred. -->
<span class="spacer"></span> <span class="spacer"></span>
</div> </div>
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
same place on every screen — and in the game (grown nav) the spare height falls below
it (banner under the title, board pinned to the bottom). -->
{#if app.profile?.banner && app.profile.banner.campaigns.length}
<AdBanner
campaigns={app.profile.banner.campaigns}
timings={app.profile.banner.timings}
reduceMotion={app.reduceMotion}
/>
{/if}
</header> </header>
<style> <style>
-9
View File
@@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Header from './Header.svelte'; import Header from './Header.svelte';
import AdBanner from './AdBanner.svelte';
import { app } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and // The app-shell layout (all screens): the nav bar grows; the ad strip, content and
@@ -89,13 +87,6 @@
<div class="screen"> <div class="screen">
<Header {title} {back} grow={growNav} /> <Header {title} {back} grow={growNav} />
{#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> <main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
{#if tabbar} {#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav> <nav class="tabbar">{@render tabbar()}</nav>
+55
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner'; import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
import { attachBannerHost, configureBanner, detachBannerHost } from './bannerEngine';
import type { BannerTimings } from './model'; import type { BannerTimings } from './model';
describe('linkify', () => { describe('linkify', () => {
@@ -165,3 +166,57 @@ describe('createBannerRotator', () => {
r.stop(); r.stop();
}); });
}); });
describe('bannerEngine', () => {
afterEach(() => vi.useRealTimers());
// A minimal host that records the messages it is told to show.
function engineHost(): { host: BannerHost; shown: string[] } {
const shown: string[] = [];
return {
shown,
host: { show: (md) => shown.push(md), resetScroll() {}, hide() {}, overflowPx: () => 0, scrollTo() {} },
};
}
const period = (c: BannerTimings) => c.fadeInMs + c.holdMs + c.fadeOutMs + c.gapMs + 5;
it('continues the cycle across a host detach/attach (does not restart)', () => {
vi.useFakeTimers();
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
const camps = [{ weight: 1, messages: ['x0'] }, { weight: 1, messages: ['x1'] }];
const a = engineHost();
configureBanner(camps, cfg);
attachBannerHost(a.host);
expect(a.shown).toEqual(['x0']); // resynced to the live message
// Swap the host mid-cycle (as navigation remounts the view): the new host resyncs to the
// same live message rather than restarting at the first.
detachBannerHost(a.host);
const b = engineHost();
attachBannerHost(b.host);
expect(b.shown).toEqual(['x0']);
// The sequence advances to the next message on the new host (it continued, not restarted).
vi.advanceTimersByTime(period(cfg));
expect(b.shown).toContain('x1');
detachBannerHost(b.host);
});
it('does not restart on a no-op reconfigure with unchanged data', () => {
vi.useFakeTimers();
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
const camps = [{ weight: 1, messages: ['y0'] }, { weight: 1, messages: ['y1'] }];
const a = engineHost();
configureBanner(camps, cfg);
attachBannerHost(a.host);
vi.advanceTimersByTime(period(cfg));
expect(a.shown).toEqual(['y0', 'y1']);
// Re-configuring with identical data keeps the running cycle (no fresh 'y0' burst).
configureBanner(camps, cfg);
vi.advanceTimersByTime(5);
expect(a.shown).toEqual(['y0', 'y1']);
detachBannerHost(a.host);
});
});
+72
View File
@@ -0,0 +1,72 @@
// A single, persistent banner rotation engine. The rotator and scheduler live here at module
// scope, so the rotation keeps running across screen navigations: each screen mounts its own
// AdBanner view, which only attaches as the DOM host. This makes the banner continuous and
// independent of route transitions (the cycle is not restarted on navigation).
import { createBannerRotator, type BannerHost, type Rotator } from './banner';
import type { BannerCampaign, BannerTimings } from './model';
let rotator: Rotator | null = null;
let mounted: BannerHost | null = null;
let current = '';
let key = '';
// proxy is the rotator's host: it records the current message (so a freshly-mounted view can
// resync to it) and forwards every effect to the currently-attached DOM host, if any.
const proxy: BannerHost = {
show(md) {
current = md;
mounted?.show(md);
},
resetScroll() {
mounted?.resetScroll();
},
hide(durationMs) {
mounted?.hide(durationMs);
},
overflowPx() {
return mounted?.overflowPx() ?? 0;
},
scrollTo(toPx, durationMs) {
mounted?.scrollTo(toPx, durationMs);
},
};
// bannerKey identifies a campaigns+timings set so configureBanner restarts the cycle only on a
// real change, not on every (re)mount.
function bannerKey(campaigns: BannerCampaign[], timings: BannerTimings): string {
return JSON.stringify({ campaigns, timings });
}
/**
* configureBanner (re)starts the rotation for the given campaigns and timings, or leaves the
* running rotation untouched when they are unchanged — so navigating between screens (which
* remounts the view with the same data) continues the cycle rather than restarting it.
*/
export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimings): void {
const k = bannerKey(campaigns, timings);
if (k === key && rotator) return;
key = k;
rotator?.stop();
current = '';
rotator = createBannerRotator(campaigns, proxy, timings);
rotator.start();
}
/**
* attachBannerHost connects a freshly-mounted view as the DOM host and resyncs it to the live
* message, so the banner resumes mid-cycle instead of restarting.
*/
export function attachBannerHost(host: BannerHost): void {
mounted = host;
if (current) host.show(current);
}
/**
* detachBannerHost disconnects a view on unmount without stopping the engine. It clears the host
* only when it is still the current one, so an outgoing view leaving after the incoming view has
* already attached does not detach the new host (the transition briefly double-mounts).
*/
export function detachBannerHost(host: BannerHost): void {
if (mounted === host) mounted = null;
}