From cb4a31a860b4486694f8402065a0a285b921679a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 23:25:27 +0200 Subject: [PATCH 1/9] feat(ads): client banner rotation, fade UX & live toggle (PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PLAN.md | 6 +- PRERELEASE.md | 2 +- docs/UI_DESIGN.md | 20 +++- ui/src/components/AdBanner.svelte | 77 +++++++------ ui/src/components/Screen.svelte | 15 +-- ui/src/lib/app.svelte.ts | 19 ++++ ui/src/lib/banner.test.ts | 179 +++++++++++++++++++++--------- ui/src/lib/banner.ts | 156 +++++++++++++++----------- ui/src/lib/codec.test.ts | 35 ++++++ ui/src/lib/codec.ts | 29 +++++ ui/src/lib/model.ts | 26 +++++ 11 files changed, 399 insertions(+), 165 deletions(-) diff --git a/PLAN.md b/PLAN.md index c4214b1..fee5f9b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1428,9 +1428,9 @@ cannot submit; three-way admin filter. docker-network caddy hop `172.18.0.x` for chat moderation, and bucketing the gateway's per-IP rate limiter on it). Correct + spoof-safe in **both** contours (prod has no host caddy → public clients untrusted → real peer used). `peerIP` unit-tested. - - **Ad banner** gated **off** behind a compile-time `SHOW_AD_BANNER=false` in `Screen.svelte` - — the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod - bundle (code kept for post-release polish). + - **Ad banner** was gated off here behind `SHOW_AD_BANNER=false`; the **AD** phase + (PRERELEASE.md) later turned it into the real server-driven advertising network and removed + the gate (`Screen` renders it from `app.profile.banner`). - **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption). - **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's diff --git a/PRERELEASE.md b/PRERELEASE.md index 9bf751a..accdc65 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -32,7 +32,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | | AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** | | AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** | -| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | PR1 backend+admin **done**; PR2 UI rotation **next** | +| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 83b1a85..28c8151 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -195,16 +195,24 @@ 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". -## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`) +## Advertising banner (`components/AdBanner.svelte`, `lib/banner.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). A parameterised **rotator** drives messages: a fitting message -holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip -pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats -until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short -message; the source becomes a server-driven channel later (see ARCHITECTURE). +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 **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. ## Result / status iconography (`lib/result.ts`) diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index b9e37fc..a49baa4 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -1,43 +1,57 @@
- {#key current} -
- {@html linkify(items[current]?.md ?? '')} -
- {/key} +
+ {@html linkify(current)} +