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
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:
+92
-64
@@ -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)!',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user