feat(ui): first-run onboarding coachmarks
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s

A one-time coachmark overlay walks a new player through the lobby and their
first game board: a light dimmed layer draws one tail-pointed hint bubble at a
time, advancing on a tap anywhere and removing itself for good after the last
hint. Two independent series (lobby: settings/stats/new game; game:
header/pass-exchange/hints/shuffle/rack), gated by a per-device persisted flag
and marked done only after the last hint, so an interrupted run replays from
the start. A deep-link into Settings -> Friends still triggers the lobby series
on the first trip back to the lobby.

Targets carry a data-coach attribute, so one positioning engine anchors the
bubble in both portrait and landscape, re-measuring each frame until the
geometry settles (route slide, hidden-banner reflow, fonts). The promo banner
hides while the overlay is up (app.coachActive); a hidden DebugPanel "Reset
visited" control replays the walk-through. Off by default in the mock build so
the Playwright smoke is unaffected; ?coach forces it on for the dedicated e2e.

Pure geometry (step lists, nextVisibleStep, placeBubble) in lib/coachmark.ts
(unit-tested); Coachmark.svelte renders. Docs: FUNCTIONAL(+ru) onboarding
story, UI_DESIGN coachmark section.
This commit is contained in:
Ilia Denisov
2026-06-30 21:48:56 +02:00
parent 90f2427fa7
commit 6636d7c309
16 changed files with 809 additions and 14 deletions
+28 -1
View File
@@ -49,6 +49,32 @@ fast load shows it for ~1.25 s. Each tile **drops in** with a brief scale + fade
dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts`
(unit-tested); `Splash.svelte` is the renderer.
## First-run onboarding coachmarks (`components/Coachmark.svelte`, `lib/coachmark.ts`)
A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an
**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings →
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle →
rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer
captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the
last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself;
a series is marked only after its **last** hint, so an interrupted player replays it from the start.
The lobby series gates on `app.splashDone`, the game series on its first target laying out; both defer
while a modal (`welcomeRedeem` / `staleInvite`) is open.
Each target carries a **`data-coach` attribute**, so the **same positioning engine anchors it in both
orientations** wherever the layout moves it (the tab bar to the landscape left panel, etc.). The
bubble points at the live `getBoundingClientRect()` of its target, **re-measuring each frame until the
geometry settles** (the route-change slide, the hidden-banner reflow, async fonts) and on resize /
rotation; a target absent in the current state is skipped. The bubble matches the in-game counter text
size (`0.85rem`, larger in landscape), wraps by word and never fills the width; its **tail** sits on
the edge facing the target, centred on it (clamped near a corner). The pure geometry (step lists,
`nextVisibleStep`, `placeBubble`) lives in `lib/coachmark.ts` (unit-tested); `Coachmark.svelte` is the
renderer. While the overlay is up the **advertising banner is hidden** (`app.coachActive`) so its
scroll does not run behind the scrim. The hidden DebugPanel offers a **Reset visited** control that
clears the flags so the walk-through replays on the next launch. In the **mock build** the overlay
does not auto-show (so it cannot block the Playwright smoke); `?coach` forces it on for the dedicated
e2e and the screenshots.
## Navigation
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
@@ -253,7 +279,8 @@ the surroundings in the light theme and a touch lighter in the dark theme, mappe
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.
the profile to show or hide it in place. It is also hidden while a first-run onboarding overlay is up
(`app.coachActive`), reappearing by this same condition once the overlay closes.
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