feat(ads): client banner rotation, fade UX & live toggle (PR2)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s

Consume the server-driven banner block (PR1) in the UI and retire the gate.

- banner.ts: createScheduler — a smooth weighted round-robin over campaigns (each
  appears its weight share per cycle, evenly interleaved) with round-robin over a
  campaign's messages; the rotator drives fade-in -> hold/scroll -> fade-out -> gap
  -> fade-in, a lone message stays put, reduce-motion swaps instantly without scroll.
- model.ts/codec.ts: Profile.banner (Banner/BannerCampaign/BannerTimings) decoded
  from the fbs block.
- Screen.svelte: drop the compile-time SHOW_AD_BANNER; render AdBanner from
  app.profile.banner (campaigns + timings + reduceMotion).
- AdBanner.svelte: opacity-driven fades + scroll host; the rotator is recreated when
  the campaigns/timings change (a `banner` notify re-fetch swaps them in place).
- app.svelte.ts: on the `notify` `banner` sub-kind, refreshProfile() so the banner
  shows/hides in place.
- tests: scheduler distribution + round-robin, the fade sequence, single-message,
  reduce-motion, stop(); codec banner decode. UI_DESIGN.md + trackers updated.
This commit is contained in:
Ilia Denisov
2026-06-15 23:25:27 +02:00
parent a5f066224c
commit cb4a31a860
11 changed files with 399 additions and 165 deletions
+92 -64
View File
@@ -1,39 +1,72 @@
// Announcement / "ad" banner — a parameterised rotator plus a tiny markdown linkifier.
// The rotator is DOM-agnostic (the host measures overflow and applies the visual
// effects through callbacks), so its timing is unit-testable with fake timers. Today
// the content is a mock long↔short rotation; later it becomes a server-driven
// announcements channel (see ARCHITECTURE).
// Advertising banner — the server-driven rotation engine plus a tiny markdown
// linkifier. The scheduler and rotator are DOM-agnostic (the host measures overflow
// and applies the visual effects through callbacks), so their timing and fairness are
// unit-testable with fake timers. The campaigns + timings come from the profile's
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
export interface BannerConfig {
/** How long one message is shown before advancing (short text), ms. */
holdMs: number;
/** Pause at each end before/after scrolling a long message, ms. */
edgePauseMs: number;
/** Scroll speed for a long (overflowing) message, px/sec. */
scrollPxPerSec: number;
/** Cross-fade duration between messages, ms. */
fadeMs: number;
}
import type { BannerCampaign, BannerTimings } from './model';
export const defaultBannerConfig: BannerConfig = {
/** Fallback display timings, matching the seeded ad_settings defaults. */
export const defaultBannerTimings: BannerTimings = {
holdMs: 60_000,
edgePauseMs: 5_000,
scrollPxPerSec: 40,
fadeMs: 400,
fadeOutMs: 1_000,
gapMs: 250,
fadeInMs: 1_000,
};
export interface BannerItem {
/** Minimal markdown: plain text + `[label](url)` links. */
md: string;
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
export interface Scheduler {
next(): string;
/** Total messages across all (non-empty, positive-weight) campaigns. */
readonly total: number;
}
/** The host the rotator drives; the Svelte component supplies the DOM measurements. */
/**
* createScheduler builds a smooth weighted round-robin over campaigns: each call to
* next() picks the campaign whose running weight is highest (then decremented by the
* total weight), so over one cycle of `sum(weights)` picks every campaign appears
* exactly its weight share, evenly interleaved rather than clustered (the nginx/envoy
* algorithm). Within a campaign its messages advance round-robin. Campaigns with no
* messages or a non-positive weight are dropped.
*/
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
const current = cs.map(() => 0); // SWRR running weights
const cursor = cs.map(() => 0); // per-campaign round-robin position
const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
return {
total,
next(): string {
if (cs.length === 0) return '';
let best = 0;
for (let i = 0; i < cs.length; i++) {
current[i] += cs[i].weight;
if (current[i] > current[best]) best = i;
}
current[best] -= totalWeight;
const c = cs[best];
const md = c.messages[cursor[best] % c.messages.length];
cursor[best]++;
return md;
},
};
}
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
export interface BannerHost {
/** Overflow width of item `index` in px (0 when it fits). */
overflowPx(index: number): number;
/** Render item `index` (the host fades it in and resets scroll to the start). */
show(index: number): void;
/** Animate the horizontal scroll to `toPx` over `durationMs`. */
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. */
show(md: string): void;
/** Reset the scroll to the start without re-fading (to loop a long message). */
resetScroll(): void;
/** Fade the current message out over durationMs. */
hide(durationMs: number): void;
/** Overflow width of the currently-shown message in px (0 when it fits or motion is reduced). */
overflowPx(): number;
/** Animate the horizontal scroll to toPx over durationMs. */
scrollTo(toPx: number, durationMs: number): void;
}
@@ -43,16 +76,19 @@ export interface Rotator {
}
/**
* createBannerRotator drives a list of messages: a fitting message holds `holdMs`
* then advances; an overflowing one pauses, scrolls to its right edge, pauses, then
* repeats while the elapsed cycle is under `holdMs`, else advances.
* createBannerRotator rotates the scheduled messages: each message fades in, holds
* `holdMs` (an overflowing one scrolls to its right edge and back while under holdMs),
* then — when more than one message exists — fades out over `fadeOutMs`, waits `gapMs`,
* and fades the next one in. A single message stays put (a long one keeps scrolling).
* Reduce-motion is handled by the host: pass zeroed fade timings and an overflowPx that
* returns 0, and messages swap instantly without scrolling.
*/
export function createBannerRotator(
items: BannerItem[],
campaigns: BannerCampaign[],
host: BannerHost,
config: BannerConfig = defaultBannerConfig,
config: BannerTimings = defaultBannerTimings,
): Rotator {
let index = 0;
const sched = createScheduler(campaigns);
let running = false;
let cycleStart = 0;
const timers: ReturnType<typeof setTimeout>[] = [];
@@ -65,26 +101,23 @@ export function createBannerRotator(
timers.length = 0;
};
function advance() {
if (!running) return;
index = (index + 1) % items.length;
present();
}
function present() {
function present(md: string) {
if (!running) return;
clear();
host.show(index);
// Let the swapped-in message render before measuring its overflow.
at(config.fadeMs, () => {
const over = host.overflowPx(index);
if (over <= 0) {
at(config.holdMs, advance);
return;
}
cycleStart = Date.now();
host.show(md);
at(config.fadeInMs, measure);
}
function measure() {
if (!running) return;
cycleStart = Date.now();
const over = host.overflowPx();
if (over > 0) {
scrollCycle(over);
});
return;
}
if (sched.total > 1) at(config.holdMs, advance);
// A single fitting message stays put.
}
function scrollCycle(over: number) {
@@ -92,22 +125,27 @@ export function createBannerRotator(
at(config.edgePauseMs, () => {
host.scrollTo(over, dur);
at(dur + config.edgePauseMs, () => {
if (Date.now() - cycleStart >= config.holdMs) {
if (sched.total > 1 && Date.now() - cycleStart >= config.holdMs) {
advance();
} else {
host.show(index); // resets scroll to the start
host.resetScroll();
scrollCycle(over);
}
});
});
}
function advance() {
if (!running) return;
host.hide(config.fadeOutMs);
at(config.fadeOutMs + config.gapMs, () => present(sched.next()));
}
return {
start() {
if (running || items.length === 0) return;
if (running || sched.total === 0) return;
running = true;
index = 0;
present();
present(sched.next());
},
stop() {
running = false;
@@ -145,13 +183,3 @@ export function linkify(md: string): string {
parts.push(escapeHtml(md.slice(last)));
return parts.join('');
}
/** mockBanners is the placeholder rotation (long ↔ short) to demo the mechanics. */
export function mockBanners(): BannerItem[] {
return [
{ md: 'New season starts soon — [learn more](https://example.com/season).' },
{
md: 'Tip: a 7-tile play earns a +50 bonus. Try the daily tournament, climb the leaderboard, and challenge friends — more modes are coming, [stay tuned](https://example.com/news)!',
},
];
}