When an online game loses the connection the game screen now says so and freezes
instead of silently greying out: a "connection lost" banner appears and the rack,
the move controls, the add-friend/block controls and the chat/dictionary entry all
disable (a started move stays a draft, committed by the player on reconnect). It is
driven off the net-state machine (netState.offline), so it also covers the
Telegram/VK mini-apps, where a lost connection was previously mute in-game.
If the player is already in the dictionary when the drop happens, the word check
falls back to the game's pinned on-device dictionary (exact when that dawg is
cached; "unavailable offline" otherwise) and the network-only complaint + external
look-up hide. Chat, when already open, keeps its existing read-only degrade
(send/nudge disable). New pure helper localWordCheck is unit-tested; the in-game
gating gets an e2e.
Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
pink (illegal), the board tiles a formed word runs through go a shade darker, and an
orange score badge sits on the main word (digit sized like a tile value, clamped on-board)
The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.
Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.
Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.
Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
Follow-up to the contour review of the landscape fixes:
- Zoom still positioned "in two steps": a wide viewport overflows vertically as soon as
the square board grows past its height, but horizontally only once it grows past the
(much wider) width — so the browser pins scrollLeft to 0 while the vertical axis
already pans (no time/ease tween fixes it; the horizontal scroll range physically is
not there yet). Found by instrumenting the scroll trajectory. Now drive both axes by
one progress = how far the board has grown past the viewport width (when a horizontal
pan first becomes possible): until then the board just zooms centred, past it both
axes pan together in one diagonal motion. Also disable scroll-anchoring so the browser
stops fighting the programmatic scroll mid-transition. Re-verified: both axes now
start and move together.
- Rack tiles resized when a tile was placed: landscape used flex-grow, so removing a
tile regrew the rest. Give them a fixed size (1/7 of the fixed-width rack), like the
portrait rack, so placing a tile leaves the rest put.
Bugs surfaced while testing in VK but present on every platform (verified across
browsers, not VK-specific):
- Rack tiles: the letter used a fixed rem size, so in landscape — where tiles shrink
below 46px in the narrow left panel — it overflowed and dropped into the bottom-left
corner. Size it relative to the rack (cqw, like the board's labels; the tile's own
container-query size resolves unreliably under flex + aspect-ratio).
- Landscape board zoom positioned "in two steps": the per-frame clamp-to-max reached
the wide axis before the tall one. Interpolate both scroll axes together by time over
the grow/shrink transition instead (settles on zoom-out too).
- The zoomed board could not be panned with a mouse (touch scrolls the overflow:auto
viewport natively; a mouse cannot drag-scroll a div). Add a drag-to-pan handler, active
only while zoomed, off pending tiles, past a small movement threshold, swallowing the
trailing click so it does not also act on a cell.
- Shorten the in-game block-confirm label ru "Блокируем?" -> "В бан?" (the long form
overflowed the seat score chip on mobile).
Top-aligning it still read a touch low; move the empty-blank star up
(top 8% -> 0.5%) so its ink centres against the rack letters' block,
matching the board tile's centred mark. Size unchanged.
Rack: the empty-blank star is now top-anchored level with the letters and a
touch larger (was centred, sitting low). Board and Stats best-move tiles: the
placed-blank star's ink is centred on the value digits' line (was slightly
high). CSS-only nudges; pixel offsets measured against the rendered glyphs.
The Erudit variant's blank is the "звёздочка", so render it with a star.
An empty rack blank (and its drag ghost) shows ✻ centred; a placed blank
keeps its designated letter and carries ✻ where the (absent) point value
sits — on the board and in the Stats best-move tiles. The Scrabble variants
are unchanged. Gated by usesStarBlank() in lib/variants.ts.
- The rack now animates the opening drop gap for a board-tile recall drag too
(the reorder transition was gated on a rack-source drag only).
- While a board tile is dragged over the rack (a recall, gap shown), the ✅
confirm and its preview caption are hidden so they don't sit over the gap;
they return if the tile is dragged back out over the board.
- A recall drop lands off the boardwrap, so its pointerup never reached the
boardwrap handler and left a stale id in the active-pointer set — the next
board swipe then read as multi-touch and the shuffle / history pull went dead.
Reconcile the pointer when a drag ends or cancels. Regression e2e added.
- vs-AI: the comms hub drops the Chat tab (Dictionary only); a finished AI
game has no comms at all, so its 💬 history-header entry is hidden too.
- Confirm-move ✅: redone as an absolute overlay pinned to the right edge (its
right edge sits under the preview caption), so the rack keeps a fixed tile
size — this reverts the tile-shrink that resized the letters at 6 tiles.
- Recall: dragging a placed tile back to the rack now drops it at the slot the
pointer is over (drag-to-position, a gap opens), instead of snapping to its
original slot; double-tap still recalls to the origin. New pure recallToSlot.
Tests: placement recallToSlot units; e2e recall-to-position; vs-AI comms e2e
updated. Docs: UI_DESIGN + FUNCTIONAL (+ru).
- admin dashboard "Users" count excludes robots (CountUsers, humans only)
- lobby finished-game delete reveal: background matches header/tab-bar (--bg-elev)
- mobile: swipe up on the zoom-out board (no staged tiles) shuffles the rack
- lobby: status icons -25%, game-row top/bottom padding halved
- toast: info tier drifts up ~a tab-bar height while fading over 2s and dismisses
on tap; error tier unchanged
- game: confirm-move button stays pinned at the right edge; rack tiles shrink to
fit so a full rack never overflows onto it
- telegram: a successful invite-link redeem shows a welcome window pointing at the bot
- settings: remove the grid-lines toggle; the board is always a gapless checkerboard
- settings/comms hubs: the selected tab highlight wraps icon + label as a pill
Docs: UI_DESIGN (toast, board surface, selected tab), PLAN; e2e updated for the
removed grid-lines toggle.
When the viewport is wider than tall (matchMedia orientation: landscape) the
game screen switches from the portrait stack to a two-column layout: the board
fills the right column as the largest square that fits the height (no zoom,
shrinking by width when cramped — lowest priority), while the left panel stacks
the rack (+ make), the status line, the score plaques, the always-open docked
history and the controls. Board zoom, the history slide-drawer gestures and the
growing nav bar are gated off in landscape; the portrait layout is unchanged and
both render from the same snippets so behaviour stays single-sourced.
The mock e2e now defaults to a portrait viewport (the mobile-first app the
gesture/zoom/history specs are written for); landscape.spec.ts covers the wide
layout in its own viewport.
Backlog item 2 of ~4 (owner review pass):
- USSR flag emblem redrawn (canonical hammer & sickle, scaled down 1.5x
below the star).
- Touch drag-and-drop: enlarge the drag ghost 1.5x on touch only (the finger
hides the tile); suppress the iOS tap-highlight that lingered on a rack tile
sliding into a dragged tile's slot.
- Telegram fullscreen: its native nav no longer hides our header -- the header
drops below the content-safe-area top inset and the menu (hamburger) lifts
into the nav band, centred (--tg-content-top from the SDK inset + a
tg-fullscreen class; new telegram.ts helper + app wiring).
Tests: UI check/test:unit/build + full e2e (60) green. The iOS tap-highlight
fix and the TG-fullscreen layout want on-device verification on the deploy.
Dragging a rack tile and dropping it back on the rack reorders it: the dragged tile is
lifted out (the drag ghost stands in) and the tiles at/after the pointer's drop slot slide
right to open a gap there, so the drop position is visible. On drop the rack and its stable
ids are permuted (reorderIndices, unit-tested). Reorder applies only with no pending tiles,
so it stays a clean permutation; dropping on a board cell still places as before. Server
persistence of the order follows (#4).
- Even zoom: interpolate the board scroll toward a pre-clamped target as the real width
grows/shrinks, so it magnifies A->B in one motion instead of lurching and snapping back
near the edges/centre. Recentre only on a zoom toggle, never on a focus change — so a
2nd+ placed tile and a hovered dragged tile no longer jump the board.
- Drag: highlight the aimed-at empty cell as a drop target; hover-hold auto-zoom now
fires only for the first (zoom-in) hold.
- Pinch zoom: two-finger spread/close toggles zoom toward the pinch midpoint (preventDefault
only for two touches, so one-finger scroll stays native); a second finger aborts a drag.
- Shuffle hop capped at 0.3s and disabled under reduce-motion.
- Make-move is a borderless icon button, disabled while the pending word is known illegal.
- Variant display names: english & russian_scrabble -> Scrabble/Скрэббл, erudit ->
Erudite/Эрудит; the in-game title shows the variant name (was always 'Scrabble').
Give each rack slot a stable id permuted with the letters on shuffle, so the keyed
rack reorders (rather than relabelling in place) and Svelte's animate directive fires.
hop flies each tile along a parabola (apogee ~half a tile height) with a duration that
scales with the horizontal distance (arc length): the longest 1<->7 swap takes ~0.5s,
shorter swaps land sooner. Ordinary reflow (place/recall) stays instant via a guard.
e2e locks that a shuffle preserves the rack's tile multiset.
- Board drag now auto-zooms toward a cell after holding the tile over it ~1s (#3).
- Profile is inline-editable: drop the Edit/Cancel toggle, form is always shown
for durable accounts; hint balance stays read-only; re-populate after link/merge (#5).
- A pending tile recalls by double-tap (same cell) or by dragging it back onto the
rack (unzoomed board); a single tap no longer recalls (#10).
- e2e: lock double-tap recall + single-tap no-op; drop the removed Edit-profile click.
Live play now exchanges per-variant alphabet indices instead of concrete
letters (rack out; submit-play, evaluate, exchange, word-check in). The client
caches each variant's (index, letter, value) table behind
StateRequest.include_alphabet and renders the rack and blank chooser from it,
dropping the hardcoded value/alphabet tables. History, the durable journal and
GCG stay decoded concrete characters (ARCHITECTURE §9.1, unchanged).
- pkg/fbs: new AlphabetEntry + PlayTile; StateView.rack -> [ubyte] + alphabet;
StateRequest.include_alphabet; SubmitPlay/Eval tiles -> [PlayTile];
Exchange tiles + CheckWord word -> [ubyte] (committed Go + TS regenerated).
- engine: AlphabetTable + a cached per-variant codec (LetterForIndex/EncodeRack/
DecodeTiles/DecodeWord) + BlankIndex sentinel; Go parity test.
- backend server edge maps index<->letter (new thin game.Service.GameVariant);
game.Service domain methods, engine.Game and the robot keep one letter-based
play path. The gateway forwards indices verbatim (no alphabet table).
- ui: lib/alphabet.ts in-memory cache; codec encodes/decodes indices; premiums.ts
is geometry-only; the mock seeds a fixture table; the UI normalises display to
upper case (codec + cache), leaving placement/board/checkword unchanged.
Parity moved to the Go engine.AlphabetTable test; premiums.ts loses its value
tables. Discharges TODO-4.
Second owner-review pass (iPhone simulator):
- Chat (and the modal) are sized in dvh so they shrink above the software keyboard,
keeping the start of the conversation on screen instead of pushed off the top.
- The profile away window returns to a native <input type="time" step="600"> (the iOS
wheel with 10-minute steps) instead of separate dropdowns; the timezone stays a
native offset <select>.
- A finished game reserves the rack's height (min-height) so the footer no longer
collapses when the final rack is empty — no layout jump versus an active game.
- New-game "play with friends" is made compact: a searchable, bounded-scroll friend
list, the game-type / move-time / hints controls as native selects in one row
(labels above), and Send invitation pinned at the bottom — it scales to many friends.