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

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.
This commit is contained in:
Ilia Denisov
2026-06-16 00:35:11 +02:00
parent 9e72e2c799
commit 3b20abe0bd
6 changed files with 230 additions and 75 deletions
+29 -22
View File
@@ -12,12 +12,13 @@ runtime; opened outside Telegram, the `/telegram/` path redirects to the site ro
## Layout shell (`components/Screen.svelte`)
A full-height flex column: the nav bar, the announcement strip, the content, and an
optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens
the nav is minimal and the **content fills** between nav and tab bar. **Only in the
game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned),
pinning the board and controls to the **bottom** for thumb reach. Every screen except
Login uses `Screen`.
A full-height flex column: the nav bar, the content, and an optional bottom tab bar (the
tab bar always sits at the screen bottom). The **advertising strip** is docked **inside the
nav** (`Header`), directly under the title, so it sits in the same place on every screen. On
most screens the nav is minimal and the **content fills** between nav and tab bar. **Only in
the game** (`growNav`) does the nav bar grow to absorb spare height, so the strip stays under
the title while the board and controls pin to the **bottom** for thumb reach. Every screen
except Login uses `Screen`.
## Navigation
@@ -195,24 +196,30 @@ Login uses `Screen`.
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
## Advertising banner (`components/AdBanner.svelte`, `lib/banner.ts`)
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token —
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
minimal markdown (text + links, escaped + linkified). It is **server-driven**: the campaigns and
display timings ride the `profile.get` response (`app.profile.banner`, present only for an eligible
viewer — see ARCHITECTURE §10), so `Screen` renders the strip only when that block is present, and
a `notify` `banner` event re-fetches the profile to show or hide it in place.
A one-line inset strip docked inside the nav bar, directly under the title (so its position never
jumps between screens), drawn on a dedicated `--ad-bg` token — a subtle accent, a touch darker than
the surroundings in the light theme and a touch lighter in the dark theme, mapped to Telegram's
`secondary_bg_color` inside the Mini App. Content is minimal markdown (text + links, escaped +
linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
`Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches
the profile to show or hide it in place.
A **smooth weighted round-robin** (`createScheduler`) picks the next message: campaigns compete by
their weight (each appears its weight share per cycle, evenly interleaved, not at random), and a
campaign's own messages advance round-robin. The **rotator** then drives one message: it fades in
(`fadeInMs`), holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its
right edge at `scrollPxPerSec`, pauses, and repeats while under `holdMs`), then — when more than one
message exists — fades out (`fadeOutMs`), waits `gapMs`, and fades the next in. A lone message stays
put (a long one keeps scrolling). All timings are operator-set (`/_gm/banner-settings`). Under
**reduce-motion** the fades collapse to an instant swap and a long message does not scroll.
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
live outside the components, so navigating between screens (which remounts the view) **continues the
cycle** rather than restarting it — each mounted `AdBanner` only attaches as the DOM host and resyncs
to the live message. A **smooth weighted round-robin** (`createScheduler`) picks the next message:
campaigns compete by their weight (each appears its weight share per cycle, evenly interleaved, not
at random), and a campaign's own messages advance round-robin. One message fades in (`fadeInMs`),
holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its right edge at
`scrollPxPerSec`, pauses, and repeats while under `holdMs`), then — with more than one message
fades out (`fadeOutMs`), waits `gapMs`, and the next fades in. The fade (a `{#if}` `transition:fade`
layer) is independent of the scroll (the inner track's transform), so a long, scrolling message
still fades at both ends. A lone message stays put (a long one keeps scrolling). All timings are
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
and a long message does not scroll.
## Result / status iconography (`lib/result.ts`)