# 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; opened outside Telegram, the `/telegram/` path redirects to the site root. ## 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`. ## 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). 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`). - **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 native header **BackButton** drives back-navigation (the app's chevron is hidden in Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile** clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API 8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window; **closing confirmation** is enabled while a game is open **on mobile only** (on desktop closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts auto-save); **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`). ## 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. - **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. 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 recalled tile returns to its original rack slot. - **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, and the status bar shows a small **1️⃣** in the score-preview slot that yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains a 🤝 **add-friend** control (non-guests; hidden once a friend, disabled once requested) that confirms via the fading-✅ tap, swapping the card's score for "Add friend?" while armed (see Controls); the name and score stay centred — the 🤝 is pinned to the card's edge. 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**. - **Grid lines** — a Settings toggle, **default off**. Off: a **gapless checkerboard** — 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. On: the classic lined grid, where the inter-cell gap shows a contrasting `--cell-line` (darker in light, lighter in dark) to avoid a wavy-line optical illusion. ## 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 🤝**. 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. The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer live outside the components, so navigating between screens (which remounts the view) **continues the cycle** rather than restarting it — each mounted `AdBanner` only attaches as the DOM host and resyncs to the live message. A **smooth weighted round-robin** (`createScheduler`) picks the next message: campaigns compete by their weight (each appears its weight share per cycle, evenly interleaved, not at random), and a campaign's own messages advance round-robin. One message fades in (`fadeInMs`), holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats while under `holdMs`), then — with more than one message — fades out (`fadeOutMs`), waits `gapMs`, and the next fades in. The fade (a `{#if}` `transition:fade` layer) is independent of the scroll (the inner track's transform), so a long, scrolling message still fades at both ends. A lone message stays put (a long one keeps scrolling). All timings are operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap and a long message does not scroll. ## Result / status iconography (`lib/result.ts`) 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 💌. ## 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. 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 the chat's send field and 🛎️ nudge stay **disabled** while the 🔎 dictionary word-check keeps working. - **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat cards (wins / losses / draws / games / win-rate / best game / best move) — pure numbers, no charts. - **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//?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 **same bot the player signed in through** (its service language). 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 right bot (`@`), not a red error on the Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in Safari. - **History / GCG**: 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); *Export GCG* (the 📤 in the history header) shares or downloads the `.gcg` file and appears only once the game is finished. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header offers *Export GCG* (not *Drop game*) and the comms hub hides the 🔎 *Dictionary* tab; 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 ``, 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. ## 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).