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
Owner

PR2 of 2 for the server-driven advertising banner. PR1 (#70, merged) shipped the backend + admin + wire; this wires the UI and removes the gate. The banner is now live end-to-end.

What's here

  • lib/banner.tscreateScheduler: a smooth weighted round-robin over campaigns (each campaign appears its weight share per cycle, evenly interleaved — not random), round-robining a campaign's own messages. The rotator drives one message: fade-in → hold (scroll a long one) → fade-out → gap → fade-in the next. A lone message stays put (a long one keeps scrolling). Reduce-motion collapses the fades to an instant swap with no scroll.
  • model.ts / codec.tsProfile.banner (Banner / BannerCampaign / BannerTimings) decoded from the fbs block.
  • Screen.svelte — the compile-time SHOW_AD_BANNER gate is gone; the strip renders from app.profile.banner (campaigns + timings + reduce-motion).
  • AdBanner.svelte — opacity-driven fades + scroll host; the rotator is recreated when the campaigns/timings change, so a banner notify re-fetch swaps them in place.
  • app.svelte.ts — on the notify banner sub-kind, refreshProfile() re-fetches the profile so the banner shows/hides in place.

Verification (local, all green)

svelte-check (0 errors); test:unit 252 (scheduler distribution + round-robin, fade sequence, single-message, reduce-motion, stop(), banner decode); build; Playwright e2e 69 passed on Chromium (CI runs WebKit too). The banner branch is inert in mock mode (no app.profile.banner), so the existing e2e suite is unaffected.

How to see it on the contour

Eligibility is !paid && hint_balance == 0 && !no_banner — open as a guest (or a fresh free account) to be eligible. With only the seeded default campaign (one message) you'll see a single static tip; to watch the rotation + fades, add a timed campaign (or a second message to the default) and tune the timings in /_gm/banners + /_gm/banner-settings.

Notes

  • No mock-mode e2e/visual test was added: the mock has no backend to drive eligibility, the component is thin + type-checked, and the rotation/decode logic is unit-tested — visual sign-off is on the contour (the project's established practice).
**PR2 of 2** for the server-driven advertising banner. PR1 (#70, merged) shipped the backend + admin + wire; this wires the **UI** and removes the gate. The banner is now live end-to-end. ### What's here - **`lib/banner.ts`** — `createScheduler`: a **smooth weighted round-robin** over campaigns (each campaign appears its weight share per cycle, evenly interleaved — not random), round-robining a campaign's own messages. The rotator drives one message: **fade-in → hold (scroll a long one) → fade-out → gap → fade-in** the next. A lone message stays put (a long one keeps scrolling). **Reduce-motion** collapses the fades to an instant swap with no scroll. - **`model.ts` / `codec.ts`** — `Profile.banner` (`Banner` / `BannerCampaign` / `BannerTimings`) decoded from the fbs block. - **`Screen.svelte`** — the compile-time `SHOW_AD_BANNER` gate is **gone**; the strip renders from `app.profile.banner` (campaigns + timings + reduce-motion). - **`AdBanner.svelte`** — opacity-driven fades + scroll host; the rotator is recreated when the campaigns/timings change, so a `banner` notify re-fetch swaps them **in place**. - **`app.svelte.ts`** — on the `notify` `banner` sub-kind, `refreshProfile()` re-fetches the profile so the banner shows/hides in place. ### Verification (local, all green) `svelte-check` (0 errors); `test:unit` **252** (scheduler distribution + round-robin, fade sequence, single-message, reduce-motion, `stop()`, banner decode); `build`; Playwright e2e **69 passed on Chromium** (CI runs WebKit too). The banner branch is inert in mock mode (no `app.profile.banner`), so the existing e2e suite is unaffected. ### How to see it on the contour Eligibility is `!paid && hint_balance == 0 && !no_banner` — open as a **guest** (or a fresh free account) to be eligible. With only the seeded **default** campaign (one message) you'll see a single static tip; to watch the **rotation + fades**, add a timed campaign (or a second message to the default) and tune the **timings** in `/_gm/banners` + `/_gm/banner-settings`. ### Notes - No mock-mode e2e/visual test was added: the mock has no backend to drive eligibility, the component is thin + type-checked, and the rotation/decode logic is unit-tested — visual sign-off is on the contour (the project's established practice).
developer added 1 commit 2026-06-15 21:25:44 +00:00
feat(ads): client banner rotation, fade UX & live toggle (PR2)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
cb4a31a860
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.
developer added 1 commit 2026-06-15 21:35:53 +00:00
feat(ads): add a link-formatting help aside to the banner message editor
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
53c6e34c13
On the campaign detail page, beside the "Add message" form, a static aside
explains the message markdown: plain text is escaped, `[text](url)` becomes a
link, and only http(s)/root-relative targets are linkified (others show as
plain text). New .form-help (flex row) + .help (muted aside) console styles;
wraps below the form on a narrow viewport.
developer added 1 commit 2026-06-15 21:53:44 +00:00
feat(ads): banner message editor — top help aside + multi-line fields
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
9e72e2c799
Admin campaign editor polish:
- move the link-formatting help aside to the top of the Messages section,
  beside the intro note (~40% width), so it no longer drops below and stretches
  the form fields.
- make the English/Russian message fields 3-row, vertically resizable textareas
  (was single-line inputs) so long text wraps instead of scrolling off to the
  right. The strip is white-space:nowrap, so a stray newline collapses to a space
  on display.
developer added 1 commit 2026-06-15 22:35:13 +00:00
feat(ads): banner under the header, continuous across navigation, robust fades
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
3b20abe0bd
Banner UX refinements (owner feedback):
- Position: render the banner inside Header (under the title) instead of in
  Screen, so it sits in the same place on every screen. In the game the grown
  nav's spare height now falls below the banner (banner under title, board
  pinned to the bottom) — it no longer jumps to the game area.
- Continuity: move the rotation into a persistent module engine
  (lib/bannerEngine) — the scheduler + timer live outside the components, so a
  navigation (which remounts the view) continues the cycle instead of restarting
  it. Each AdBanner only attaches as the DOM host and resyncs to the live message.
- Fades: a long, scrolling message now fades at both ends. The fade is a
  {#if} transition:fade layer, independent of the scroll (the inner track's
  transform), so the two no longer interfere.

Verified live (mock + Playwright): same position in lobby and game; the cycle
continues across lobby↔game; opacity sampling shows fade-out + fade-in for the
long message. Engine continuity unit-tested.
developer added 1 commit 2026-06-16 03:45:40 +00:00
fix(ads): banner truly continuous across navigation + re-measure on resize
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
5fb0daa746
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.
developer added 1 commit 2026-06-16 04:09:38 +00:00
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
dc582e9f73
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.
developer added 1 commit 2026-06-16 04:26:13 +00:00
feat(ads): carry the banner scroll position across navigation
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 57s
9f83962bf7
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).
developer added 1 commit 2026-06-16 04:39:15 +00:00
fix(ads): pulse a lone banner message through the fade cycle
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
115c92b39a
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.
developer added 1 commit 2026-06-16 05:18:52 +00:00
fix(ads): fade the scroll rewind + keep the banner strip height constant
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
dd45af20ef
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.
owner approved these changes 2026-06-16 05:44:13 +00:00
developer merged commit 27871f2a1d into development 2026-06-16 05:45:01 +00:00
developer deleted branch feature/ad-network-ui 2026-06-16 05:45:01 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#71