fix(ads): restore reliable banner fades + keep banner on profile update
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s

Two regressions from the previous banner pass:

- Fade (#2): the manual-opacity fade could paint opacity 0 and 1 in one frame and
  skip the transition — most visible for a single (default) campaign message,
  whose only fade is the first show. Revert the fade to Svelte transition:fade
  (which forces the from-state, so even the first/only message fades), keeping it
  on its own {#if} layer independent of the scroll. A freshly-mounted view onto a
  running cycle still renders the live message instantly (inFade duration 0 once),
  so navigation does not replay the fade. Verified by opacity sampling: advances
  fade, navigation stays at opacity 1.

- Profile update (#3): the banner block was attached only to GET /profile, so a
  profile.update (e.g. a language switch) returned a profile without it and the
  banner vanished until reload. A shared profileResponse() now attaches the banner
  to GET, PUT and the link/merge profile responses. Regression test added
  (TestBannerSurvivesProfileUpdate).

Still open: the scroll position is not preserved across navigation (the view
remounts); discussed separately.
This commit is contained in:
Ilia Denisov
2026-06-16 06:09:35 +02:00
parent 5fb0daa746
commit dc582e9f73
6 changed files with 75 additions and 26 deletions
+36 -21
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import {
attachBannerHost,
bannerCurrent,
@@ -17,41 +18,50 @@
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
// Initialise from the persistent engine's live message: a view mounted by navigation shows the
// current message immediately (opacity 1, no transition), so a screen change does not replay the
// fade — only a real message advance fades. Empty on the very first mount (engine not started).
// 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 opacity = $state(bannerCurrent() ? 1 : 0);
let opDur = $state(0);
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 here
// stay in step with the rotator's hold/transition scheduling.
// 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,
);
// The DOM host the engine drives. Opacity (fade) lives on the .fadewrap layer and the transform
// (scroll) on the inner track, so a long message's scroll never blocks its fade in/out. show()
// fades in from the (already painted) faded-out state left by the preceding hide().
// 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;
opDur = eff.fadeInMs;
opacity = 1;
visible = true;
},
resetScroll() {
tx = 0;
txDur = 0;
},
hide() {
opDur = eff.fadeOutMs;
opacity = 0;
visible = false;
},
overflowPx() {
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
@@ -96,15 +106,17 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
<div class="fadewrap" style="opacity:{opacity}; transition:opacity {opDur}ms ease">
<div
class="track"
bind:this={track}
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
>
{@html linkify(current)}
{#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>
</div>
{/if}
</div>
<style>
@@ -120,6 +132,9 @@
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);
}
.track {
display: inline-block;