Commit Graph

96 Commits

Author SHA1 Message Date
Ilia Denisov 80cc2a76e8 fix(ui): unify tie-for-lead result across lobby + game (shared victory, not a draw)
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 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Bring the online lobby badge and the in-game 'you won/lost' text to the
same competition ranking the hotseat seat medals use: in a 3-4 player game
a TIE for the lead is a SHARED victory for the top scorers and a placed
finish for those below — not a full draw for everyone (winner() is -1 on
any tie, so isWinner alone conflated them). An aborted game and a genuine
all-level finish stay a draw; a win by resignation (winner at a not-higher
score) is unchanged.
- result.ts: resultBadge no-winner branch ranks by final score; placeBadge
  helper; resigned seats excluded from the ranking.
- Game.svelte: resultText aligned (shared lead = won, below = lost).
2026-07-07 15:47:34 +02:00
Ilia Denisov 84d0385c95 fix(offline): scope the medal treatment to hotseat only
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Per owner: a vs_ai game (one human) keeps its 'you won/lost' status text
AND its lobby medal — the hidden-status + per-seat-medal treatment applies
only to hotseat, where 2-4 local players make a single 'you' meaningless.
isLocalGameId -> game.hotseat at the three call sites (statusBlock, seat
plaque, lobby card).
2026-07-07 15:00:59 +02:00
Ilia Denisov 0ed34c7720 feat(offline): per-seat medals in local games; drop the you-won text + lobby medal
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 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
For a local (offline) game the outcome is not always about a single
'you' (hotseat is 2-4 players), so:
- Game.svelte: a finished local game no longer shows the viewer-centric
  'you won/lost/draw' status; instead each seat plaque shows a per-seat
  place medal, left of the name (result.seatMedal — trophy for the winner,
  then places by score; a draw medals everyone).
- Lobby.svelte: a local game shows no lobby medal (resultBadge is
  viewer-centric and no seat matches the account) — its result lives on
  the in-game plaques.
- result.ts: seatMedal(game, seat), unit-tested.
2026-07-07 14:54:44 +02:00
Ilia Denisov 52d0c559f9 fix(ui): local-game history (hide social, keep dictionary); Enter dismisses keyboard; email-code autosubmit
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 1m7s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
- History drawer in a local (offline) game: the seat plaques no longer
  show the add-friend/block controls — canAddFriend/canBlock now exclude a
  local game (hotseat seats have synthetic account ids that slipped past
  the vs_ai-only guard) — and the Dictionary entry is restored: an active
  hotseat game keeps the comms button, and CommsHub is Dictionary-only for
  a chatless vs_ai OR hotseat game (ChatScreen never mounts offline).
- Enter on any single-line <input> now dismisses the soft keyboard (blur);
  a <textarea> (feedback) keeps Enter for newlines. One global handler.
- Login email code: the friend-code spread-digit style (.codein), a
  6-char cap, auto-submit on the 6th digit, and Enter to submit.
- e2e: the local-game history has no social controls and keeps the
  dictionary entry.
2026-07-07 14:45:53 +02:00
Ilia Denisov beda6ccd3d fix(ui): iOS soft-keyboard shell alignment + hotseat relock on return
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
iOS Safari/WKWebView does not shrink the layout viewport for the soft
keyboard (Android Chrome does): the visual viewport shrinks AND offsets
down toward the focused field, and the values do not cleanly revert. The
pinned shell tracked only the height (--vvh), not the offset, so its
top-anchored content misaligned on iOS — empty space below the form,
worst on a repeat focus. Fix (one place; covers NewGame, Chat, Feedback):
- app.svelte.ts syncViewport: also mirror visualViewport.offsetTop into
  --vv-top, and scroll the focused field into view on the keyboard-open
  transition (iOS does not reliably scroll a pinned document to it).
- app.css: the pinned app-shell body follows top=--vv-top + height=--vvh
  (was inset:0), so it stays on the visible area.
- e2e/viewport.spec.ts emulates the visual-viewport resize+offset (a fake
  window.visualViewport) to verify the shell follows, on Chromium+WebKit.

Hotseat: returning to the lobby now re-locks the current seat (its PIN is
re-prompted) — LocalSource.relock, cleared in Game.svelte onDestroy.
Root-caused a crash it exposed: a $props() value (id) reads back undefined
during Svelte teardown, so isLocalGameId(id) threw and aborted onDestroy
(breaking navigation) — read the id from the loaded view instead.
2026-07-07 14:10:57 +02:00
Ilia Denisov 4ddc690c38 feat(offline): hotseat in-game seat lock + host menu
Game.svelte drives a hotseat game:
- the seat-to-move's rack is replaced by an Unlock button when the seat
  is PIN-locked (board stays visible); canMove gates the move controls
  on the unlock; PinPad(verify) -> source.unlockSeat reveals it.
- the hint slot becomes a host button (hints off in hotseat), always
  enabled while the game runs (acts on a locked seat too). Master-PIN ->
  action sheet: skip current / exclude a player / end the game, each with
  a confirm + a fading check; terminate deletes and returns to the lobby.
- per-turn advance re-fetches the next seat's (locked) state; self-resign
  and chat are hidden (hotseat resign is a host action, no comms).
- gamesource: expose unlockSeat / verifyHostPin / hostAction on the local
  proxy. i18n hotseat.* (en + ru).
2026-07-07 11:39:43 +02:00
Ilia Denisov 53311cbc95 fix(hint): arm the vs_ai idle-gate from the warm cache so the lock shows without a delay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Entering a vs_ai game from the lobby armed the idle-hint countdown only in load() (after
the gameState round-trip), so the lock popped in after a visible delay. Arm it on the
instant warm-cache render in onMount too — the preloadGames-warmed StateView carries
hint_unlock_left_seconds; load() then refreshes the snapshot. A first move (0 seconds
left) stays open.
2026-07-07 00:59:06 +02:00
Ilia Denisov 7fc1301b31 feat(hint): unify the vs_ai idle hint online + offline (server-enforced, monotonic)
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 1m28s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Online vs_ai hints were broken: #207 made the vs_ai hint button always-enabled and
wallet-free (assuming all vs_ai = the offline idle-gate), but the backend still served
online vs_ai from the allowance/wallet, so over-clicking hit ErrNoHintsLeft -> a generic
error toast. Now online vs_ai uses the SAME idle-gate model as offline.

Backend: Hint() for a vs_ai game skips the allowance/wallet and increments no hints_used
(owner: vs_ai counts toward no hint statistic), and is idle-gated from the SERVER clock --
it returns ErrHintLocked (code hint_locked) until the robot's last move + 30 min, else
serves the top move. GameState/StateView expose hint_unlock_left_seconds (server-computed
seconds remaining; 0 for a human game / first move / not-your-turn). Pure helper
hintUnlockLeftSeconds unit-tested.

Wire: StateView gains hint_unlock_left_seconds (FlatBuffers, additive); pkg/wire + gateway
transcode carry it (round-trip test).

Client: the gate counts down from a MONOTONIC clock (performance.now()) anchored to the
source's seconds-left when it lands (on load from the view; to the full window when the
robot moves), so a client clock change cannot skew it and a relaunch re-reads a fresh
value. The vs_ai hint button (online + offline) shows the lock + toast; doHint handles the
hint_locked backstop by re-syncing. Replaces #207's absolute hintUnlockAtMs on the
wire/model/delta (the offline record keeps the absolute for persistence; the view exposes
seconds-left).

Docs FUNCTIONAL(+_ru)/ARCHITECTURE updated. Verified: go build/vet + game/server/transcode
tests (+ new hintUnlockLeftSeconds); ui check 0 / unit 490 / e2e 198 (one pre-existing
webkit offline flake) / app entry 114.2/115.
2026-07-07 00:42:43 +02:00
Ilia Denisov ff486c80f8 feat(hint): persist the vs_ai idle-hint wait (wall-clock + read sanitiser)
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 1m28s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Per the owner's call, the idle-hint gate now PERSISTS across leaving and reopening the
app, instead of the session-scoped monotonic clock: the unlock is a wall-clock instant
(hintUnlockAtMs) stamped from the robot's reply, stored on the local record, carried on
the game view + the opponent_moved move delta, and read through a sanitiser that caps it
at now + the window. So:
- the wait survives a relaunch (a stuck turn is not forgotten);
- a device clock set BACK cannot freeze the gate (the cap bounds the remaining to the
  window and self-heals on the next read);
- a clock set FORWARD just opens the hint early -- accepted as harmless for a solo game.

- lib/hints.ts hintGateRemainingMs now takes the unlock instant + clamps to the window.
- localgame: re-add hintUnlockAtMs to the record/meta; stamp it off the robot's reply;
  sanitise on read (a shared hintUnlock helper feeds stateView + the opponent_moved event).
- gamedelta + PushEvent: the move delta carries hintUnlockAtMs so the view stays fresh.
- Game.svelte: hintUnlockAt derives from the view; the tick + lock + toast unchanged.
- offline.spec.ts: also assert the lock survives a reload (the wait persisted).
- Docs FUNCTIONAL(+_ru)/ARCHITECTURE updated (persist + sanitiser, not monotonic-resets).
2026-07-06 22:34:50 +02:00
Ilia Denisov 42a6308261 feat(hint): idle-gated wallet-free vs_ai hint on a monotonic clock (B4)
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 1m11s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
A vs_ai hint is now unlimited and wallet-free, but idle-gated as an anti-frustration
aid: it unlocks only after the player has been stuck ~30 min on a turn (timed from the
robot's last move; the human's first move, before the robot has played, is exempt).
While gated the hint button carries a small lock badge and a tap shows the remaining
minutes ('Available in N min.'); the lock lifts live at the mark.

The gate is enforced CLIENT-SIDE against a MONOTONIC clock (performance.now()), never a
wall-clock timestamp: a device clock the player controls (or an auto-sync) must not be
able to open or freeze it. The wait is therefore session-scoped -- a reload restarts it
(there is no tamper-proof way to carry idle time across a relaunch without a wall clock).
An online vs_ai game will gate the same way but from the server's clock (a follow-up).

- lib/hints.ts: HINT_GATE_MS + pure hintGateRemainingMs (monotonic) + hintLockMinutes;
  removed the wall-clock hintRemainingMs. Unit-tested red->green.
- Game.svelte: the monotonic gate (hintGateStart/monoNow, armed on the turn change), a
  vs_ai hint button (plain: no confirm, no count; lock badge + gated tap -> toast), a
  live 10s tick.
- localgame: removed the wall-clock hint gate and the now-dead robotLastMoveAtUnix field
  from source.ts/serialize.ts (the client is authoritative); hint() just serves the top
  move.
- i18n game.hintLockedIn.
- offline.spec.ts: assert the first move is un-gated and the lock arms after the robot moves.
- Docs FUNCTIONAL(+_ru) + ARCHITECTURE; bundle budget 114->115 (game-screen feature).
2026-07-06 22:03:27 +02:00
Ilia Denisov fc8143758a fix(offline): return online after flight-mode off; gate online-game actions offline
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 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
Two mid-session issues found on the contour:
- Auto-offline did not return to online after the network came back: a single
  reachability check right after the 'online' event failed (the interface is up
  before the gateway is actually reachable again). Retry a few times with backoff
  (tryReturnOnline) — that is what actually gets the app back online.
- An online game viewed while offline (flight mode on) still enabled its network
  actions, so they hit the kill switch and raised 'something went wrong' toasts.
  Gate them: netReady = isLocalGame || (connection.online && !offlineMode.active)
  — a local game stays fully usable; an online game's make/exchange/hint/resign
  disable while offline and re-enable when back. Also suppress the 'offline' code
  in handleError (a blocked call in offline mode is expected, not a toast).
2026-07-06 18:35:52 +02:00
Ilia Denisov 1654131904 feat(offline): wire the local game source into the game screen (Phase B3.2)
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 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
The game screen now drives a local vs_ai game through the offline engine, dispatched by
game id — completing the playable local game (on top of the source, #193). Online play
is unchanged.

- gamesource.ts: gameSource(id) returns the local source for a `local:` id, else the
  gateway (the same game-loop interface). The offline engine stays OUT of the app entry
  bundle — it is dynamically imported on first use (a separate chunk), so online-only
  users never pay for it (the app entry stays within its size budget).
- localgame/id.ts: the tiny id helper (no engine imports) the dispatcher branches on.
- Game.svelte: the game-loop calls (state/history/submit/pass/exchange/resign/hint/
  evaluate/draft) go through gameSource(id) instead of the gateway directly; a local
  game's robot-reply events route through the same app event hub the network stream
  feeds, so the screen reacts to opponent_moved / game_over identically.

Behaviour-preserving for network games (gameSource returns the gateway for them). Local
verify green: check + test:unit + build + bundle-size gate + e2e (196 passed).
2026-07-06 09:24:55 +02:00
developer d5fbaa3034 feat(export): server-rendered artifacts behind one signed download URL (#160)
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one
signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min
TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin
and delivered by the best affordance each platform has (five on-device
review rounds):

- TG Android/desktop: native showPopup chooser -> native downloadFile
  dialog (bridge-only chain, activation-safe).
- TG iOS: app-modal chooser -> OS share sheet with the fetched file
  (a popup callback cannot supply the activation the sheet needs).
- VK iOS: VKWebAppDownloadFile for both formats.
- VK Android: the PNG opens in VK's native image viewer, the GCG copies
  to the clipboard (the VK Android downloader hangs on any download,
  Content-Length/Range notwithstanding).
- VK desktop iframe / desktop browsers: plain anchor downloads.
- Mobile browsers: the OS share sheet (fetch-then-share).
- Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option.

The PNG is rasterized on demand by the new internal `renderer` sidecar
(node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts)
executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests;
the backend rebuilds the render payload from the journal +
engine.AlphabetTable, and the device date locale, IANA time zone and
localized non-play labels ride the signed URL. Nothing is stored — the
artifact re-derives from the immutable journal on each GET. The gateway
forwards /dl/* (caddy @gateway matcher extended) behind the per-IP
public rate limiter and serves bytes via http.ServeContent.

Deploy: renderer service in compose + prod overlay + rolling order +
prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke
runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN,
TESTING, deploy/README, renderer/README.
2026-07-02 21:58:07 +00:00
developer 16a4431158 Merge pull request 'feat(game): finished-game export as a PNG image behind a format chooser' (#159) from feature/game-export-image into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m0s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m21s
2026-07-02 15:29:40 +00:00
Ilia Denisov 946420db93 fix(game): own export chooser modal; PNG option outside in-app webviews only
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 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
Telegram's native showPopup delivers its callback with no user
activation, so navigator.share (TG iOS) and clipboard writes (TG
Android GCG copy) silently fail from it — the chooser is now always the
app's own modal, keeping the button click's gesture alive for the
delivery APIs.

The data:URL preview modal is dropped: the Android TG/VK long-press
menu mangles data: URLs (dead download, black-screen open, base64
clipboard garbage), so a binary PNG has no working client-side route in
those webviews at all. The image option is withheld there until the
server-rendered signed-URL delivery (Telegram downloadFile /
VKWebAppDownloadFile) lands; the plain web and mobile browsers keep it.

On-device findings by the owner on the test contour (TG iOS, TG
Android, VK Android).
2026-07-02 17:16:05 +02:00
Ilia Denisov a0eacf3011 feat(game): finished-game export as a PNG image behind a format chooser
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 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
The history header's export button now opens a chooser — Telegram's
native popup inside Telegram, the app's own modal elsewhere — offering
the GCG file and a new client-rendered PNG of the final position
(lib/gameimage, Canvas 2D, lazy dynamic import, zero dependencies):
light theme, classic A..O/1..15 axes, label-free premium fills, and a
fixed-typography per-seat scoresheet with GCG-style move coordinates,
multi-word sub-lines, endgame rack-settlement row, winner trophy and a
hostname + device-locale finish date footer; a long game stretches the
board, never the typography.

Delivery mirrors the GCG rules (Web Share with no blob fallback, else
download) except on Android Telegram/VK WebViews and the desktop VK
iframe, where a binary PNG has no clipboard-text fallback: those get a
preview modal with a long-press/right-click save hint and a copy-image
button where ClipboardItem exists.
2026-07-02 16:20:35 +02:00
Ilia Denisov 622d3965a7 fix(game): no placement auto-zoom in landscape
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 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m12s
Landscape fits the whole board, so the coarse-pointer auto-zoom on tile
placement, drag hover-hold and hint only hid the rest of the position.
Gate all three on portrait; manual double-tap/pinch zoom is unchanged.
New e2e lock both sides: landscape placement stays unzoomed, portrait
placement still auto-zooms (touch-emulated, both engines).
2026-07-02 15:08:30 +02:00
Ilia Denisov aec915d5c1 feat(ui): drop the one-word status-bar badge
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The small 1️⃣ in the status bar's score-preview slot read as noise; the
single-word rule keeps its spelled-out label in the history header and
the lobby invitation card.
2026-07-02 00:10:08 +02:00
Ilia Denisov 2e8fa83814 feat(telemetry): local move-preview adoption metrics (Phase 4)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Measure uptake of the client-side local move-preview accelerator (§5) so
adoption can be watched before defaulting it on: app cold starts, dictionary
loads by result (fetched / cache_hit / miss) and move previews by path
(local / network — the backend load shed).

A small best-effort client beacon (POST /metrics/local-eval, session-gated)
batches counter deltas and posts them on a 60s timer and when the app is
backgrounded — never on the gameplay path: the in-app counters are plain
in-memory increments, only the periodic flush touches the network and it is
fire-and-forget. The gateway folds each batch into three OTel counters
(local_eval_cold_start_total, local_eval_dict_load_total,
local_eval_preview_total), clamped against a spoofed inflation.

- gateway: counters + recordLocalEval + session-gated /metrics/local-eval handler
- ui: localeval-metrics accumulator/beacon; hooks in the dict loader, in
  Game.recompute and in bootstrap (skipped under the mock harness)
- caddy: route /metrics/* to the gateway
- docs: ARCHITECTURE §11; Grafana "Scrabble — Users" dashboard panels
2026-07-01 23:31:49 +02:00
Ilia Denisov 5689f7f6a3 feat: on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Score and validate a tentative move on-device instead of a per-arrangement network
round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.

- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
  (immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
  game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
  IndexedDB (best-effort, self-healing on a rejected blob) and reused across
  sessions; a warm-up overlay covers a cold load, then the network preview is the
  fallback; a bad-connection breaker stops warming after repeated misses; the move
  preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
  CI against the release dictionaries. A hidden debug readout lists the cached
  dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
2026-07-01 22:58:40 +02:00
developer 4458f0e545 Merge pull request 'fix(ui): copy GCG to clipboard on Android in-app WebViews' (#151) from feature/gcg-export-android-fix into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m13s
2026-07-01 11:23:59 +00:00
Ilia Denisov 88b6761e28 fix(ui): copy GCG to clipboard on Android in-app WebViews
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
On-device diagnostics from Android Telegram and VK confirmed both expose
no navigator.share AND no navigator.canShare, so the export fell to a Blob
<a download> that those WebViews silently ignore — nothing happened.

pickGcgDelivery is now a 3-way decision: Web Share where available (iOS),
a clipboard copy in an Android in-app WebView (Telegram/VK: no share, dead
download), else a desktop Blob download. shareOrDownloadGcg reports the
outcome so the game shows a "GCG copied" toast; the copy is VKWebAppCopyText
inside VK (which also covers the desktop VK iframe, where navigator.clipboard
is blocked) and navigator.clipboard otherwise.

Unit tests cover the 3-way choice and the copy/failed outcomes; new i18n key
game.gcgCopied (en+ru); docs ARCHITECTURE/FUNCTIONAL(+ru)/UI_DESIGN/TESTING.
2026-07-01 13:17:00 +02:00
Ilia Denisov 71c3411276 fix(ui): mobile in-app WebView polish (tap-flash, VK haptics, swipe-back)
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
- Tap-highlight: add -webkit-tap-highlight-color: transparent on #app.
  Android in-app WebViews (Telegram, VK) flashed a momentary selection-like
  box on tappable nodes on tap — seen on the header title and the back
  chevron; user-select (already none) does not govern it. Inherited, so
  this clears it app-wide.

- VK haptics: mirror the Telegram haptic set on VK via VK Bridge taptic
  (impact / notification / selection), routed through a new shared
  lib/haptics.ts dispatcher. VK users previously got no haptics; the game
  and error call sites now fire haptic() instead of telegramHaptic().

- VK swipe-back: disable VK's horizontal swipe-back at launch
  (VKWebAppSetSwipeSettings history:false) so it does not fight the app's
  own edge-swipe-back and on-board tile drag — parity with Telegram's
  disabled vertical swipes; the app owns navigation via its back chevron.

Docs: UI_DESIGN (no-select / tap-highlight, VK integration).
2026-06-30 23:00:19 +02:00
Ilia Denisov 6636d7c309 feat(ui): first-run onboarding coachmarks
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.
2026-06-30 21:48:56 +02:00
Ilia Denisov 6a5ce12fab fix(vk): friend-code link as ?hash, Android safe-area via bridge insets, landscape home-bar colour
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 1m22s
Contour review of the VK Bridge group:

- #6 invite link: VK's documented '#' direct-link payload is eaten by the vk.com SPA before it
  reaches the app, so the friend-code link now carries the payload as a query param
  (vk.com/app<id>?hash=f<code>); the recipient already reads the `hash` query param (vkStartParam).
  (Whether VK forwards the '?' through to the iframe is being confirmed on the contour.)
- #8 Android: the VK mobile webview does not surface the home-bar inset via CSS env() (config
  insets are iOS-only), so subscribe to the bridge insets (VKWebAppUpdateConfig + VKWebAppUpdateInsets)
  and set --tg-safe-* to max(env(), the VK value).
- #8 landscape colour: the home-indicator strip was the (grey) page background because the two-pane
  landscape game has no bottom bar. The left-panel controls bar now paints its own chrome into the
  inset (Screen gains a selfInset flag that drops the shell's detached padding strip), and the
  game-land runs flush to the edge.

Verified: svelte-check, 347 unit, build, bundle-gate; the landscape safe-area painting reproduced in
the mock (controls bar + board reach the edge, strip takes the bar colour). The VK-Bridge / VK launch
behaviours (Android insets, the ?hash forward) need the live contour.
2026-06-29 22:15:43 +02:00
Ilia Denisov a06dfe00fc fix(ui): pin the landscape confirm button into the freed tile slot
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 1m24s
Follow-up to the contour review: with the rack now exactly seven fixed-size slots in the
narrow landscape panel, the 56px confirm button (sized for the roomy portrait rack) was
wider than the single slot a staged tile frees, so it overlapped the now-rightmost tile.
Match the button width to one landscape tile slot, so it sits inside the freed slot with
its right edge level with the rack's right edge — the mirror of the first tile's left edge.
2026-06-29 20:54:44 +02:00
Ilia Denisov 2ba7cc3086 feat(telegram): native dialogs for confirms and deep-link notices
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Inside the Mini App, route the destructive confirmations (resign, block, unfriend)
through Telegram's native showConfirm, and present the deep-link info modals (stale
invite, welcome-on-redeem) as a native showPopup whose button opens the bot chat.
Outside Telegram, or on a client predating the dialogs, the existing in-app Modal is
used unchanged; offline also keeps the modal so the action retains its disabled state.

Adds showConfirm/showPopup wrappers to telegram.ts and a pure popup-params builder
(nativedialogs.ts) with unit tests; the deep-link modal components choose native vs
in-app via an effect gated on insideTelegram + dialog availability.
2026-06-24 12:36:52 +02:00
Ilia Denisov 6aa5023b24 fix(ui): Android Telegram nav — windowed mode, no close guard
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Three Android Mini App issues, all in the Telegram chrome:

- Entering a game showed no native back button, and the Android system
  swipe-back minimised the app instead of navigating. Root cause: immersive
  fullscreen (requestFullscreen). On Android, fullscreen replaces the native
  header — and its BackButton, which also captures the system back — with a bare
  close/menu pill, so back navigation has no control to land on. Request
  fullscreen on iOS only; Android stays windowed, keeping the native header +
  BackButton (and the system swipe-back that routes to it). iOS is unchanged.

- Closing the game board always prompted "changes that you made may not be
  saved", even on a board just opened and untouched. The close-confirmation was
  armed unconditionally on game mount. Remove it entirely: move drafts auto-save
  (debounced during play + flushed on destroy), so nothing is lost on close.

Drops the now-unused telegramClosingConfirmation + isMobilePlatform helpers and
their SDK interface fields. Docs: UI_DESIGN.md.
2026-06-23 11:48:38 +02:00
Ilia Denisov e3e4cedc77 feat(ui): Erudit blank tiles carry the star (✻) mark
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
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.
2026-06-22 11:52:00 +02:00
Ilia Denisov c127bc9f0e feat(social): per-game friend request to disguised robots + lobby/stats/tile cosmetics
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m25s
Functional: the in-game add-friend handshake aimed at an auto-match opponent who is
secretly a pooled robot now records the request per (game, seat) in a new
robot_friend_requests table -- never against the shared robot account -- mirroring the
robot_blocks pattern. The shared account stays out of friendships, the "requested" state
is pinned to the seat (not leaked across the player's other games), the robot ignores it,
and a background reaper drops the row 7 days after its game finishes. The outgoing-requests
list carries these per-game rows so the seat control stays disabled across reloads. No
withdraw UI, per owner decision.

Cosmetics:
- Lobby: an in-progress game tints the viewer's own score number green when leading or
  tied, red when trailing (reusing --ok/--danger); scores now render in seat-number order,
  matching the over-the-board scoreboard.
- Stats: the per-variant best move is laid out on two lines -- the variant label, then the
  score (right-aligned in its column) and the word tiles (left-aligned) below it.
- Dark theme: the played-tile background is a touch darker so a player-placed tile reads
  with more contrast (light theme unchanged).

Docs (ARCHITECTURE, FUNCTIONAL + _ru mirror, backend README, UI_DESIGN) updated in the
same change.
2026-06-19 21:39:27 +02:00
Ilia Denisov 6e77de4c1e feat: sparser robot nudges, typed unread badge, lobby unread bump
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
Three owner-requested polish changes:

- robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a
  flat uniform 9-12 h wait before every nudge; the existing sleep-window gate
  still skips and defers a nudge that would land in the robot's night.
- ui: colour the lobby/in-game unread dot by type -- the regular danger colour
  when a chat message is unread, a softer amber (--warn) when only nudges are.
  Adds a per-viewer unread_messages flag (chat_messages.kind='message') across
  the backend DTO, FlatBuffers wire, gateway transcode and the UI store.
- ui: float games with any unread notification to the top of the lobby's
  your-turn and opponent-turn sections (finished keeps its order), reusing the
  existing unread_chat flag.

Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire
field is backward-compatible.
2026-06-19 16:50:48 +02:00
Ilia Denisov 0dd4099d68 perf(ui): reuse hint result as move preview, skip redundant evaluate
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
Taking a hint auto-placed the suggested tiles and then called recompute(),
which round-trips a debounced evaluate for that exact placement. The hint is
the engine's own top-ranked, fully scored legal move, so its move already
carries the score, words and direction an evaluate would return — the second
call was pure duplicate work and added a visible disabled->enabled flicker on
the submit button over slow links.

Seed the move preview directly from the hint move via previewFromHint and
cancel any pending evaluate timer so a stale one cannot clobber it. A later
manual edit re-arms recompute() as before, so rearranged tiles are re-evaluated
normally. Client-only; no backend, wire or schema change.
2026-06-19 12:03:27 +02:00
Ilia Denisov b156d3403c fix(ui): polish board→rack recall drag and its gesture interplay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
- 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.
2026-06-19 10:12:48 +02:00
Ilia Denisov 4adb608ad1 feat(ui): vs-AI comms trim, overlay confirm button, recall-to-slot drag
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
- 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).
2026-06-19 09:54:30 +02:00
Ilia Denisov b5688d4848 fix(ui): right-align the confirm-move glyph under the preview caption
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
The  box already ended at var(--pad) from the screen edge, but place-items:
center centred the glyph inside the 56px box, so it sat ~14px left of the green
preview caption (.scores, text-align:right at the same var(--pad)). Right-align
the glyph (place-items: center end) so its right edge lands under the caption's.
2026-06-19 09:17:39 +02:00
Ilia Denisov ab1ad998aa feat(ui): batch of UI polish tweaks
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
- 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.
2026-06-19 08:56:29 +02:00
Ilia Denisov 64be0572b3 fix(social): robot blocks
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Blocking an auto-match opponent who is secretly a pooled robot is recorded  instead in a separate `robot_blocks` table.

Now blocking behaves the same in that game (struck name, hidden composer) and lists the blocked opponent under the name you saw, but is recorded only against that game — the disguise holds, the shared robot is never globally blocked, and the matchmaker keeps pairing you with robots (so you can never block yourself out of opponents).

- the shared robot account is never put in `blocks`
- the matchmaker keeps it free and it is not blocked under its other per-game names
- the blocked list and the in-game card still show it by joining that table; an unblock deletes the row
2026-06-18 13:12:19 +02:00
Ilia Denisov 81b9e1529e feat(social): asymmetric per-user block, in-game block control, admin lists
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
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 1m19s
Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.

- backend: a directional blockExists guard across chat/nudge/friends/invitations
  (store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
  the matchmaker excludes a block-related pair (both directions) from auto-match;
  user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
  (red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
  composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
2026-06-18 11:50:34 +02:00
developer 8793bd34f2 feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
The statistics screen gains real depth, plus a hint-count bug fix found along the way.

- Best move per variant: the screen shows the actual best-move word (drawn as game
  tiles; a wildcard shows its letter but no value), broken down by game variant, empty
  variants omitted. New account_best_move table, written at game finish.
- Moves & hint share: two new lifetime tiles — the player's play count and the share of
  plays that used a hint — from summed account_stats counters (moves, hints_used).
  Honest-AI games are excluded, like the rest of the stats.
- Hint-count fix: the in-game hint badge no longer goes stale across games. The global
  wallet now rides the wire apart from the per-game allowance (wallet_balance on
  StateView/HintResult/StatsView), so the client reads the live wallet rather than a
  per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet),
  its true per-game total.
- Account merge: sums the new moves/hints_used counters and merges the per-variant best
  moves (higher score kept), which it previously dropped.
- Admin: the user card shows Moves and Hints used.
- UI polish: tab/label wording, game-over text, and e2e selectors hardened against label
  changes.

All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru,
UISN_DESIGN) updated in step.
2026-06-17 22:17:27 +00:00
Ilia Denisov fb0ddab0f1 feat(ui): hide GCG export in honest-AI games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
A vs_ai game is throwaway practice, so its finished history header no longer
offers the 📤 GCG export (an empty slot keeps the comms icon pinned right).
Docs note the AI exclusion; UI_DESIGN also records that confirming a resign
reveals the full board (closes the history drawer, zooms out).
2026-06-17 14:01:08 +02:00
Ilia Denisov 2c24d54047 feat(ui): reveal the full board on resign (close history, zoom out)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
After the player confirms a resign, close the move-history drawer (portrait;
the landscape dock is unaffected) and zoom the board out if it was magnified,
so the resigned game shows its full final board.
2026-06-17 13:33:27 +02:00
Ilia Denisov aaac816dc2 feat(chat): unread read-receipts with lobby/game dot and history-open ack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).

UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
2026-06-17 11:12:38 +02:00
Ilia Denisov aa765a0c06 feat: honest AI opponent in quick game
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
New Game's quick game gains an explicit opponent selector — 🤖 AI (default)
or 👤 Random player. AI starts a game seated with a pooled robot that joins
and moves at once: no per-move timeout (a 7-day inactivity loss reusing the
turn-timeout sweeper), chat/nudge disabled, no statistics, the opponent shown
as 🤖 everywhere. The random path (disguised robot) is unchanged.

Driven by one game flag (games.vs_ai), set only on AI-started games so the
disguised path is never revealed; Matchmaker.StartVsAI seats the robot
directly (no open pool); the robot replies event-driven via the game service's
after-commit/after-create hook. Wire: vs_ai on EnqueueRequest + GameView.
2026-06-15 20:14:24 +02:00
Ilia Denisov 02ef31c464 feat(ui): landscape iteration — reorder left panel, enable board zoom
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
Left panel order is now score / history / status / rack / controls (the history
fills the middle and scrolls). Board zoom is re-enabled in landscape with the
same gestures as portrait, but height-driven: the viewport is the full right
pane, the board fits by height as a centred square when zoomed out, and on
zoom-in it magnifies past the pane and pans within it (focus-centred scroll that
rides the magnify transition), occupying the full width up to the left panel.
Board.svelte gains a landscape prop; portrait stays width-driven and unchanged.
landscape.spec.ts now asserts zoom works (the zoomed board overflows the pane).
2026-06-14 19:39:06 +02:00
Ilia Denisov 9ec72c8377 feat(ui): landscape two-column game layout, board fitted by height
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
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.
2026-06-14 19:06:35 +02:00
Ilia Denisov c9021fc070 feat(ui): preload ongoing games and cache the draft for an instant, jump-free game open
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 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
Opening a game from the lobby for the first time this session showed a brief
loading flash, and every open showed a two-step rack->board jump: the saved
draft (pending composition) was fetched separately and applied only after the
board had already painted the full rack.

Both stem from the full state and the draft not being available synchronously at
first paint. Cache the draft alongside view+history (CachedGame.draft), make
applyDraft take the already-fetched JSON so it runs synchronously, and fetch the
draft in the same Promise.all as state+history. setCachedGame preserves the
cached draft when the delta path omits it and clears it on a committed move
(mirroring the server). A new preload module warms the per-game cache (state,
history, draft) for the lobby's ongoing games with bounded concurrency, so
opening any of them is instant.

Tests: gamecache (preserve/clear/setCachedDraft) and preload (warm/skip) units;
existing draft-restore e2e still green.
2026-06-14 17:53:03 +02:00
Ilia Denisov 222eaf730f feat(game): void unreplayable games as a draw instead of erroring
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
A committed move that becomes illegal under a tightened rule (the single-word
connectivity fix) makes engine replay fail, which left such games unopenable —
an empty screen and an 'illegal play' error. Now the first open closes the game
gracefully as a draw (engine.EndAborted -> end_reason 'aborted', no winner),
preserves the journal, and surfaces an impersonal organizer note at the end of
the move history and in the GCG export.

- engine: EndAborted + Abort() (draw, no rack adjustment; winner -1).
- service: replay aborts on ErrIllegalPlay; liveGame persists the void once
  (lazy, on open); GameState re-reads for the settled view.
- store: VoidGame finishes the game and stamps a draw without a journal row.
- migration 00002: allow end_reason 'aborted'.
- ui: organizer note under the history grid; i18n en/ru.
- docs: ARCHITECTURE 6/9.1, FUNCTIONAL(+ru), PRERELEASE MW3.
2026-06-14 16:57:27 +02:00
Ilia Denisov 1cc6c0d56e fix(ui): stop the game-screen freeze when an opponent joins (reactive self-loop)
The in-game live-event $effect read `view`/`moves`/`placement` (via cacheSnapshot
and direct reads) AND wrote `view` in its branches, so those reads became the
effect's own dependencies: writing `view = …` re-ran the effect, and with
app.lastEvent unchanged it re-entered the same branch and wrote `view` again — a
tight self-invalidating loop that pinned the main thread, freezing the board and
rack. opponent_moved escaped it only because applyMoveDelta is idempotent on the
move count (no cache → no write on the second pass); opponent_joined and game_over
have no such guard, so an opponent joining hung the whole screen. Tracking
`placement` similarly re-fired the handler on every tile the player placed after
an opponent's move (a spurious reload).

Fix: the effect must depend only on app.lastEvent and process each event once.
Wrap the branch body in `untrack`, scoping its view/moves/placement reads out of
the effect's dependency set; the writes inside no longer re-trigger it.

e2e: after the opponent joins, placing a rack tile must render a pending tile —
verified RED (frozen, 0 pending tiles, both engines) before the fix, GREEN after.
2026-06-14 12:39:57 +02:00
Ilia Denisov f3914af793 fix(ui): recentre the board on a hint taken while already zoomed in
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
The board only scrolled to the hint's word when the hint also toggled zoom (the
zoom-out case): the recenter effect tracked `zoomed` and read `focus` untracked,
so a hint taken while ALREADY zoomed in — no zoom change — never recentred and
the board stayed parked where the player was looking.

Add a `recenter` nonce the hint bumps; the board's scroll effect tracks it and,
when it fires without a zoom toggle, pans straight to `focus` (the board width is
stable, so the width-driven zoom tween has nothing to ride). Placing a 2nd+ tile
or hovering a dragged tile still set `focus` without the nonce, so the board
never jumps on those — the original no-jump intent is preserved.

e2e: zoom into the corner, then hint (the mock plays at the centre) — the board
pans toward the centre. Verified RED without the fix (both engines), GREEN with.
2026-06-14 11:44:04 +02:00
Ilia Denisov 56dbf86472 feat(lobby): keep lobby/game caches fresh from any screen + invitation delta channel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Builds on the cross-screen cache work: the global stream handler now keeps both
caches current no matter which screen is mounted, and invitations become a live
delta channel so the lobby's invitations list is fresh from any screen too.

Client (boundary already started):
- advanceCached now also folds opponent_joined into a not-currently-viewed game's
  cache via a new pure reducer applyOpponentJoined (extracted and reused by the
  mounted game board), so opening an open game that filled while you were elsewhere
  is flash-free.
- patchLobbyInvitation upserts a still-pending invitation and removes a terminal
  one (started/declined/cancelled/expired); the global notify handler calls it on
  the invitation / invitation_update sub-kinds.

Invitations delta channel (no wire/gateway/connector change — the notification
already carries the full invitation with id/status/game_id end to end):
- notify: a new in-app-only NotifyInvitationUpdate sub + NotificationInvitationUpdate
  constructor (shares encoding with NotificationInvitation). The Telegram connector
  renders no message for it, so a decline/cancel never becomes an out-of-app push.
- lobby: emit the changed invitation to every participant on respond (accept/decline),
  on the final accept's game start, and on cancel — so each participant's lobby patches
  its list in place. The authoritative list holds only pending invitations, so the
  client's pending-vs-terminal rule matches it exactly.

Tests: applyOpponentJoined + patchLobbyInvitation unit tests (TDD), the
NotificationInvitationUpdate encoding unit test, and integration assertions that
decline/cancel/accept publish invitation_update to every participant. Full local
suite green (backend unit+integration, UI check/unit/build/bundle/e2e). Docs:
ARCHITECTURE §10 (notify catalog + in-app-only note) and UI_DESIGN updated.
2026-06-14 11:22:47 +02:00