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
+62 -44
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).
// 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;
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(() => {
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;
},
}, cfg);
rotator.start();
return () => rotator.stop();
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}>
<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>
{#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); 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>