.ad {
+ position: relative;
overflow: hidden;
white-space: nowrap;
padding: 6px 0;
@@ -142,9 +147,18 @@
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);
+ }
+ /* Reserves one line of height inside the padding so .ad keeps a constant height even during the
+ fade gap; invisible and non-interactive. */
+ .reserve {
+ visibility: hidden;
+ }
+ /* The message layer is overlaid on the reserved line, so showing/hiding it never resizes .ad. */
+ .fadewrap {
+ position: absolute;
+ top: 6px;
+ left: 0;
+ right: 0;
}
.track {
display: inline-block;
diff --git a/ui/src/lib/banner.test.ts b/ui/src/lib/banner.test.ts
index 88e4aa3..78c0afa 100644
--- a/ui/src/lib/banner.test.ts
+++ b/ui/src/lib/banner.test.ts
@@ -135,6 +135,21 @@ describe('createBannerRotator', () => {
r.stop();
});
+ it('fades the scroll loop of a long message (out, rewind, in) instead of a hard reset', () => {
+ vi.useFakeTimers();
+ const c: BannerTimings = { holdMs: 30000, edgePauseMs: 100, scrollPxPerSec: 1000, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
+ const { host, calls } = recordingHost(200); // overflows -> scroll dur = 200ms
+ const r = createBannerRotator([{ weight: 1, messages: ['long'] }], host, c);
+ r.start();
+ // fadeIn -> measure -> edgePause -> scrollTo -> (dur + edgePause) -> loop boundary
+ vi.advanceTimersByTime(c.fadeInMs + c.edgePauseMs + 200 + c.edgePauseMs + 5);
+ expect(calls).toContain('scroll');
+ expect(calls).toContain('hide'); // the loop fades out at the right edge (not a hard reset)
+ vi.advanceTimersByTime(c.fadeOutMs + c.gapMs + 5); // -> the same message fades back in
+ expect(calls.filter((x) => x === 'show').length).toBeGreaterThanOrEqual(2);
+ r.stop();
+ });
+
it('swaps instantly when fade timings are zero (reduce-motion)', () => {
vi.useFakeTimers();
const zero: BannerTimings = { ...cfg, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 };
diff --git a/ui/src/lib/banner.ts b/ui/src/lib/banner.ts
index 03aa349..3a09ac8 100644
--- a/ui/src/lib/banner.ts
+++ b/ui/src/lib/banner.ts
@@ -138,8 +138,14 @@ export function createBannerRotator(
if (Date.now() - cycleStart >= config.holdMs) {
advance();
} else {
- host.resetScroll();
- scrollCycle(over);
+ // Loop the same message with a fade: fade out at the right edge, rewind to the start
+ // while hidden, fade the same message back in, then scroll again — so the rewind gets
+ // the same fade as a message change, not a hard jump.
+ host.hide(config.fadeOutMs);
+ at(config.fadeOutMs + config.gapMs, () => {
+ host.show(lastShown);
+ at(config.fadeInMs, () => scrollCycle(over));
+ });
}
});
});