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:
@@ -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