From 115c92b39aae72dccd3c4051e85bf248430f7408 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 16 Jun 2026 06:39:12 +0200 Subject: [PATCH] fix(ads): pulse a lone banner message through the fade cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single campaign message (e.g. the default campaign's one message) faded in once on load and then sat frozen — the rotator only ran the fade/advance cycle when more than one message existed, so with one message there were no further fades. Drop the `total > 1` guards: every message now runs the full hold → fade-out → gap → fade-in cycle, so a lone message pulses (the same message fades back in) and a lone long message fades at each scroll-loop boundary. Multi-message rotation is unchanged. Verified by opacity sampling (single message pulses 1→0→gap→0→1 without navigation); the single-message test now asserts the pulse. --- ui/src/lib/banner.test.ts | 8 +++++--- ui/src/lib/banner.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ui/src/lib/banner.test.ts b/ui/src/lib/banner.test.ts index d0b94f1..88e4aa3 100644 --- a/ui/src/lib/banner.test.ts +++ b/ui/src/lib/banner.test.ts @@ -122,14 +122,16 @@ describe('createBannerRotator', () => { r.stop(); }); - it('shows a single message once and never fades it out', () => { + it('pulses a lone message through the fade cycle (fades out, then back in)', () => { vi.useFakeTimers(); const { host, calls, shown } = recordingHost(0); const r = createBannerRotator([{ weight: 1, messages: ['solo'] }], host, cfg); r.start(); - vi.advanceTimersByTime(cfg.fadeInMs + cfg.holdMs * 5); expect(shown).toEqual(['solo']); - expect(calls).not.toContain('hide'); + vi.advanceTimersByTime(cfg.fadeInMs + cfg.holdMs); // hold elapses -> fade out + expect(calls).toContain('hide'); + vi.advanceTimersByTime(cfg.fadeOutMs + cfg.gapMs + 1); // -> the same message fades back in + expect(shown).toEqual(['solo', 'solo']); r.stop(); }); diff --git a/ui/src/lib/banner.ts b/ui/src/lib/banner.ts index 318c6f0..03aa349 100644 --- a/ui/src/lib/banner.ts +++ b/ui/src/lib/banner.ts @@ -85,8 +85,8 @@ export interface Rotator { /** * 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). + * then fades out over `fadeOutMs`, waits `gapMs`, and fades the next one in. A lone + * message pulses (the same message fades back in) so the cycle keeps its rhythm. * Reduce-motion is handled by the host: pass zeroed fade timings and an overflowPx that * returns 0, and messages swap instantly without scrolling. */ @@ -125,8 +125,9 @@ export function createBannerRotator( scrollCycle(over); return; } - if (sched.total > 1) at(config.holdMs, advance); - // A single fitting message stays put. + // Hold, then run the fade cycle. A lone message pulses (fades out, then back in) rather than + // sitting frozen, so the banner keeps its rhythm even with a single campaign message. + at(config.holdMs, advance); } function scrollCycle(over: number) { @@ -134,7 +135,7 @@ export function createBannerRotator( at(config.edgePauseMs, () => { host.scrollTo(over, dur); at(dur + config.edgePauseMs, () => { - if (sched.total > 1 && Date.now() - cycleStart >= config.holdMs) { + if (Date.now() - cycleStart >= config.holdMs) { advance(); } else { host.resetScroll();