Non-default campaigns gain an optional colour override (background / text /
link) in two sets — one for every theme, one for the dark theme only — and an
"urgent" flag.
- Colours ride profile.get as six trailing FlatBuffers strings on
BannerCampaign (backward-compatible). The client resolves the cascade
(dark <- dark ?? all, light <- all) per rendered theme and derives the strip
border from the background in JS (no CSS color-mix, for the old Android
WebView floor); AdBanner applies them as inline vars scoped to the strip.
- Urgent is resolved entirely server-side: while any enabled, in-window urgent
campaign exists, computeActiveSet returns only the urgent campaigns and
bannerFor skips the eligibility gate — so a system notice reaches every viewer
(paid / hint-holding / no_banner included) and preempts the ordinary feed. No
wire field; it appears on each viewer's next profile.get.
- Admin console (/_gm/banners): native colour pickers + a live light/dark
preview of the strip, and an urgent toggle. The default campaign stays plain,
enforced by the service and a DB CHECK.
Migration 00009 is additive (nullable colour columns + a bool default +
all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe.
Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent
preempt + colour validation), codec + resolver unit, gateway transcode, and
integration (colour round-trip + urgent bypass against real Postgres).
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.
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.
Per the owner's idea: instead of moving the banner out of the per-screen header
(which would change its position), remember the banner's "life stage" and resume
it on the next screen. The engine already keeps the message + rotation timing;
this adds the scroll offset:
- bannerEngine tracks the in-flight scroll (target, duration, start). On attach,
if a scroll is still running, it computes the current offset and calls the new
host's resumeScroll(fromTx, toPx, remaining) — the view jumps to the carried
offset and continues to the end over the remaining time, instead of restarting
at the left.
- A finished scroll is left at its end; the rotator's own loop then takes over.
Verified: spot-checked in the browser (a long message at offset -785 resumes at
-788 on the next screen, not 0) and unit-tested (attach mid-scroll calls
resumeScroll with a partial offset and the remaining duration).
The previous engine kept the scheduler running but the view re-`show()`-ed the
current message on every (re)mount, replaying the fade on each navigation — which
looked like the cycle restarting (especially for a single message). Now:
- A mounted AdBanner reads the engine's live message (bannerCurrent) and renders
it immediately, with no fade; attach no longer re-shows. Only a real advance
fades. Verified: opacity stays 1.0 across a navigation, message preserved.
- The fade is manual opacity on a .fadewrap layer (not transition:fade), kept
independent of the scroll (inner track transform), so a long message still
fades at both ends and a {#key} remount cannot force an intro fade.
- A viewport size change (portrait↔landscape) re-measures the current message
(remeasureBanner on resize/orientationchange, debounced) so the scroll
re-evaluates for the new width — the owner accepts the restart on resize.
Rotator gains restart(); engine gains bannerCurrent()/remeasureBanner().
Engine continuity + remeasure unit-tested.
Consume the server-driven banner block (PR1) in the UI and retire the gate.
- banner.ts: createScheduler — a smooth weighted round-robin over campaigns (each
appears its weight share per cycle, evenly interleaved) with round-robin over a
campaign's messages; the rotator drives fade-in -> hold/scroll -> fade-out -> gap
-> fade-in, a lone message stays put, reduce-motion swaps instantly without scroll.
- model.ts/codec.ts: Profile.banner (Banner/BannerCampaign/BannerTimings) decoded
from the fbs block.
- Screen.svelte: drop the compile-time SHOW_AD_BANNER; render AdBanner from
app.profile.banner (campaigns + timings + reduceMotion).
- AdBanner.svelte: opacity-driven fades + scroll host; the rotator is recreated when
the campaigns/timings change (a `banner` notify re-fetch swaps them in place).
- app.svelte.ts: on the `notify` `banner` sub-kind, refreshProfile() so the banner
shows/hides in place.
- tests: scheduler distribution + round-robin, the fade sequence, single-message,
reduce-motion, stop(); codec banner decode. UI_DESIGN.md + trackers updated.