Files
scrabble-game/ui/src/components/AdBanner.svelte
T
Ilia Denisov dd45af20ef
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
fix(ads): fade the scroll rewind + keep the banner strip height constant
Two polish fixes (owner feedback):

- Scroll loop: a long message that scrolled to its right edge rewound with a hard
  jump (no fade). It now runs the same fade as a message change at each rewind:
  fade out at the edge, reset the scroll while hidden, fade the same message back
  in, then scroll again.
- Strip height: during the fade gap the message layer is removed, which let the
  strip collapse by ~1-2px. An always-present invisible spacer now reserves one
  line of height and the message is overlaid absolutely, so the strip height is
  constant whether or not the message is showing.

Verified live: opacity sampling shows a full fade-out → gap → fade-in at each
scroll rewind (~every 6s), and the .ad height stays a single constant value
(30.31px) across the whole cycle including the gap. Loop-fade unit-tested.
2026-06-16 07:18:50 +02:00

175 lines
6.3 KiB
Svelte

<script lang="ts">
import { fade } from 'svelte/transition';
import {
attachBannerHost,
bannerCurrent,
configureBanner,
detachBannerHost,
remeasureBanner,
} from '../lib/bannerEngine';
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
import type { BannerCampaign, BannerTimings } from '../lib/model';
import { onExternalLinkClick } from '../lib/telegram';
let {
campaigns,
timings = defaultBannerTimings,
reduceMotion = false,
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
// Initialise from the persistent engine's live message: a view mounted by navigation shows the
// current message and is visible at once (no fade — see inFade), so a screen change does not
// replay the fade. Empty on the very first mount (engine not yet started).
let current = $state(bannerCurrent());
let visible = $state(bannerCurrent() !== '');
let tx = $state(0);
let txDur = $state(0);
let track = $state<HTMLElement>();
let viewport = $state<HTMLElement>();
// The first appearance after mounting onto an already-running cycle is instant; every later
// message change fades. (Consumed by the first in:fade.)
let instantOnce = bannerCurrent() !== '';
// Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
// scroll. The engine is configured with the same effective timings, so the fade durations stay in
// step with the rotator's hold/transition scheduling.
const eff = $derived<BannerTimings>(
reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
);
// in:fade with a reliable from-state (Svelte forces opacity 0 → 1), so even the first message
// fades — unlike a bare opacity toggle, which can paint 0 and 1 in one frame and skip the fade.
// The resync mount renders the live message instantly (duration 0).
function inFade(node: Element) {
const duration = instantOnce ? 0 : eff.fadeInMs;
instantOnce = false;
return fade(node, { duration });
}
// The DOM host the engine drives. The fade lives on the {#if} layer (transition:fade), the scroll
// on the inner track's transform, so a long message's scroll never blocks its fade in/out.
const host: BannerHost = {
show(md) {
current = md;
tx = 0;
txDur = 0;
visible = true;
},
resetScroll() {
tx = 0;
txDur = 0;
},
hide() {
visible = false;
},
overflowPx() {
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
},
scrollTo(toPx, durationMs) {
txDur = durationMs;
tx = -toPx;
},
resumeScroll(fromTx, toPx, durationMs) {
// Jump to the carried-over offset instantly, then continue to the end over the remaining
// time — so a long message keeps its scroll position across a navigation.
txDur = 0;
tx = fromTx;
requestAnimationFrame(() => {
txDur = durationMs;
tx = -toPx;
});
},
};
// Attach to the persistent engine on mount, detach on unmount (without stopping it). Declared
// before configure so the host is attached when the rotator first measures overflow.
$effect(() => {
attachBannerHost(host);
return () => detachBannerHost(host);
});
// (Re)configure the engine when the campaigns or effective timings change; a no-op when
// unchanged, so a remount continues the running cycle rather than restarting it.
$effect(() => {
configureBanner(campaigns, eff);
});
// Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
// may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
$effect(() => {
let t: ReturnType<typeof setTimeout>;
const onResize = () => {
clearTimeout(t);
t = setTimeout(remeasureBanner, 250);
};
window.addEventListener('resize', onResize);
window.addEventListener('orientationchange', onResize);
return () => {
clearTimeout(t);
window.removeEventListener('resize', onResize);
window.removeEventListener('orientationchange', onResize);
};
});
</script>
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
Telegram SDK (onExternalLinkClick uses closest('a')) so they skip the WebView confirmation. -->
<!-- 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
class="track"
bind:this={track}
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
>
{@html linkify(current)}
</div>
</div>
{/if}
</div>
<style>
.ad {
position: relative;
overflow: hidden;
white-space: nowrap;
padding: 6px 0;
background: var(--ad-bg);
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.2;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
user-select: none;
}
/* 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;
/* The side inset lives on the track (not the clipping .ad) so the scroll distance
(scrollWidth - viewport.clientWidth) reaches the very end of a long message. */
padding: 0 var(--pad);
will-change: transform;
}
.track :global(a) {
color: var(--accent);
text-decoration: underline;
}
</style>