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
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:
+29
-22
@@ -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`)
|
||||
|
||||
|
||||
@@ -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,14 +70,18 @@
|
||||
<!-- 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>
|
||||
.ad {
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user