diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 28c8151..5d03720 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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`) diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index a49baa4..ae9faa8 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -1,5 +1,7 @@ @@ -59,13 +70,17 @@