6d1d8030e3
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
Store the hints a player used in each game, and add two lifetime tiles — Moves and Hint share — to the statistics screen. - per-game: game_players.hints_used now counts EVERY hint (allowance + wallet), not just the free allowance, so it is the seat's true total hints used this game. The allowance decision (used < HintsPerPlayer) and the hint badge values (hints_remaining / wallet_balance) are unchanged — the lobby hint-count fix does NOT regress; only the admin "hints used" column, which silently under-counted wallet hints, becomes accurate. - account_stats gains two summed counters: moves (the player's plays — passes and exchanges excluded) and hints_used (every hint). Computed at game finish in buildStats over the same non-guest, non-honest-AI games as the rest of the stats. - wire: StatsView gains moves + hints_used (trailing); gateway + UI codec + model; regen. - ui: two tiles (Moves, Hint share = hints_used/moves, one-decimal % in the active locale); card order games·wins·draws·losses·moves·hint-share·best game·win-rate. - docs: ARCHITECTURE §9 + baseline comment, FUNCTIONAL (+ru), UI_DESIGN. Tests: TestHintPolicy (hints_used counts the wallet hint), TestGameLifecycleAndStats (moves>0, hints=0), gateway stats round-trip, UI hintShare unit + codec + e2e.
334 lines
26 KiB
Markdown
334 lines
26 KiB
Markdown
# 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. 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.
|
||
|
||
## 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 💌. 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).
|
||
|
||
**Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list`
|
||
`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared
|
||
`.tab:disabled` opacity) and a plain, low-emphasis line — muted, centred, no accent — sits
|
||
under the lists with the localized "limit reached" notice. Both follow the flag carried in the
|
||
cached lobby snapshot, so they render in their last-known state on entry (the button stays
|
||
enabled on the first, uncached load) and flip in place when an event refreshes the lobby.
|
||
|
||
## 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 (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: one row per played variant (catalogue order, empty variants
|
||
omitted) with the variant name on the left, 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) right-aligned to a shared edge, and the
|
||
score right-aligned in its own column.
|
||
- **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 **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 (`@<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 / 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 — and
|
||
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
|
||
it closes the history drawer (portrait) and zooms the board out.
|
||
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||
offers *Export GCG* (not *Drop game*; an AI game offers neither) 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 `<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.
|
||
|
||
## 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).
|