6636d7c309
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
A one-time coachmark overlay walks a new player through the lobby and their first game board: a light dimmed layer draws one tail-pointed hint bubble at a time, advancing on a tap anywhere and removing itself for good after the last hint. Two independent series (lobby: settings/stats/new game; game: header/pass-exchange/hints/shuffle/rack), gated by a per-device persisted flag and marked done only after the last hint, so an interrupted run replays from the start. A deep-link into Settings -> Friends still triggers the lobby series on the first trip back to the lobby. Targets carry a data-coach attribute, so one positioning engine anchors the bubble in both portrait and landscape, re-measuring each frame until the geometry settles (route slide, hidden-banner reflow, fonts). The promo banner hides while the overlay is up (app.coachActive); a hidden DebugPanel "Reset visited" control replays the walk-through. Off by default in the mock build so the Playwright smoke is unaffected; ?coach forces it on for the dedicated e2e. Pure geometry (step lists, nextVisibleStep, placeBubble) in lib/coachmark.ts (unit-tested); Coachmark.svelte renders. Docs: FUNCTIONAL(+ru) onboarding story, UI_DESIGN coachmark section.
421 lines
34 KiB
Markdown
421 lines
34 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.
|
||
|
||
## First-run onboarding coachmarks (`components/Coachmark.svelte`, `lib/coachmark.ts`)
|
||
|
||
A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an
|
||
**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings →
|
||
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle →
|
||
rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer
|
||
captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the
|
||
last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself;
|
||
a series is marked only after its **last** hint, so an interrupted player replays it from the start.
|
||
The lobby series gates on `app.splashDone`, the game series on its first target laying out; both defer
|
||
while a modal (`welcomeRedeem` / `staleInvite`) is open.
|
||
|
||
Each target carries a **`data-coach` attribute**, so the **same positioning engine anchors it in both
|
||
orientations** wherever the layout moves it (the tab bar to the landscape left panel, etc.). The
|
||
bubble points at the live `getBoundingClientRect()` of its target, **re-measuring each frame until the
|
||
geometry settles** (the route-change slide, the hidden-banner reflow, async fonts) and on resize /
|
||
rotation; a target absent in the current state is skipped. The bubble matches the in-game counter text
|
||
size (`0.85rem`, larger in landscape), wraps by word and never fills the width; its **tail** sits on
|
||
the edge facing the target, centred on it (clamped near a corner). The pure geometry (step lists,
|
||
`nextVisibleStep`, `placeBubble`) lives in `lib/coachmark.ts` (unit-tested); `Coachmark.svelte` is the
|
||
renderer. While the overlay is up the **advertising banner is hidden** (`app.coachActive`) so its
|
||
scroll does not run behind the scrim. The hidden DebugPanel offers a **Reset visited** control that
|
||
clears the flags so the walk-through replays on the next launch. In the **mock build** the overlay
|
||
does not auto-show (so it cannot block the Playwright smoke); `?coach` forces it on for the dedicated
|
||
e2e and the screenshots.
|
||
|
||
## Navigation
|
||
|
||
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
|
||
`.chev`) — lighter than a glyph.
|
||
- **No hamburger**: navigation is entirely bottom tab bars (the former `Menu.svelte`
|
||
dropdown is gone — it fought the Telegram-fullscreen layout, where it had to be re-centred).
|
||
Destinations beyond the lobby live behind **hub screens** reached from a tab: a ⚙️
|
||
**Settings hub** (`screens/SettingsHub.svelte`, the lobby's ⚙️ tab) and an in-game
|
||
**comms hub** (`game/CommsHub.svelte`, the history's 💬). A hub owns one nav bar + one
|
||
bottom tab bar whose tabs switch its body **in place** (state, not navigation), so the
|
||
back control always returns to the hub's parent (Settings → lobby, comms → game). Settings
|
||
hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests);
|
||
comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active; an
|
||
honest-AI game has no Chat, so it shows the Dictionary alone). The
|
||
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
|
||
entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The
|
||
**Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so
|
||
it slides in/out like a sub-screen rather than switching a tab in place.
|
||
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
|
||
emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the
|
||
button). A press highlights a rounded **square** behind the icon; a hub's **selected** tab
|
||
stays highlighted (a filled
|
||
square with an accent underline). A red count **badge** rides the icon's corner — on the
|
||
lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations
|
||
keep their own lobby section), and on the Hint tab for the remaining count. No text
|
||
selection on nav / tab-bar / buttons (`user-select: none`).
|
||
- **Screen transitions** (`App.svelte`): navigation slides directionally — a
|
||
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
||
from the left (back). Transitions are local (so they do not play on first load) and
|
||
collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
|
||
(`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
|
||
and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
|
||
on lobby ↔ game navigation. The caches are kept fresh **across** screens, so the instant render
|
||
is already current and not a stale frame the background refresh then corrects. The single global
|
||
stream handler (`lib/app.svelte.ts`) is the one place that runs for every live event regardless of
|
||
the mounted screen, so it owns cross-screen freshness: it upserts the affected game's `GameView`
|
||
into the lobby snapshot (`patchLobbyGame`) on every game event — opponent move, game-over,
|
||
opponent-joined, and a match/started game (which it also adds) — patches the lobby's invitations
|
||
list (`patchLobbyInvitation`: a still-pending invitation upserted, a terminal one removed) on the
|
||
`invitation` / `invitation_update` notifications, and advances a not-currently-viewed game's own
|
||
cache from the move/over/joined delta (`advanceCached`, skipping the game in view, whose mounted
|
||
board owns its cache). The game board additionally mirrors the player's **own** move and its own
|
||
`load()` into the lobby snapshot — the two updates no live event carries.
|
||
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour
|
||
scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
|
||
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
|
||
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
|
||
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
|
||
app's **own back chevron** (Header) drives back-navigation on every platform — the native
|
||
Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
|
||
**HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
|
||
bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
|
||
the native header (and its BackButton) and the Android system swipe-back then minimised the app,
|
||
so it stays windowed with Telegram's thin native header (close) above the app's own header; move
|
||
drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten
|
||
quick taps on the header title) shows and shares a privacy-safe client diagnostic snapshot for
|
||
support; **vertical swipes** (swipe-to-minimise) are disabled so they don't fight tile drag or
|
||
the board scroll; **external links** (the word-check
|
||
dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink`
|
||
so Telegram shows them in its in-app browser instead of the WebView's "open this link?"
|
||
confirmation a plain `target=_blank` triggers; and a live stream dropped
|
||
by a background suspend reconnects silently on return — the connection banner is
|
||
suppressed while hidden and for a short grace after resume (visibilitychange +
|
||
pageshow/pagehide + Telegram `activated`/`deactivated`).
|
||
- **VK integration** (`lib/vk.ts`): inside the VK Mini App the **auto** theme follows the VK
|
||
client's light/dark (`VKWebAppUpdateConfig`), as the VK webview's `prefers-color-scheme` does not
|
||
track it; explicit light/dark prefs still win. The device safe-area comes from CSS
|
||
`env(safe-area-inset-*)` (the meta already sets `viewport-fit=cover`) **max'd** with the VK bridge
|
||
insets (`VKWebAppUpdateInsets`, needed on Android, where the VK webview exposes no `env()` inset),
|
||
so the layout clears the VK home bar. Share and copy route through the bridge (`VKWebAppShare` /
|
||
`VKWebAppCopyText`) because `navigator.share` / `clipboard` are absent in the desktop VK iframe.
|
||
|
||
## Tiles & board
|
||
|
||
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
|
||
value), the point value bottom-right; blanks show no value. In **Erudit** the blank is the
|
||
"звёздочка" (star) chip: an unplaced blank shows the star (`✻`, U+273B) centred on the rack
|
||
tile, and a placed blank carries it in the value corner; the Scrabble variants leave the
|
||
blank unmarked (`usesStarBlank` in `lib/variants.ts`).
|
||
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
|
||
the board's width** inside a fixed-size viewport (a real layout change → native scroll
|
||
that works consistently across browsers; no `transform`, which broke scrolling
|
||
differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
|
||
they stay a constant size as the cells grow (relatively smaller at higher zoom).
|
||
**Double-tap** an empty/filled cell toggles zoom centred on it; double-tap a **pending**
|
||
tile recalls it. **Pinch** zooms toward the pinch midpoint (a two-finger gesture;
|
||
preventDefault fires only for two touches, so one-finger scroll stays native, and a second
|
||
finger aborts an in-progress drag). The pan is **interpolated toward a pre-clamped target**
|
||
as the real width grows/shrinks, so it magnifies evenly A→B instead of lurching and snapping
|
||
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
||
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
||
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
||
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
|
||
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
|
||
that once retired this gesture) — and it is suppressed on the zoomed board, where the
|
||
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
|
||
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
|
||
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
|
||
opens on a **tap of the score bar** and closes on a tap or
|
||
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
|
||
hint's placement, not
|
||
the top-left.
|
||
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
|
||
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
|
||
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
|
||
**double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
|
||
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger). A
|
||
**double-tap** returns the tile to its original rack slot; **dragging it back** drops it at the
|
||
rack slot the pointer is over (a gap opens there), the same drag-to-position as a rack reorder.
|
||
- **Players plaque & history** (`Game.svelte`): the seats above the board share
|
||
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
|
||
while the others read **sunk in** (an inset shadow). A tap on the plaque toggles
|
||
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
|
||
shadow) pins to the board as the board slides down, instead of tracking the table as
|
||
moves accumulate; its scrollbar gutter is reserved so the centred cells do not jitter. The
|
||
history is a **ruled matrix** — one column per seat, aligned under the score plaques, each
|
||
seat's moves filling its column top to bottom: no player names (the plaque heads the column)
|
||
and no running total, just the word(s) and the move score, `WORD (12)`, centred. A non-play
|
||
move keeps its dim parenthesised tag `(pass)`; the seat whose move is awaited shows a dim
|
||
`thinking…` in its next cell (never the viewer's own). While the history
|
||
is open the **slid board is inert** and the stage cannot scroll, so the whole board reads
|
||
as a **tap-or-swipe-up-to-close** surface — closing genuinely clears the open state (it no
|
||
longer merely scrolls the board out of view, which used to leave a stale-open state that
|
||
made a follow-up plaque tap "jump" the board). The drawer carries its own **header**: a 🏁
|
||
**Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬
|
||
(badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian
|
||
game with "multiple words per turn" off) centres a **"One word per turn"** label between
|
||
those two icons, 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. It is also hidden while a first-run onboarding overlay is up
|
||
(`app.coachActive`), reappearing by this same condition once the overlay closes.
|
||
|
||
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
|
||
live outside the components, so navigating between screens (which remounts the view) **continues the
|
||
cycle** rather than restarting it. A mounted `AdBanner` reads the engine's live message at init and
|
||
renders it **immediately, without a fade** (`bannerCurrent`), and attaches only as the DOM host — so
|
||
a screen change does not replay the fade; only a real message advance fades. A **smooth weighted
|
||
round-robin** (`createScheduler`) picks the next message: campaigns compete by their weight (each
|
||
appears its weight share per cycle, evenly interleaved, not at random), and a campaign's own messages
|
||
advance round-robin. One message fades in (`fadeInMs`), holds `holdMs` (a message wider than the
|
||
strip pauses `edgePauseMs`, scrolls to its right edge at `scrollPxPerSec`, pauses, and — while still
|
||
under `holdMs` — fades out, rewinds to the start hidden, then fades the **same** message back in to
|
||
scroll again), then fades out (`fadeOutMs`), waits `gapMs`, and the next fades in. The fade (opacity
|
||
on a `.fadewrap` layer) is independent of the scroll (the inner track's transform). **Every** cycle
|
||
boundary fades — a message change, a scroll rewind, and a **lone** message (which pulses out and back
|
||
in rather than sitting frozen) — so the rhythm is uniform. So the strip does not jump in height while
|
||
the message is momentarily gone during `gapMs`, it reserves one text line with an invisible spacer
|
||
and overlays the message absolutely. When a navigation remounts the view mid-scroll, the engine
|
||
**resumes the scroll from where it had reached** (`resumeScroll`) instead of restarting it at the
|
||
left. A **viewport size change** (e.g. a portrait↔landscape rotation) re-measures the current message
|
||
(`remeasureBanner`, debounced), so its scroll re-evaluates for the new width. All timings are
|
||
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
|
||
and a long message does not scroll.
|
||
|
||
## 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).
|