Files
scrabble-game/docs/UI_DESIGN.md
T
Ilia Denisov ad91bc728b
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
UI: taller tg-fullscreen header + labelled hub tabs
- tg-fullscreen: +20px header height — without the (removed) hamburger the
  title bar lost its bulk and sat flush on Telegram's native nav band.
- Settings/Comms hub tabs gain text labels under the icons (Settings /
  Profile / Friends / Info and Chat / Dictionary); the icon is aria-hidden
  so the label names the button. New i18n keys about.tab, game.dictionary.
2026-06-11 15:12:40 +02:00

15 KiB
Raw 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; 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 announcement strip, the content, and an optional bottom tab bar (the tab bar always sits at the screen bottom). 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 (buttons top-aligned), pinning the board and controls 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).
  • 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.
  • 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; closing confirmation is enabled while a game is open; vertical swipes (swipe-to-minimise) are disabled so they don't fight tile drag or the board scroll; 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. The swipe-to-open-history gesture stays dropped (it fought native scroll); history 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 word column does not jitter. A move's row lists every word it formed (the main word first). 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. 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).
  • Highlights: pending tiles use a slightly darker tile background (no outline). The last completed word gets a dark tile background — static while it is the opponent's turn (our word), and a 1 s flash 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 Skip and Hint tabs (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: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 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".

Announcement banner (components/AdBanner.svelte, lib/banner.ts)

A one-line inset strip under the nav bar, drawn on a dedicated --ad-bg token — a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter in the dark theme, mapped to Telegram's secondary_bg_color inside the Mini App. Content is minimal markdown (text + links, escaped + linkified). A parameterised rotator drives messages: a fitting message holds holdMs (default 60 s) then cross-fades to the next; a message wider than the strip pauses (edgePauseMs), scrolls to its right edge at scrollPxPerSec, pauses, and repeats until the cycle exceeds holdMs. Today a mock provider rotates a long and a short message; the source becomes a server-driven channel later (see ARCHITECTURE).

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

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; creating one is the "Play with friends" mode in NewGame.svelte (pick invitees, then variant / move time / hints).
  • 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. Flex text inputs carry min-width:0 so they shrink instead of overflowing in Safari.
  • History / GCG: the in-game slide-down history gains the running total per move; 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.

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