feat(ads): client banner rotation, fade UX & live toggle (PR2) #71

Merged
developer merged 9 commits from feature/ad-network-ui into development 2026-06-16 05:45:01 +00:00
3 changed files with 40 additions and 5 deletions
Showing only changes of commit dd45af20ef - Show all commits
+17 -3
View File
@@ -116,6 +116,10 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
<!-- An always-present, invisible spacer reserves exactly one line of height, so the strip never
collapses while the message layer is absent during the fade gap (the message is overlaid
absolutely, so its presence/absence does not change the strip height). -->
<span class="reserve" aria-hidden="true">&nbsp;</span>
{#if visible}
<div class="fadewrap" in:inFade out:fade={{ duration: eff.fadeOutMs }}>
<div
@@ -131,6 +135,7 @@
<style>
.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;
+15
View File
@@ -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 };
+8 -2
View File
@@ -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));
});
}
});
});