# Scrabble Game — UI design system

Visual and interaction conventions for the `ui` client. Behaviour lives in
[`FUNCTIONAL.md`](FUNCTIONAL.md); cross-service architecture (including the global
points this doc references) lives in [`ARCHITECTURE.md`](ARCHITECTURE.md). The client
is **pure HTML5/CSS + Unicode** — no image/font/SVG assets; icons are CSS shapes or
emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark via
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**:
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
runtime; on that path without sign-in data (no `initData` — outside Telegram, or a Mini App
launch that delivered none, as seen on some Android clients) the app renders a compact,
shareable launch-diagnostic screen (`screens/TelegramLaunchError.svelte`) rather than
redirecting to the site root. `telegram-web-app.js` is loaded **dynamically with a timeout**,
only on a Telegram entry — not a render-blocking `<script>` in the shared `index.html` shell —
so a network that blocks `telegram.org` cannot hang the page; `/app/` (web) and the native build
never load it.

## Layout shell (`components/Screen.svelte`)

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`.

## Loading splash (`components/Splash.svelte`)

On a **cold app open** the lobby is the landing screen, but its game list arrives over the
network — on a slow link the empty "no games yet" line would flash before the games load. A
full-screen **tile splash** covers that gap: it lays a small Scrabble crossword out of the
words **ЭРУДИТ** / **ЗАГРУЗКА** / **ОЖИДАНИЕ**, tile by tile, until the lobby's first load
settles, then removes itself to reveal the populated list. The tiles carry their **Эрудит
point values** (hardcoded in `lib/splash.ts`, since the alphabet table the board's
`valueForLetter` reads is not cached yet at boot) and mirror a placed board tile's look
(cream stock, bottom edge, drop shadow). The words form a 6×8 crossword: ЭРУДИТ horizontal,
ЗАГРУЗКА and ОЖИДАНИЕ vertical, crossing it through the shared **Р** and **Д** (laid once).

It is an **App-level overlay** shown while `routeIsLobby && !app.splashDone` (so it also
covers the session bootstrap; a deep-link to another screen is not covered). The lobby sets
`app.lobbyReady` when its first load settles (success **or** error). Each word is laid, then
**held ~0.25 s** so it stays readable, and only after that hold does the readiness check fire —
so a word never blinks away the instant it finishes. ЭРУДИТ lays + holds over ~1.25 s, then the
splash loops ЗАГРУЗКА → ОЖИДАНИЕ (clearing back to ЭРУДИТ between rounds) until ready, so even a
fast load shows it for ~1.25 s. Each tile **drops in** with a brief scale + fade. Under **reduced motion**
(or the mock build, to keep the Playwright smoke unblocked) it shows a static ЭРУДИТ and
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`
  `.chev`) — lighter than a glyph.
- **No hamburger**: navigation is entirely bottom tab bars (the former `Menu.svelte`
  dropdown is gone — it fought the Telegram-fullscreen layout, where it had to be re-centred).
  Destinations beyond the lobby live behind **hub screens** reached from a tab: a ⚙️
  **Settings hub** (`screens/SettingsHub.svelte`, the lobby's ⚙️ tab) and an in-game
  **comms hub** (`game/CommsHub.svelte`, the history's 💬). A hub owns one nav bar + one
  bottom tab bar whose tabs switch its body **in place** (state, not navigation), so the
  back control always returns to the hub's parent (Settings → lobby, comms → game). Settings
  hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests);
  comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active; an
  honest-AI game has no Chat, so it shows the Dictionary alone). The
  routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
  entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The
  **Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so
  it slides in/out like a sub-screen rather than switching a tab in place.
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
  emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the
  button). A press highlights a rounded **square** behind the icon; a hub's **selected** tab
  stays highlighted (a filled
  square with an accent underline). A red count **badge** rides the icon's corner — on the
  lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations
  keep their own lobby section), and on the Hint tab for the remaining count. No text
  selection on nav / tab-bar / buttons (`user-select: none`), and no tap-highlight flash on any
  tappable element (`-webkit-tap-highlight-color: transparent` on `#app`) — Android in-app WebViews
  otherwise draw a momentary selection-like box on a clickable node on tap (seen on the header title
  and the back chevron).
- **Screen transitions** (`App.svelte`): navigation slides directionally — a
  screen entered from the lobby flies in from the right; returning to the lobby reveals it
  from the left (back). Transitions are local (so they do not play on first load) and
  collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
  (`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
  and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
  on lobby ↔ game navigation. The caches are kept fresh **across** screens, so the instant render
  is already current and not a stale frame the background refresh then corrects. The single global
  stream handler (`lib/app.svelte.ts`) is the one place that runs for every live event regardless of
  the mounted screen, so it owns cross-screen freshness: it upserts the affected game's `GameView`
  into the lobby snapshot (`patchLobbyGame`) on every game event — opponent move, game-over,
  opponent-joined, and a match/started game (which it also adds) — patches the lobby's invitations
  list (`patchLobbyInvitation`: a still-pending invitation upserted, a terminal one removed) on the
  `invitation` / `invitation_update` notifications, and advances a not-currently-viewed game's own
  cache from the move/over/joined delta (`advanceCached`, skipping the game in view, whose mounted
  board owns its cache). The game board additionally mirrors the player's **own** move and its own
  `load()` into the lobby snapshot — the two updates no live event carries.
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour
  scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
  which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
  theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
  `setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
  app's **own back chevron** (Header) drives back-navigation on every platform — the native
  Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
  **HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
  bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
  the native header (and its BackButton) and the Android system swipe-back then minimised the app,
  so it stays windowed with Telegram's thin native header (close) above the app's own header; move
  drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten
  quick taps on the header title) shows and shares a privacy-safe client diagnostic snapshot for
  support; **vertical swipes** (swipe-to-minimise) are disabled so they don't fight tile drag or
  the board scroll; **external links** (the word-check
  dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink`
  so Telegram shows them in its in-app browser instead of the WebView's "open this link?"
  confirmation a plain `target=_blank` triggers; and a live stream dropped
  by a background suspend reconnects silently on return — the connection banner is
  suppressed while hidden and for a short grace after resume (visibilitychange +
  pageshow/pagehide + Telegram `activated`/`deactivated`).
- **VK integration** (`lib/vk.ts`): inside the VK Mini App the **auto** theme follows the VK
  client's light/dark (`VKWebAppUpdateConfig`), as the VK webview's `prefers-color-scheme` does not
  track it; explicit light/dark prefs still win. The device safe-area comes from CSS
  `env(safe-area-inset-*)` (the meta already sets `viewport-fit=cover`) **max'd** with the VK bridge
  insets (`VKWebAppUpdateInsets`, needed on Android, where the VK webview exposes no `env()` inset),
  so the layout clears the VK home bar. Share and copy route through the bridge (`VKWebAppShare` /
  `VKWebAppCopyText`) because `navigator.share` / `clipboard` are absent in the desktop VK iframe.
  Haptics fire the same set as Telegram via VK Bridge taptic (`VKWebAppTapticImpactOccurred` /
  `…NotificationOccurred` / `…SelectionChanged`), through the shared `lib/haptics.ts` dispatcher; and
  VK's **horizontal swipe-back** is disabled at launch (`VKWebAppSetSwipeSettings`) so it does not
  fight the app's own edge-swipe-back and tile drag — parity with Telegram's disabled vertical
  swipes, the app owning navigation through its back chevron. VK's **status bar** (and, on Android,
  the action / navigation bars) is painted to the app theme via `VKWebAppSetViewSettings` on launch
  and every theme change — parity with the Telegram chrome painting (`syncVKChrome` mirrors
  `syncTelegramChrome`); the status-bar appearance follows the `--bg` token's luminance
  (`appearanceForBg`).

## Tiles & board

- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
  value), the point value bottom-right; blanks show no value. In **Erudit** the blank is the
  "звёздочка" (star) chip: an unplaced blank shows the star (`✻`, U+273B) centred on the rack
  tile, and a placed blank carries it in the value corner; the Scrabble variants leave the
  blank unmarked (`usesStarBlank` in `lib/variants.ts`).
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
  the board's width** inside a fixed-size viewport (a real layout change → native scroll
  that works consistently across browsers; no `transform`, which broke scrolling
  differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
  they stay a constant size as the cells grow (relatively smaller at higher zoom).
  **Double-tap** an empty/filled cell toggles zoom centred on it; double-tap a **pending**
  tile recalls it. **Pinch** zooms toward the pinch midpoint (a two-finger gesture;
  preventDefault fires only for two touches, so one-finger scroll stays native, and a second
  finger aborts an in-progress drag). The pan is **interpolated toward a pre-clamped target**
  as the real width grows/shrinks, so it magnifies evenly A→B instead of lurching and snapping
  back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
  hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
  in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
  the first time — both gated by the **Zoom the board** setting (on by default; the toggle is
  shown only on a touch device). A **swipe down on the zoom-out board** opens the history, but only when the
  board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
  that once retired this gesture) — and it is suppressed on the zoomed board, where the
  one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
  viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
  zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
  opens on a **tap of the score bar** and closes on a tap or
  an **upward swipe** of the then-inert board (below). Taking a **hint** while zoomed in **zooms
  out** to the whole board, so the hint's word — highlighted (below) — is never left off-screen.
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
  dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
  cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
  **double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
  the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger). A
  **double-tap** returns the tile to its original rack slot; **dragging it back** drops it at the
  rack slot the pointer is over (a gap opens there), the same drag-to-position as a rack reorder.
- **Players plaque & history** (`Game.svelte`): the seats above the board share
  the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
  while the others read **sunk in** (an inset shadow). A tap on the plaque toggles
  the **move history** — a fixed-height slide-down drawer whose bottom border (and its
  shadow) pins to the board as the board slides down, instead of tracking the table as
  moves accumulate; its scrollbar gutter is reserved so the centred cells do not jitter. The
  table is shown **scrolled to its newest row**: the grid fills top to bottom, oldest first, so an
  untouched scroller would open on the opening moves — the least interesting end. It is pinned once
  per showing (in landscape, once the moves reach the already-docked table), never on each arriving
  move, so a player reading back through a long game is not yanked to the bottom by an opponent's
  move. The
  history is a **ruled matrix** — one column per seat, aligned under the score plaques, each
  seat's moves filling its column top to bottom: no player names (the plaque heads the column)
  and no running total, just the word(s) and the move score, `WORD (12)`, centred. A non-play
  move keeps its dim parenthesised tag `(pass)`; the seat whose move is awaited shows a dim
  `thinking…` in its next cell (never the viewer's own). While the history
  is open the **slid board is inert** and the stage cannot scroll, so the whole board reads
  as a **tap-or-swipe-up-to-close** surface — closing genuinely clears the open state (it no
  longer merely scrolls the board out of view, which used to leave a stale-open state that
  made a follow-up plaque tap "jump" the board). The drawer carries its own **header**: a 🏁
  **Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬
  (badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian
  game with "multiple words per turn" off) centres a **"One word per turn"** label between
  those two icons; standard games show no label. Each **opponent**'s card also gains two
  edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend,
  disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap,
  swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while
  armed (see Controls); while one is armed the other hides so they never overlap. Once the
  opponent is **blocked** both controls go and the **name is struck through** (and the comms-hub
  chat composer is hidden — only the log remains). The name and score stay centred — the controls
  are pinned to the card's edges.
  Unread chat is also badged on the score bar itself, so it shows with the history closed.
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
  board area scrolls vertically (`Screen` `column` mode; the turn strip, score bar, rack and tab
  bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
  `Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
  overlays the empty area below, so the layout doesn't resize/jank; other modals stay
  keyboard-aware (they size to the area above the keyboard).
- **Landscape (wide) layout** (`Game.svelte`): when the viewport is wider than tall
  (`matchMedia('(orientation: landscape)')`) the game switches from the portrait stack to a
  **two-column** layout. The board fills the **right** column; it fits by **height** as a square
  (`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a
  `container-type: size` pane, shrinking by width when the column is narrow — the board has the
  **lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to
  bottom: the **turn/result strip**, the score plaques, the **always-open** history (docked and
  scrolling with its header sticky and the **bag count** pinned at its foot — no slide-down drawer,
  no score-bar toggle), the rack (with the ✅ confirm control in its fixed 7th slot) and, pinned at
  the bottom, the controls tab bar. Board
  **zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the
  full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past
  the pane and pans within it, occupying the full width up to the left panel (the focus-centred
  scroll is set directly, without the portrait width-progress tween). Only the history open/close
  swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The
  portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the
  behaviour and markup stay single-sourced. The rack is a **seven-column grid** filling the tray
  width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes
  the fixed 7th slot while a play is staged). The landscape tray shares the narrow left panel, so its
  tiles — and with them the `cqw`-sized glyphs — come out smaller than the full-width portrait tray:
  the **letter** and Erudit's **blank star** are scaled up a quarter there (a `(orientation:
  landscape)` query in `Rack.svelte`) to stay comfortably legible, while the point value keeps its
  size — it reads fine small, and growing it would crowd the letter on a tile that size.
- **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light
  green once they form a word (a shade darker on the committed board tiles the word runs through —
  in a **single-word game** only the main word lights up, since the engine ignores cross words),
  a calm pink when they form none — and an **orange score badge** sits on a corner of the main
  word's tile, its digit sized like a tile's point value (so it scales with the zoom) and clamped
  to stay on the board. Off-turn or with no preview the staged tiles keep the plain pending fill.
  The colours are theme tokens in `app.css` (`--tile-pending-legal` / `-illegal`, `--tile-formed`,
  `--score-badge`); the geometry — which cells a word covers and where the badge anchors — is a
  pure client-side helper (`lib/formed.ts`), independent of the move evaluator. The
  last completed word keeps the normal tile background; instead its letters — not the point
  values — are drawn in the recent-move colour, in both themes. It is static while it is the
  opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour
  and the recent-move colour) when it is our turn (their word).
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
  split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
  nothing. Default **beginner**.
- **Board surface** — always a **gapless checkerboard** (there is no grid-lines setting):
  plain cells alternate two shades, and tiles get rounded corners with a soft right-side
  shadow so adjacent gapless tiles still read apart, reclaiming ~14px of board width.

## Controls

- **TapConfirm** (`components/TapConfirm.svelte`, logic in `lib/tapconfirm.ts`): the shared
  tap-to-confirm control. A first tap arms a ~2 s window showing a **fading ✅** (no fade
  under reduce-motion, but the window still holds); a tap on the ✅ within it confirms,
  otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
  the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
  controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
- **Confirm / Reset**: while ≥1 tile is pending a **borderless ✅ icon button** (styled like a
  tab, not a filled accent button) takes the rack's **fixed 7th slot** and commits the move — no
  popover, and disabled while the pending word is known illegal; the 🔀 Shuffle tab is replaced by
  a **↩️ Reset** tab.
- **Game tab bar**: 🔄 Exchange/Pass (a **bag-count badge** on its icon while the bag is non-empty;
  opens a dialog — pick tiles to **Exchange N**, or pick
  none to **Pass without exchanging**; pass is always available on your turn, exchange only
  while the bag still holds a full rack, below which the tiles disable and only the pass
  remains), ✨ Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
  no confirm), which
  **animates** — tiles hop along a low parabola to their new slots (duration scaled by the
  distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
  above the score plaques** reads, while composing a legal play, the formed word(s) and the move
  score ("WORD+WORD = N"); otherwise whose turn it is, or the viewer's result once the game ends. The
  move score also rides the board badge (above), never an under-board caption.
  The screen **title** is the variant's
  display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".

## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)

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. 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
cycle** rather than restarting it. A mounted `AdBanner` reads the engine's live message at init and
renders it **immediately, without a fade** (`bannerCurrent`), and attaches only as the DOM host — so
a screen change does not replay the fade; only a real message advance fades. 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 — while still
under `holdMs` — fades out, rewinds to the start hidden, then fades the **same** message back in to
scroll again), then fades out (`fadeOutMs`), waits `gapMs`, and the next fades in. The fade (opacity
on a `.fadewrap` layer) is independent of the scroll (the inner track's transform). **Every** cycle
boundary fades — a message change, a scroll rewind, and a **lone** message (which pulses out and back
in rather than sitting frozen) — so the rhythm is uniform. So the strip does not jump in height while
the message is momentarily gone during `gapMs`, it reserves one text line with an invisible spacer
and overlays the message absolutely. When a navigation remounts the view mid-scroll, the engine
**resumes the scroll from where it had reached** (`resumeScroll`) instead of restarting it at the
left. A **viewport size change** (e.g. a portrait↔landscape rotation) re-measures the current message
(`remeasureBanner`, debounced), so its scroll re-evaluates for the new width. 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.

**Per-campaign colours.** A campaign may override the strip's colours (background, text, link). The
engine carries the current message's campaign colours to the host, and `AdBanner` resolves them for
the **rendered theme** (`lib/bannerColors`: dark ← dark-set ?? all-set, light ← all-set) and applies
them as inline CSS variables scoped to `.ad` (`--ad-bg`, `--text-muted`, `--accent`, and a derived
`--ad-border`) — so an override never leaks past the strip, and a campaign with no override keeps the
neutral tokens. The border is computed from the background in JS (a luminance-aware nudge toward
black/white — no CSS `color-mix`, which the old Android WebView floor lacks), matching the admin
console's live preview. The resolution re-runs when the theme flips (a `[data-theme]` /
`prefers-color-scheme` observer), so switching light↔dark repaints an overridden strip at once. An
**urgent** campaign has no client-specific styling — it simply arrives (with its colours) as the only
campaign in the feed; the preempt-and-show-everyone behaviour is entirely server-side (ARCHITECTURE
§10).

## Result / status iconography (`lib/result.ts`)

Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-player games II 🥈 / III 🥉 /
IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. The score line
lists seats in **seat-number order** (matching the over-the-board scoreboard) in a **bold**,
slightly smaller line; on an **in-progress** game the viewer's **own** number is tinted `--ok`
when leading or tied and `--danger` when trailing, and an opponent's number is tinted `--ok` only
when it **ties the viewer for the lead** (so an equal non-zero score paints both numbers green);
otherwise numbers stay muted, as does a fresh **0:0** board where nobody has scored yet — a quick
"am I ahead" read that finished games leave to the place emoji. When a listed
game **becomes your turn or finishes** while the lobby is open, that status emoji **blinks
twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the
opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping
events animate in isolation, and the newest bottom toast cancels the previous one and replays
its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence). An **info** toast
lives ~2 s, drifting up by about the tab-bar height as it fades (a plain fade, no travel, under
reduce-motion); an **error** toast dwells longer (~4 s). Either dismisses instantly on tap.

**Active-game caps.** A player's simultaneous unfinished games are capped per kind (vs AI / random /
friends) against the caller's per-tier limits (`Profile.game_limits`), counted client-side from the
lobby games. The lobby's **New Game** tab is **always enabled** — the lock lives on the per-kind
start, not the tab. On the **New Game** screen a start whose kind is at its cap renders as an
**outline** button with a **🔒** (not the filled accent CTA), so it reads as gated rather than ready;
tapped, it opens a short prompt instead of a game — inside Telegram a **native popup**, elsewhere the
in-app **Modal**. A **guest** sees a sign-in funnel (Cancel / Sign in → the account screen); a
signed-in account at its cap sees a plain "finish a current game first" notice (OK). The lock lifts in
place when the profile is re-fetched after a guest→durable upgrade.

## Social, account & history surfaces

- **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom
  tab bar over four bodies — ⚙️ Settings, 👤 Profile, 🤝 Friends, ℹ️ About — switched in
  place; back always returns to the lobby. The **selected** tab is marked by a filled pill
  hugging its icon **and** label (with an accent underline) — a persistent selection cue that
  the plain, unhighlighted action tabs (lobby nav, in-game controls) never carry. Guests see
  all but Friends. The lobby ⚙️ badge and the 🤝 tab badge both show the pending
  incoming-friend-request count.
- **Friends** (`screens/Friends.svelte`, the Settings hub's 🤝 tab): an "add a friend" block
  pairing a code **input** with a **Show my code** action that reveals a large 6-digit
  code + its expiry; then the incoming **requests** (Accept / Decline), the **friends**
  list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a
  guest sees a sign-in prompt.
- **Invitations**: a lobby **section** (a 💌 row per open invitation) with Accept /
  Decline for an invitee and a waiting/Cancel state for the inviter; a single-word-rule
  invitation adds a **"One word per turn"** line to the card. Creating a game lives in
  `NewGame.svelte`: **"Play with friends"** (pick invitees, then variant / move time /
  hints) and **quick game**. Quick game opens with a two-button **🤖 AI / 👤 Random player**
  segmented select (the `.seg`/`.opt` style, **AI the default**) in place of the old subtitle — AI
  is an honest robot you knowingly play, Random player is auto-match against a human. The variant plaques are
  **mutually-exclusive selects** — a tap **highlights** one (an accent inset border) instead
  of starting a game; a lone offered variant is pre-selected, and a bottom **Start game**
  button (disabled until a variant is chosen) confirms. For a **Russian** variant (either
  flow) a **"Multiple words per turn"** checkbox (`.toggle`, **default off** = the single-word
  rule) appears once that variant is selected; English variants never show it. Starting an
  auto-match **enters the game immediately** and waits inside it: until an opponent joins, the
  opponent's score card (and the game's lobby row) reads the localized **"searching for opponent"**
  placeholder, the add-friend 🤝 is hidden, and resign and the chat's send/nudge are disabled; an
  **opponent_joined** push restores them in place when a human or robot takes the seat, and a line
  under Start game notes the wait can take a while (the app may be closed meanwhile). With **AI**
  selected there is no wait: the move-clock line instead reads **"Loss after 7 days of inactivity"**
  and the "searching" hint is hidden; the game starts already seated, the opponent shows as **🤖**
  everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and there is
  **no chat at all**: the comms hub drops the 💬 Chat tab and offers only the 🔎 dictionary
  word-check (the 🛎️ nudge is likewise gone).
- **Statistics** (`screens/Stats.svelte`, the lobby ✏️ tab): a 2-column grid of stat
  cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) —
  pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's
  notation (e.g. "4.8%" / "4,8%") — followed by a **full-width "best move" card** that
  breaks the best move
  down per variant: two lines per played variant (catalogue order, empty variants
  omitted) — the variant name on its own line, then below it the score (right-aligned
  in a shared column so the scores align) and the **word drawn as game tiles**
  (`components/WordTiles.svelte`, the board's placed-tile look at a small fixed size;
  a wildcard shows its letter but no value) left-aligned, so long words get the full width.
- **Profile editing** (`screens/Profile.svelte`): an inline form — display name, a
  **UTC-offset** timezone dropdown (defaulting to the browser's offset), the away
  window as hour + 10-minute dropdowns (24-hour, ≤ 12 h), and block toggles — plus an
  email-binding sub-flow (enter email → enter the confirm code on a numeric field).
  Invalid fields show a **red border** (no message) and **Save stays disabled** until
  every field is valid. Interface language stays in **Settings** (it writes through to
  the account for durable users).
- **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or
  the icon copies it. **Share via Telegram** wraps the code in a `t.me/<bot>/<app>?startapp=`
  deep-link and opens Telegram's native share-to-chat sheet (Web Share / clipboard fallback
  outside Telegram); the link and its caption are for the **single bot**. Redeeming your **own**
  invite shows a friendly note, not an
  error; opening an **outdated** invite link (a used or expired single-use code) lands in the
  lobby with a calm modal pointing at the bot (`@<username>`), not a red error on the
  Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
  Safari.
- **History / export**: the in-game slide-down history lays each move out in a per-seat grid
  (the word(s) and the move score, no running total); the 📤 in the history header appears
  only once the game is finished — never in an honest-AI game (throwaway practice) — and
  opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe
  there, since that chain is all bridge calls needing no user activation (a popup callback
  must never lead into `navigator.share`/clipboard, the on-device finding) — and the app's
  own modal elsewhere (TG iOS delivers via the OS share sheet, which needs the modal
  click's activation; VK has no native chooser; the accent button leads with the image,
  both options need the connection). Both formats mint the same signed relative link
  (`game.export_url`), resolved against the app's own origin, then delivered per platform
  (each branch owner-verified on-device): TG Android/desktop `downloadFile` (its preview
  shares onwards); TG iOS the OS share sheet with the fetched file; VK iOS
  `VKWebAppDownloadFile` for both formats (VK's native share flow); VK Android — whose
  DownloadFile hangs — the PNG in VK's native image viewer (`VKWebAppShowImages`, its save
  works) and the GCG to the clipboard; the VK desktop iframe plain anchor downloads; a
  mobile browser the OS share sheet (the proven fetch-then-share pattern); a desktop
  browser an anchor download. A legacy Telegram
  client without `downloadFile` keeps the old GCG clipboard copy, hides the image option
  and stays on the app modal. Confirming a resign reveals the full board: it closes the
  history drawer (portrait) and zooms the board out.
- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar
  executes; the browser app no longer draws it): always the light palette (pinned constants
  mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as
  plain colour fills (no text labels), tiles in the in-game style (bevel, letter top-left,
  value bottom-right, ✻ for an Erudit blank), no last-move highlight. The right column is
  the scoresheet: per-seat name over the final score (🏆 by the winner), one
  fixed-typography row per move — muted classic coordinate (across = row-first `8G`, down =
  column-first `H8`), the main word, points right-aligned; extra words of a multi-word play
  on a smaller second line; italic localized notes for pass/exchange/resign/timeout; a
  closing muted ± row for the endgame rack settlement when present. Footer:
  `hostname · finish date` in the **device** locale. The scoresheet's height drives the
  board side (never below its minimum): a long game stretches the board, never the
  typography, so the image has no dead space.
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
  offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
  the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
  hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
  row is turn-driven: on your turn the message field shows until your one allowed message is
  sent, then a short caption replaces it until the next turn; on the opponent's turn the 🛎️
  nudge takes the field's place.
- **Feedback** (`screens/Feedback.svelte`, reached from a button at the bottom of the ℹ️ Info
  tab, registered accounts only): a multi-line message field over a row that pairs a **ghost**
  attach button (a hidden `<input type=file>`, with a chosen-file name + a remove control) with
  the filled accent **Send** button. A rejected file shows one generic notice (the allowed types
  are not advertised). The send controls disable while a previous message is under review or the
  account is barred; the operator's reply renders below as a titled block. An undelivered reply
  raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the
  Info tab.

## Dictionary warm-up overlay (`components/DictWarmup.svelte`)

Opening a game whose local move-preview dictionary is not yet cached shows a brief,
non-dismissable warm-up overlay while it downloads (a returning player's cached
dictionary loads from IndexedDB in a few ms, so the flash-guard suppresses it then).
A darker-than-onboarding scrim covers
the board; centred on it, a large emoji cycles through a fixed playful sequence — one
per 500 ms tick (300 ms still, then a 200 ms clockwise spin with the current glyph
fading out and the next fading in), looping — under a constant "Loading…" caption.
Reduce-motion drops the spin to a plain cross-fade. A ~120 ms flash-guard suppresses
the overlay for a disk-cached dictionary that loads in a few ms; a cold (network) load
shows it until the dictionary is ready or a 5 s cap elapses, after which the preview
falls back to the network. See docs/ARCHITECTURE.md §5 (the local eval).

## Caveat

Emoji are rendered by the platform's system emoji font, so their exact look varies across
OSes — acceptable for the MVP, and consistent with the no-asset rule (no glyphs are
downloaded).
