ae5090b851
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The Telegram SDK was a render-blocking <script src="telegram.org/..."> in the shared index.html shell, so it ran on every entry (/telegram/, /app/, native). On a network that blocks telegram.org — common where Telegram itself reaches users only over a proxy — the script hangs forever, stranding the whole page, including the launch-diagnostic screen meant to surface exactly this failure. This is the likely root cause of the Android "won't open" reports (all launch methods fail identically; iOS on a different network works). Remove the head <script> and load the SDK dynamically (lib/telegram.ts loadTelegramSDK) with a 10s timeout, only on a Telegram entry (the /telegram/ path or a tgWebApp launch fragment). The SPA — served from our own reachable origin — boots first and controls the load: on a block/error it falls through to the diagnostic screen (reporting sdk: no) instead of hanging, and Retry re-attempts. /app/ and the native build no longer touch telegram.org. Pin the SDK to the version the official page recommends (?62) for the newer client features the app already uses (fullscreen, safe areas, swipe guard). Tests: loadTelegramSDK unit tests (present / error / timeout); an e2e that aborts the script fetch and asserts the diagnostic still renders.
382 lines
31 KiB
Markdown
382 lines
31 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; 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.
|
||
|
||
## 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`).
|
||
- **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. 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. 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, 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 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.
|
||
|
||
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 💌. 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.
|
||
|
||
**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. 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 / 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 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.
|
||
|
||
## 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).
|