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`)
A full-height flex column: the nav bar, the announcement strip, the content, and an
optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens
the nav is minimal and the **content fills** between nav and tab bar. **Only in the
game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned),
pinning the board and controls to the **bottom** for thumb reach. Every screen except
Login uses `Screen`.
A full-height flex column: the nav bar, the content, and an optional bottom tab bar (the
tab bar always sits at the screen bottom). The **advertising strip** is docked **inside the
nav** (`Header`), directly under the title, so it sits in the same place on every screen. On
most screens the nav is minimal and the **content fills** between nav and tab bar. **Only in
the game** (`growNav`) does the nav bar grow to absorb spare height, so the strip stays under
the title while the board and controls pin to the **bottom** for thumb reach. Every screen
except Login uses `Screen`.
## Navigation
@@ -195,24 +196,30 @@ Login uses `Screen`.
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
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 subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
minimal markdown (text + links, escaped + linkified). It is **server-driven**: the campaigns and
display timings ride the `profile.get` response (`app.profile.banner`, present only for an eligible
viewer — see ARCHITECTURE §10), so `Screen` 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 one-line inset strip docked inside the nav bar, directly under the title (so its position never
jumps between screens), drawn on a dedicated `--ad-bg` token — a subtle accent, a touch darker than
the surroundings in the light theme and a touch lighter in the dark theme, mapped to Telegram's
`secondary_bg_color` inside the Mini App. Content is minimal markdown (text + links, escaped +
linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
`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
their weight (each appears its weight share per cycle, evenly interleaved, not at random), and a
campaign's own messages advance round-robin. The **rotator** then drives one message: it fades in
(`fadeInMs`), holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its
right edge at `scrollPxPerSec`, pauses, and repeats while under `holdMs`), then — when more than one
message exists — fades out (`fadeOutMs`), waits `gapMs`, and fades the next in. 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.
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
live outside the components, so navigating between screens (which remounts the view) **continues the
cycle** rather than restarting it — each mounted `AdBanner` only attaches as the DOM host and resyncs
to the live message. A **smooth weighted round-robin** (`createScheduler`) picks the next message:
campaigns compete by their weight (each appears its weight share per cycle, evenly interleaved, not
at random), and a campaign's own messages advance round-robin. One message fades in (`fadeInMs`),
holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its right edge at
`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`)
+46 -28
View File
@@ -1,5 +1,7 @@
<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 { onExternalLinkClick } from '../lib/telegram';
@@ -9,48 +11,57 @@
reduceMotion = false,
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
let visible = $state(false);
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>();
// 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) => {
// Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
// scroll. The rotation engine is configured with the same effective timings, so the fade
// transition durations here stay in step with the rotator's hold/transition scheduling.
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;
opDur = 0;
opacity = 0;
requestAnimationFrame(() => {
opDur = cfg.fadeInMs;
opacity = 1;
});
visible = true;
},
resetScroll: () => {
resetScroll() {
tx = 0;
txDur = 0;
},
hide: (durationMs) => {
opDur = durationMs;
opacity = 0;
hide() {
visible = false;
},
overflowPx: () => (reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0))),
scrollTo: (toPx, durationMs) => {
overflowPx() {
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
},
scrollTo(toPx, durationMs) {
txDur = durationMs;
tx = -toPx;
},
}, cfg);
rotator.start();
return () => rotator.stop();
};
// 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(() => {
attachBannerHost(host);
return () => detachBannerHost(host);
});
// (Re)configure the engine when the campaigns or effective timings change; a no-op when
// unchanged, so a remount does not restart the rotation.
$effect(() => {
configureBanner(campaigns, eff);
});
</script>
@@ -59,13 +70,17 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
{#if visible}
<div class="fadewrap" in:fade={{ duration: eff.fadeInMs }} out:fade={{ duration: eff.fadeOutMs }}>
<div
class="track"
bind:this={track}
style="transform:translateX({tx}px); opacity:{opacity}; transition:transform {txDur}ms linear, opacity {opDur}ms ease"
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
>
{@html linkify(current)}
</div>
</div>
{/if}
</div>
<style>
@@ -81,13 +96,16 @@
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
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 {
display: inline-block;
/* 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, opacity;
will-change: transform;
}
.track :global(a) {
color: var(--accent);
+12
View File
@@ -3,7 +3,9 @@
import { insideTelegram } from '../lib/telegram';
import { connection } from '../lib/connection.svelte';
import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
import Spinner from './Spinner.svelte';
import AdBanner from './AdBanner.svelte';
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. -->
<span class="spacer"></span>
</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>
<style>
-9
View File
@@ -1,8 +1,6 @@
<script lang="ts">
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
@@ -89,13 +87,6 @@
<div class="screen">
<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>
{#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav>
+55
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
import { attachBannerHost, configureBanner, detachBannerHost } from './bannerEngine';
import type { BannerTimings } from './model';
describe('linkify', () => {
@@ -165,3 +166,57 @@ describe('createBannerRotator', () => {
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;
}