Files
scrabble-game/docs/UI_DESIGN.md
T
Ilia Denisov 3306a016a0
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
feat(ui): per-kind active-game limit lock on the New Game screen
Carry the caller's per-tier active-game caps and each game's kind on the
wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode
+ client codec, committed regen). The New Game screen counts the player's
active games per kind from the lobby and locks a capped start: an outline
button with a lock that opens a funnel modal instead of a game -- a sign-in
prompt for a guest, a "finish a current game first" notice for a signed-in
account (native Telegram popup, in-app modal elsewhere). The lock lifts via
the existing profile refetch after a guest->durable upgrade.

Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag
(now the random-kind cap) conflicted with the per-kind lock -- it hid the
screen where the lock lives and wrongly blocked an unfulfilled kind. The New
Game tab is always enabled; the per-kind start lock is the only gate. The
at_game_limit wire field stays (unused by the client) for a later cleanup.

Tests: client lock logic + codec kind/game_limits roundtrip + gateway
transcode encode + native popup builders (unit); a mock e2e for the lock
badge and the modal.
2026-07-10 10:09:45 +02:00

40 KiB
Raw Permalink Blame History

Scrabble Game — UI design system

Visual and interaction conventions for the ui client. Behaviour lives in FUNCTIONAL.md; cross-service architecture (including the global points this doc references) lives in 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. 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). A hint auto-zooms centred on the hint's placement, not the top-left.
  • 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 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 score bar, status, 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 score plaques, the always-open history (docked and scrolling with its header sticky — no slide-down drawer, no score-bar toggle), the status line (bag · turn · score preview), the rack (+ the make control) 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 tiles size to the (fixed-width) panel rather than the viewport width so seven tiles never overflow the column.
  • Highlights: pending tiles use a slightly darker tile background (no outline). 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). While placing, only the pending tiles are highlighted.
  • 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).
  • MakeMove / Reset: when ≥1 tile is pending the rack collapses its used slots and shifts left, a borderless icon button (styled like a tab, not a filled accent button) beside the rack 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 (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. The 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 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 34-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).