Promote development → master (initial production release: pre-release line + Stage 18) #104

Merged
developer merged 307 commits from development into master 2026-06-22 05:05:48 +00:00
Owner

Promote development → master for the initial production release.

Brings the full pre-release line (R1–R7 + the ad-hoc phases: TX split connector, AG anti-abuse, CM channel moderation, SB single bot, FM first-move draw, …) and the Stage 18 prod-deploy machinery (two-host registry rollout, rolling + auto-rollback, Ansible provisioning, host-memory monitoring) onto the production trunk.

Test-only CI (no auto-deploy on a master PR). After merge, the production cutover is the separate manual prod-deploy workflow (workflow_dispatch from master, confirm=deploy) — nothing auto-deploys to prod. DNS for erudit-game.ru (+www) already resolves to the main host, so ACME is ready.

Promote **development → master** for the initial production release. Brings the full pre-release line (R1–R7 + the ad-hoc phases: TX split connector, AG anti-abuse, CM channel moderation, SB single bot, FM first-move draw, …) and the **Stage 18 prod-deploy machinery** (two-host registry rollout, rolling + auto-rollback, Ansible provisioning, host-memory monitoring) onto the production trunk. Test-only CI (no auto-deploy on a master PR). After merge, the production cutover is the **separate manual** `prod-deploy` workflow (`workflow_dispatch` from master, `confirm=deploy`) — nothing auto-deploys to prod. DNS for `erudit-game.ru` (+`www`) already resolves to the main host, so ACME is ready.
developer added 307 commits 2026-06-22 05:01:01 +00:00
Stage 16: deploy infra & test contour
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 19s
CI / deploy (pull_request) Failing after 1s
8700fbfae1
- backend + gateway multi-stage distroless Dockerfiles; the gateway embeds and
  serves the SPA at / and /telegram/ via go:embed (committed dist placeholder,
  real build baked in by the image's node stage)
- deploy/docker-compose.yml: backend + gateway + Postgres + Telegram connector
  (VPN sidecar) + OTel Collector + Prometheus (15d) + Tempo (72h) + Grafana,
  fronted by a caddy owning a single /_gm Basic-Auth (admin console + Grafana
  subpath); inter-service on a private network, only caddy on the edge network
- new metrics: backend accounts_created_total{kind} (robots excluded) and an
  in-memory gateway active_users{window=24h,7d} gauge
- CI: single .gitea/workflows/ci.yaml (unit/integration/ui + a gated test-contour
  deploy) on the new feature/* -> development -> master branch model; the old
  go-unit/integration/ui-test workflows are folded in; the connector-scoped
  compose is retired (superseded by deploy/)
- docs: ARCHITECTURE §11/§12/§13, root + gateway READMEs, CLAUDE.md branching,
  PLAN.md (stage 16 done + refinements + Stage 17 forward-notes)
Stage 16: deploy/README.md — full environment-variable reference
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 20s
CI / deploy (pull_request) Successful in 20s
ee8d4fd85e
- deploy/README.md documents the services, how to run it locally and in CI, and
  every variable: required (the four :? ones + ≥1 bot token) and optional with
  defaults, marked secret-vs-variable and with the TEST_/PROD_ Gitea mapping;
  plus the fixed internal wiring and the host-side setup.
- ci.yaml maps the remaining POSTGRES_DB/USER, DICT_VERSION and LOG_LEVEL (unset
  renders empty -> the compose ":-" defaults apply), so every documented var is
  per-contour overridable.
- .env.example points at the README for the full reference.
Stage 16: connector test-env via UseTestEnvironment; pin it in the test contour
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 20s
CI / deploy (pull_request) Successful in 30s
0ea35fe991
- bot.New now selects Telegram's test environment with the library's native
  tgbot.UseTestEnvironment() instead of a token += "/test" hack (functionally
  identical URL /bot<token>/test/METHOD, but idiomatic) + a bot test asserting
  the getMe path for both test and prod.
- ci.yaml pins TELEGRAM_TEST_ENV=true for the test contour (it IS the test
  environment) instead of a TEST_TELEGRAM_TEST_ENV variable: removes the
  confusing double-TEST, telegram-specific, prefixed operator knob and the
  secret-vs-variable footgun. Prod (Stage 17) leaves it false.
- deploy/README.md + PLAN.md updated.
Stage 16: insert Stage 17 (test-contour verification); renumber prod deploy to 18
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 20s
CI / deploy (pull_request) Successful in 21s
efbaf657c6
- PLAN.md: new Stage 17 "Test-contour verification & defect fixes" (exercise the
  deployed contour end-to-end and fix what it surfaces — connector liveness check,
  path-conditional CI); the former prod-deploy stage becomes Stage 18.
- Renumber every "Stage 17" prod-deploy reference to "Stage 18" across docs,
  compose, Caddyfile, ci.yaml and CLAUDE.md; the post-Stage-14 split range is now
  "Stages 15–18".
Merge pull request 'Stage 16: deploy infra & test contour' (#17) from feature/stage-16-deploy-test-contour into development
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 19s
CI / deploy (push) Successful in 20s
dce3edacee
Fix Grafana dashboards mount; keep connector OTLP (AWG_CONF must omit DNS=)
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 20s
CI / deploy (pull_request) Successful in 19s
4a07d48a7b
- deploy/docker-compose.yml: mount the provisioned dashboards at
  /etc/grafana/dashboards, not /var/lib/grafana/dashboards — the grafana-data
  volume mounts over the latter and shadows the nested bind, so the provider
  logged "readdirent /var/lib/grafana/dashboards: no such file or directory".
  dashboards.yaml provider path updated to match.
- Connector telemetry stays OTLP. The VPN sidecar's netns reaches the collector's
  internal IP fine (connected route, off-tunnel), but the sidecar's DNS hijacks
  name resolution: AWG_CONF must NOT carry a DNS= directive, else otelcol won't
  resolve ("produced zero addresses"). Without DNS= the netns uses Docker's
  resolver (resolves both otelcol and api.telegram.org). Documented in
  deploy/README.md (AWG_CONF row + wiring note), ARCHITECTURE §13, compose comment.
Fix dangling config binds: seed configs to a stable host path
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 19s
CI / deploy (pull_request) Successful in 20s
831ecd0cab
Root cause of the Grafana "readdirent /etc/grafana/dashboards: no such file or
directory": the CI runner checks out into an ephemeral act workspace that is
removed after the job, so binding the compose config files straight from it
dangles the mounts in the long-lived containers (verified the act source dir is
emptied after the job). caddy/otelcol/prometheus/tempo read their config once at
startup so they survive, but would break on a restart — same latent bug.

Fix (mirrors ../galaxy-game's $HOME/.galaxy-dev/monitoring): the deploy job seeds
the config dirs to a stable $HOME/.scrabble-deploy and the compose binds them via
${SCRABBLE_CONFIG_DIR:-.} (local runs keep "."). Documented in the compose header,
deploy/README.md and the ci.yaml step.
Merge pull request 'Fix Grafana dashboards mount; connector OTLP via AWG_CONF (no DNS=)' (#18) from feature/contour-defect-fixes into development
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 19s
CI / deploy (push) Successful in 38s
6886efb6c0
- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn')
- #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback
- #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin)
- #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4)
- #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only
- #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal)
- ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
- #10 a `changes` job path-filters unit/integration/ui; an always-running `gate` job aggregates them (success-or-skipped) and becomes the only required check
- #9 deploy adds a Telegram-connector liveness probe (docker inspect: running, not restarting, stable restart count) with a VPN-handshake grace period
- #1a Game-domain dashboard gains a 'Move think-time by phase (p50/p95)' panel
- deploy README: branch protection now requires only CI / gate
- #6 align the UI variant id to the backend canonical 'russian_scrabble' (type, variants, Lobby, mock, tests) — fixes the New->Russian 400
- #11/#12 inside Telegram force the colour scheme from WebApp.colorScheme (over OS prefers-color-scheme, fixing the Telegram Desktop breakage) and hide the theme switcher
- #14/#15 nav bar takes Telegram's bg; announcement banner gets a dedicated subtle --ad-bg accent token
- #16 suppress the reconnect banner while backgrounded and silently reconnect the live stream on return to the foreground
- #17 hint zoom scrolls to the placement's bounding box, not the top-left
- #19/#20 players plaque: active seat raised with side shadows, others sunk; tap toggles history
- #21/#23 history: scrollbar-gutter:stable (no word jitter); fixed-height drawer pins the bottom shadow to the board
- #3 (UI) disable nudge on the player's own turn
- #18a directional screen slide transitions (forward in from the right, back reveals the lobby)
- #13 per-game in-memory cache: instant render on re-entry + background refresh
- e2e: openGame waits for the slide transition to settle
Stage 17: bake decisions into PLAN, ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, READMEs; mark stage done
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 26s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
09fec2b83c
- PLAN: Stage 17 Refinements entry + caveats resolved summary + tracker done
- ARCHITECTURE §7 (move-number robot timing, composed variant-aware names), §10 (move event to the actor too), §11 (game_move_duration metric + offline admin per-user analytics), §14 (current branch model, path-conditional CI + gate, connector liveness)
- FUNCTIONAL(+ru): robot draws language-appropriate names
- UI_DESIGN: screen transitions, Telegram theme/nav, ad-banner accent, players plaque + history drawer
- backend README: robot timing/names refinements
- Grafana: disable Live (GF_LIVE_MAX_CONNECTIONS=0) so its WebSocket no longer trips caddy Basic-Auth and re-prompts; admin console gains a Grafana nav link
- deploy: force-recreate config-only services so reseeded Grafana dashboards / Caddyfile are actually picked up (the move-duration panel was invisible because the bind-mount went stale)
- rate-limit: raise per-user budget 120/40 -> 300/80; UI skips reloading on the echo of the player's own move (fewer requests, no double-load)
- iOS/Telegram reconnect: suppress the connection banner while backgrounded and for a short grace after resume; reconnect silently; wire visibilitychange + pageshow/pagehide + Telegram activated/deactivated (Bot API 8.0)
- hint button disabled when 0 hints remain; nudge button shows a disabled state on your own turn
- players plaque: invert so the active seat pops (accent chip, raised) and others recede
- make-move UX: a direct  commit button (no hold/popover); the Shuffle tab becomes ↩️ Reset while tiles are pending
Stage 17 (#4): in-memory lobby cache — render instantly on the back-slide, refresh in background
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
645a503532
Stage 17 (contour round 3): Telegram Mini Apps polish, board scroll, keyboard overlay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 54s
f6bffd1f57
- Telegram (lib/telegram.ts): chrome colours (setHeaderColor/setBackgroundColor/setBottomBarColor) match Telegram's header/bg/bottom bar to the app; native BackButton on sub-screens (app chevron hidden in TG); HapticFeedback on tile place/commit/error; enableClosingConfirmation while a game is open; disableVerticalSwipes so swipe-to-minimise doesn't fight tile drag / board scroll
- #9 board-only vertical scroll: Screen 'column' mode lets the board area scroll while score/status/rack/tab bar stay fixed (zoom keeps its own scroll)
- #10 check-word dialog opens in Modal keyboard-overlay mode (top-anchored, keyboard overlays the empty area) — no resize/relayout jank; other modals stay keyboard-aware
- docs: UI_DESIGN Telegram integration + vertical fit/keyboard; PLAN round 2-3 follow-ups
Stage 17 (contour round 4a): quick fixes
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
b15fd30c4f
- #4 bag label: '{n} in the bag' / 'Bag is empty' (was 'Bag {n}')
- #6 allow a single trailing dot in display names (backend + UI regex + tests)
- #1 double-tap zooms toward the tapped cell, not the top-left
- #8 shuffle fires a short multi-pulse haptic
- #11 highlighted/flashing tiles darken their bottom edge too (shadow joins the flash)
- #13 toast slides up from the bottom and fades out
- #7 hide the logout button (kept wired behind `hidden`)
- #16 admin game seats: left-align numeric columns, clarify the 'Hints used' header
Stage 17 (#15): admin users people/robots toggle + display-name & external-id glob filters
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
54497374e4
- account.ListUsers/CountUsers with a UserFilter: people vs robots (by a robot identity),
  case-insensitive '*'/'?' glob masks on display_name and any identity's external_id
- admin users list shows the real kind (robot/guest/registered), defaults to people,
  with a People/Robots toggle + a filter form; pager preserves the filter
- integration test for the filter; SQL verified against the live contour DB
Stage 17 (#14): raise robot early-move band [1,5] -> [3,10] min (slower openings)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
4fd82335db
Stage 17 (#3,#5,#10): hover-hold drag zoom, always-editable profile, drag-back + double-tap recall
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
1bbf0bc654
- 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.
Stage 17 (#9): animated shuffle — tiles hop along a low parabola to new slots
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 28s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 53s
d0c1306d9b
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.
Stage 17 (#12): lines-off board variant (gapless checkerboard), Settings toggle
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
71b054227a
Add a 'Grid lines' preference (default off): when off the board drops the 1px grid
gaps for a gapless checkerboard (plain cells alternate shades; tiles get rounded
corners and a soft right-side shadow so adjacent gapless tiles still read apart),
saving ~14px of width. When on, the classic lined grid returns. Persisted with the
other board-style prefs; wired through Board's new lines prop. e2e locks the default
and the toggle.
Stage 17 docs: round-4 UI (inline profile, double-tap/drag recall, hover-zoom, animated shuffle, lines-off board)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 41s
3856b34f8a
- UI_DESIGN: double-tap recall vs zoom, hover-hold drag auto-zoom, placing & recall
  rules, grid-lines toggle (gapless checkerboard default), animated shuffle; fix the
  stale MakeMove/Reset description (direct  button + ↩️ Reset tab, no popover).
- FUNCTIONAL (+ru): optional trailing '.' in display names; profile edited inline.
- PLAN: robot early band [1,5]→[3,10] (#14); round-4 refinements + deferred #2/#16.
Stage 17 round 5 — backend/correctness bug fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Failing after 12s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
10412fee8e
- Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat
  (not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat.
- Quick-match cancel was a UI no-op (only stopped polling): add the full path
  (REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's
  pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no
  later robot-substituted game). NewGame dequeues on cancel and on abandon.
- Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win.
  The winner now takes rank 1 and the viewer is placed from rank 2 — matching the
  game-detail screen.
- Friend request to a robot: robots no longer block requests; the request stays
  pending and expires (friendRequestTTL), mirroring a human who ignores it.
- Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a
  correct message; the chat nudge button disables during the hourly cooldown; the
  nudge note reads 'Waiting for your move!' (button keeps the Nudge action label).
Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot
inttest, result.ts 0-0 resignation, nudge_too_soon mapping.
Stage 17 round 5: fix robot-pool test for the new friend-request policy
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 11s
3899ffda0f
TestRobotPoolProvisionsRobotAccounts asserted robots block friend requests; they no
longer do (a request stays pending and expires like a human ignore). Assert chat is
blocked and friend requests are open. (Unblocks the integration job / contour deploy.)
Stage 17 round 5 — board interaction & UI polish
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
29d1193a0a
- 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').
Stage 17 round 5 (L2): robot play-to-win intent + next-move ETA in the admin game card
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m14s
f916d5e0ca
The admin game detail now shows, per robot seat, the game's deterministic play-to-win
decision (from the bag seed) and — while it is that robot's turn — its scheduled next-move
ETA (sampled think-time delay, deferred past the sleep window), plus a caption with the
~40% global target. Wiring: robot.PlayToWin/NextMoveAt/PlayToWinTargetPercent exports,
account.IsRobot, game RobotSchedule (seed + turn-start). Tests: NextMoveAt invariants
(never early, never in the sleep window), PlayToWin export, and an admin render integration
test asserting the intent + ETA + target appear.
Stage 17 round 5 docs: bake the bug fixes + UI polish + L2 into live docs
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 52s
a420d6a2cd
- ARCHITECTURE: resign on the opponent's turn (ResignSeat + turn-check bypass); robots
  block chat but accept-and-ignore friend requests; quick-match /lobby/cancel; the admin
  robot play-to-win intent + next-move ETA panel.
- UI_DESIGN: even A->B zoom (recentre only on zoom-in), pinch, drop-target highlight,
  shuffle ≤0.3s + reduce-motion, borderless make-move disabled on illegal, variant title.
- FUNCTIONAL (+ru): variant display names (Scrabble/Erudite); robot ignores friend requests.
- PLAN: round-5 refinements bullet (+ the bilingual two-Scrabble open edge).
Stage 17 round 6 (cluster 1): profile, tap flash, variant naming, chat/nudge by turn
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
512ad4dfb9
- Profile: drop the hint-balance line.
- Board: no mobile tap flash on a cell tap (-webkit-tap-highlight-color: transparent),
  matching the web click; the only intentional cell animation stays the last-word flash.
- Variant names keyed by the game's alphabet, not the UI language: english -> Scrabble
  always, russian_scrabble -> Скрэббл always (unlocalized, never collide), erudit localized.
- Chat/nudge are mutually exclusive by turn: the message field + Send show on your turn,
  the nudge replaces them on the opponent's turn; while the nudge cooldown is active the
  button is disabled with a grey 'awaiting reply' caption to its left.
Stage 17 round 6 (#10 backend): enforce chat only on your turn
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
2cb2b57cdb
PostMessage now rejects a chat sent on a finished game or when it is not the sender's
turn (ErrChatNotYourTurn -> 409 chat_not_your_turn), matching the UI where the message
field is hidden off-turn and only the nudge shows. Existing chat tests post on the
to-move seat and are unaffected; adds an off-turn-rejection integration test + the dto
mapping case + the UI error message.
Stage 17 round 6 (#7): reset the nudge cooldown once the player acts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
cdf616d6c4
The hourly nudge cooldown now clears as soon as the sender has moved or posted a chat
since their last nudge — engagement lifts the 'don't spam' limit. Backend: Nudge checks
game.LastMoveAt + the sender's last non-nudge chat against the last nudge time
(GameReader gains LastMoveAt). UI: nudgeOnCooldown mirrors it — a chat reset is read from
the message list, a move is tracked client-side (lastActedAt on commit/pass/exchange; the
backend stays authoritative across a reload). Integration test covers the reset.
Stage 17 round 6 (#13/About): About screen content + app version from git describe
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
74683f294f
- About screen: prominent localized title (Scrabble / Эрудит (Скрэббл)), a rules link
  (en/ru Wikipedia), and the Random-game / Game-with-friends sections; copy lives in a
  shared aboutContent module (the landing will reuse it). The random-game move limit
  inlines the 24h auto-match clock.
- App version: Vite define __APP_VERSION__ from VITE_APP_VERSION (default 'dev'), wired as
  a Docker build-arg sourced from `git describe --tags --always` in the deploy step — no
  manual version bumps. The fallback keeps a plain/local build working.
Stage 17 round 6 (#11/#12): quick-game variant plaques with rules, flag, and move-limit
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
d3657fdf5c
Each auto-match variant is now a lobby-style plaque: the display name with a flag on the
right (🇺🇸 / 🇷🇺; Erudit uses a bundled minimalist USSR flag SVG) and a one-line rules
summary below — bag size, the ё rule, and bonus differences, sourced from the engine
rulesets (Scrabble 100 · Скрэббл 104, ё a letter · Эрудит 131, ё=е, no centre ×2, +15).
The move-time limit (24h auto-match clock) is shown under the buttons. e2e locks it.

(Multiple-words-per-move is the same for every variant, so it is described in About/landing
rather than repeated on each button.)
Stage 17 round 6 fixes: pin the nudge button right; schematic USSR flag emblem
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
35666e1705
- Chat: always render the (possibly empty) flex:1 caption before the nudge button, so the
  nudge stays pinned right whether or not the cooldown text shows (it drifted left when
  available).
- USSR flag: redraw the hammer & sickle as a thin schematic sketch — an elongated
  semicircle sickle with a handle, crossed by a T-shaped hammer (per the original's
  structure), instead of the bold over-filled emblem; the star is a touch smaller.
Stage 17 round 6 (#3): drag-reorder rack tiles with a visual gap
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
2b0b1c0035
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).
Stage 17 round 6 (#4/#5/#6 backend): per-game draft store + conflict reset
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
06c8039281
Foundation for persisting a player's client-side composition: a game_drafts table
(game_id, account_id, rack_order, board_tiles jsonb) with raw-SQL store/service methods —
GetDraft/SaveDraft (seated-player check) and, on every committed move, clearing the actor's
own draft and resetting any opponent's board draft whose cell the play overlapped (the
draft can no longer be placed; the rack order is kept). Integration tests cover the
round-trip, the actor clear, the overlap reset, a non-conflicting survival, and the
outsider rejection. The gateway op slice + UI wiring follow.
Stage 17 round 6: log progress + the next-pass plan in the tracker
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
3632c2239f
Record round 6 in PLAN.md so the repository (not conversation memory) carries the trace:
the shipped follow-ups (profile, tap-flash, alphabet-keyed variant names + title, chat/nudge
by turn + cooldown reset, About + git-describe version, quick-game rules plaques, the two
fixes, #3 rack drag-reorder, and the #4/#5/#6 persistence backend foundation), plus the
REMAINING next-pass work with ready designs — the persistence gateway op-slice + UI wiring
(lean JSON-string FB; #5 off-turn placement) and the landing + /app/ move (#16-20).
Merge pull request 'Stage 17: test-contour verification & defect fixes' (#19) from feature/stage-17-contour-verification-fixes into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 29s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m9s
353dff20c4
Stage 17 round 6 (#4/#5/#6): draft persistence wire + gateway + UI
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
f5c2404123
Complete the client-side draft feature on top of the shipped backend
foundation (the game_drafts store/service):

- FB: DraftRequest{game_id,json} + DraftView{json} (a draft get reuses
  GameActionRequest); regenerated committed Go + TS bindings.
- Backend REST: GET/PUT /games/:id/draft, a draftDTO
  (rack_order/board_tiles) mapped to game.Draft.
- Gateway: draft.get/draft.save transcode forwarding the composition
  JSON verbatim (json.RawMessage both ways -- no double-encode).
- UI: debounced save of the rack order + board tiles and restore on
  load (lib/draft.ts), plus #5 -- tiles may be arranged on the
  opponent's turn (placement relaxed; the preview and Make-move stay
  your-turn-only, so an off-turn draft is position-only).

Tests: backend handler validation, gateway pass-through round-trip, UI
draft/codec units, and a draft-restore e2e.
Merge pull request 'Stage 17 round 6 (#4/#5/#6): draft persistence wire + gateway + UI' (#20) from feature/stage-17-round-6-drafts into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 32s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m1s
b8787a4123
Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
e16076c89e
Close out Stage 17 round 6:

- Landing page at / — one Vite build with two entries (index.html = game
  SPA, landing.html = a lightweight landing reusing the theme/i18n/
  aboutContent leaf modules, not the app store).
- Move the web game SPA to /app/; the Telegram Mini App stays at /telegram/
  (gateway webui.Handler(stripPrefix, indexName): landing at /, SPA at /app/
  + /telegram/). Per-language "Play in Telegram" link via new
  VITE_TELEGRAM_LINK_EN/_RU build vars (button hides when unset).
- Cache headers: hash-named /assets/* immutable, HTML shells no-cache (the
  go:embed zero modtime emitted no validators, so the client re-downloaded
  the whole bundle every launch).
- Live-stream 15s abort fix: an immediate heartbeat on open + a 10s default
  interval (the first tick at 15s raced the edge idle timeout -> reconnect
  storm).

PLAN/ARCHITECTURE(§13)/FUNCTIONAL(+ru)/gateway+ui+deploy READMEs updated;
round 6 closed. Tests: gateway webui/connectsrv units, ui landing unit + e2e,
full e2e (60) green.
Merge pull request 'Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes' (#21) from feature/stage-17-round-6-landing into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 31s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m6s
5928be40b0
Landing v2: icon switchers, ephemeral theme, channel link, drop browser CTA
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 7s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
3fd279cf8c
Owner review-pass rework of the landing page:
- Rename the per-language Telegram link build var
  VITE_TELEGRAM_LINK_EN/_RU -> VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU
  (it carries a channel username; the landing builds https://t.me/<name> --
  the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*).
- Language switcher -> a globe icon dropdown (flags + names), saved + synced
  to the app prefs.
- Theme switcher -> a sun/moon icon toggle, ephemeral (follows the system
  scheme, no auto, never persisted) -- galaxy-game style.
- Drop the "Play in browser" CTA (no standalone-web onboarding yet).

Docs: FUNCTIONAL(+ru), PLAN, deploy + ui READMEs.
Game/Telegram review polish: USSR flag, touch drag ghost, TG fullscreen header
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
34385240b9
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.
Review fixes #2: bigger flag star, TG header below nav, board-tile relocation
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
b720907db2
Addressing the review on #23:
- Flag star scaled up ~25% (the hammer&sickle emblem unchanged, kept clear of it).
- TG fullscreen header: drop the WHOLE header below the content-safe-area top
  inset (the hamburger stays to the right of the title), instead of pinning the
  hamburger to the physical top edge.
- DnD: a placed (pending) tile can now be relocated by dragging it to another
  board cell (board->board); it lifts off its source cell while dragged; and it
  can be grabbed even on the zoomed board (touch-action:none on the pending
  cell, so the drag wins over the board pan). The manual-selection blue frame
  now clears on recall.
Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
6b6baf5710
Lobby: group the my-games list into your-turn / opponent-turn / finished
(empty sections hidden), ordered by last activity (your-turn oldest-first,
the other two newest-first), as a compact line-separated list. gameDTO and
FB GameView gain last_activity_unix (turn start while active, finish time
once finished); a pure lib/lobbysort.ts holds the grouping/ordering.

Friends: the in-game 'add to friends' item is now server-derived via a new
GET /user/friends/outgoing (+ friends.outgoing op), returning addressees with
a pending OR declined request (both read as 'request sent'), so it is correct
across reloads; it shows a disabled '✓ in friends' once accepted. It
live-updates when the opponent answers: RespondFriendRequest now publishes
friend_added (accept) / friend_declined (new notify sub-kind, decline) to the
original requester, whose open game re-derives its friend state.

Tests: lobbysort unit test; gateway outgoing + last_activity transcode tests;
backend integration ListOutgoingRequests + respond-publishes-to-requester;
e2e updated for the new lobby section labels + a non-friend active opponent.
Docs: ARCHITECTURE notify catalog, FUNCTIONAL(+ru) lobby/friends, PLAN.
Stage 17 round 6 (#18, PR D): admin Messages moderation section
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m14s
356f490546
A new /_gm/messages console page lists posted chat messages (nudges
excluded) newest-first — time, source (guest/robot/oldest identity kind),
sender (linked to the user card), IP, body, game (linked to the game card)
— searchable by sender name / external-id glob masks and pinnable to one
game (?game=) or sender (?user=), linked from the game and user cards.

The list query lives in social (raw SQL, kind='message', source via a SQL
CASE), reusing the now-exported account.LikePattern. Server-rendered
adminconsole MessagesView + messages.gohtml, 50/page via the shared pager.

Tests: adminconsole render case; backend integration AdminListMessages
(real Postgres) — nudge exclusion, game/sender pins, glob masks, source.
Docs: ARCHITECTURE section 8 chat moderation, PLAN round-6.
Merge pull request 'Landing v2: icon switchers, ephemeral theme, channel link, drop browser CTA' (#22) from feature/landing-v2 into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 31s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m7s
83e9a90d40
Merge pull request 'Game/Telegram review polish: USSR flag, touch drag ghost, TG fullscreen header' (#23) from feature/game-ux 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 31s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
c9a1eee510
Merge pull request 'Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state' (#24) from feature/lobby-friends into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 31s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m6s
5d677cb282
Merge pull request 'Stage 17 round 6 (#18, PR D): admin Messages moderation section' (#25) from feature/admin-messages into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m5s
f95a6cb9c8
Round-6 follow-up: UX polish + client-IP fix
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
645df52c0b
- Client IP: the compose caddy trusts X-Forwarded-For from private-range
  upstreams (trusted_proxies private_ranges), so the real client IP survives
  the host-caddy hop (it was logging the docker caddy hop 172.18.0.x for chat
  moderation and bucketing the gateway per-IP rate limiter on it). Correct and
  spoof-safe in both contours (prod has no host caddy); peerIP unit-tested.
- Ad banner gated off behind a compile-time SHOW_AD_BANNER=false (the if-branch,
  the AdBanner import and banner.ts are tree-shaken out of the prod bundle).
- Landing: the Telegram entry is just the 64px logo (clickable, no button/text).
- TG-fullscreen header: title + menu centred as a pair (hamburger right of the
  title), pinned to the bottom of the TG nav band.
- Edge-swipe back (Screen): a left-edge rightward drag navigates to back
  (touch/pen only, armed from <=24px; skipped inside Telegram).
- Chat soft-keyboard: a bottom-sheet Modal lifted above the keyboard by a
  visualViewport-driven transform (compositor-only, no page/sheet relayout).
  iOS-specific, needs on-device tuning; native resize=none awaits Capacitor.
- Tests: e2e for the in-game '✓ in friends' item and a board→board tile
  relocation; codec units for last_activity_unix + OutgoingRequestList.

Deferred to the next PR (agreed): #4 enrich the your-turn/game-end push; #5 hide
finished games from the lobby.
Review fixes: swipe (capture-phase, enabled in TG), TG header aligns to the nav band, DnD zoom delay 1s->0.7s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
7e34897d6d
- Edge-swipe back now listens at the window in the CAPTURE phase (the board's
  pointer handlers can't swallow it) and is no longer skipped inside Telegram
  (where the owner tests it).
- TG-fullscreen header: expose the device safe-area top (--tg-safe-top) and
  centre the title + menu pair within Telegram's nav band ([safe-top,
  content-top]) below the notch, keeping the band's height — lining up with
  Telegram's own controls.
- DnD auto-zoom-on-hover delay reduced 1000ms -> 700ms.

(Client-IP: diagnosed as the owner's home-router SNAT — the host caddy already
receives 192.168.0.1 with no XFF, so the real IP is lost upstream of our stack;
correct in prod. No code change.)
Admin Messages: CSV export of the whole filtered list
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 33s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m14s
c96d714fec
A right-aligned 'Export CSV ↓' link in the filter row downloads /_gm/messages.csv
with the active filters (game / sender / name / ext masks), exporting every matching
message (capped at 100k) regardless of the page window — columns time, source,
sender_id, sender, ip, message, game_id.
Admin Messages CSV: defuse spreadsheet formula injection
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 33s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
461e330bfc
The sender name and message body are user-controlled; a leading =, +, -, @, tab or
CR in the CSV export would execute as a formula when a moderator opens it in a
spreadsheet. csvSafe() prefixes such values with a single quote. Unit-tested.
TG-fullscreen header: +6px band height so native controls aren't flush (owner tweak)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 33s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m14s
a132edd40a
TG-fullscreen header: add height via padding (min-height wasn't binding), +12px breathing room
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 33s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
295e45486d
Chat + word-check as their own screens; in-game unread badge (review item 7)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 34s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
70110effd9
- Chat and word-check are now routed screens (/game/:id/chat, /game/:id/check) with a
  header back to the game and no tab-bar, replacing their modals. The soft keyboard just
  resizes the visible viewport (tracked into --vvh, which the Screen height uses since iOS
  does not shrink dvh for the keyboard) with the input pinned to the bottom: no modal
  relayout, no page jump. Supersedes the earlier bottom-sheet Modal attempt.
- A new chat message raises an unread badge on the in-game hamburger + the Chat menu row
  (per game, cleared on opening the chat), mirroring the lobby badge.
- TG native back + the header back chevron return chat/check to their game.
- Exposes --tg-safe-top (device notch) for the finalised TG-fullscreen header.

Tests: e2e for chat/check opening as their own screens + back. Docs: PLAN, FUNCTIONAL(+ru).
Fix screen-slide direction: by route depth, so back from chat/check slides back
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
a84e9d8cb7
The 'lobby is back' rule slid the chat/check back-to-the-game forward. Direction is now
computed from route depth (lobby < game < chat/check): shallower = back, deeper = forward.
Merge pull request 'Round-6 follow-up: UX polish + client-IP fix' (#26) from feature/ux-polish-ipfix into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 34s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m6s
a7c566d2d1
Stage 17 #5: hide finished games from your own lobby list
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m16s
4999478ded
A player can remove a finished game from their own 'my games' list. The action is
per-account, finished-only and irreversible (the game stays for the other players;
there is no un-hide).

- backend: migration 00012 game_hidden(account_id, game_id); store HideGame +
  hiddenGameIDs + ListGamesForAccount filtering; service HideGame (seat + finished
  checks, reusing ErrNotAPlayer / ErrGameActive); POST /api/v1/user/games/:id/hide.
- gateway: game.hide edge op (reuses GameActionRequest -> Ack) + backendclient.HideGame.
- ui: finished rows reveal a delete via swipe-left (touch) or a kebab tap (desktop),
  active rows get an inert chevron for icon alignment; optimistic removal + lobby-cache
  sync; mock + transport + client wiring; lobby.hideGame label (en/ru).
- tests: integration (active->ErrGameActive, outsider->ErrNotAPlayer, per-account,
  idempotent), gateway transcode round-trip, mock e2e (kebab -> delete); hardened a
  pre-existing chat-screen .back transition flake surfaced by the new test's timing.
- docs: ARCHITECTURE persistence list, FUNCTIONAL (+ _ru) lobby story, PLAN tracker.
Stage 17 #5: make the active-row chevron open the game (not a no-op)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
13361c098c
Owner review: the '>' on an active game row should be a real tap target that opens
the game, like the rest of the row — not inert. The chevron now navigates (kept out
of the tab order / a11y tree since the row's main button already does the same), and
active-row swipes no longer suppress the tap. Adds an e2e for the chevron navigation.
Merge pull request 'Stage 17 #5: hide finished games from your own lobby list' (#27) from feature/hide-finished-games into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 35s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m5s
6956dad354
Stage 17 #4: enrich the out-of-app your-turn push + add game-over
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 34s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
f166ff30fe
The Telegram 'your turn' notification now names the opponent and recaps their last
move (voiced as the opponent: «{name}: my move — «WORD». Score 120:95» for a scoring
play; a short 'swapped / passed, your turn' otherwise), and a new game-over
notification reports the result + final score when a game ends by any path (closing
play, all-pass, resign, timeout). Scores are recipient-first (the reader's score
leads), 2-4 players (120:95:80).

- schema: YourTurnEvent gains opponent_name/last_action/last_word/score_line
  (appended, backward-compatible); new GameOverEvent{result, score_line}. Go + UI
  bindings regenerated (flatc 23.5.26 + pnpm codegen).
- backend: notify.YourTurn enriched + notify.GameOver; emitMove resolves the mover's
  name and emits per-recipient (your_turn to the next mover, game_over to every seat),
  with recipient-first score lines built in one place.
- gateway: game_over joins the out-of-app whitelist (routing.go).
- connector: render builds the enriched your_turn + game_over text per language (en/ru).
- tests: notify round-trip (enriched + game_over), emit (enriched fields + game_over to
  all seats / per-seat result), connector render (en/ru), routing; integration replay
  (play → your_turn with real name; resign → game_over) green.
- docs: ARCHITECTURE push catalog + out-of-app set, FUNCTIONAL (+ _ru), PLAN tracker.
Merge pull request 'Stage 17 #4: enrich the out-of-app your-turn push + add game-over' (#28) from feature/push-enrichment into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 35s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m10s
844f26bbae
Stage 17 #2: Connecting indicator + auto-retry, instead of red toasts
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 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
ef61b778fc
Connectivity failures become state, not a toast on every attempt. A global online
signal (lib/connection.svelte.ts) flips on a transport unavailable / rate_limited and
on the live stream's drop, driving a pure-CSS header spinner + 'Connecting…' in place
of the title and softly disabling the in-game server actions (commit / exchange / pass
/ hint; local board/rack/reset stay live).

- transport: exec auto-retries with capped exponential backoff — every op on a
  rate-limit (rejected before processing, safe), reads only on unavailable (a mutation
  is never blindly re-sent, to avoid double-applying one whose response was lost; its
  button is disabled while offline so the player re-issues on reconnect). A reachability
  watcher (profile.get probe) and any successful traffic clear the signal.
- the old red error.unavailable toast is gone (handleError suppresses connection codes;
  the indicator replaces it). A server-data screen still opens with the spinner and
  fills on reconnect (global indicator + read auto-retry), so navigation is never dead.
- pure retry policy unit-tested (retry.ts); a mock-only window.__conn hook drives a
  Chromium+WebKit e2e (indicator shows offline, the action disables, both clear on
  reconnect). Full suite + build green.
- docs: ARCHITECTURE transport note, FUNCTIONAL (+ _ru), PLAN tracker (incl. #1 — the
  bot already drains all updates, no change).

Also records #1 as investigated/no-change in PLAN. Other server-action buttons (chat
send, profile save, …) still degrade to a safe no-op offline; visual disable is easy to
extend.
Stage 17 #2: extend the offline soft-disable to all server-action buttons
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 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
efa1d0bd22
Following the in-game bar, the Connecting indicator now also visually disables the
other proactive (server-sending) controls while offline: chat send + nudge, profile
save / link email|telegram / merge-confirm, friends (redeem, get-code, accept/decline,
unfriend, block, unblock), New Game (auto-match variant + send-invitation) and the
lobby hide . Purely local controls (board/rack/reset, menus, navigation, settings,
copy-code) stay live. Each reads the global connection.online signal; full e2e + check
green.
A dropped/reset/timed-out connection can surface as a Connect code other than
Unavailable (Canceled/DeadlineExceeded/Unknown/…) which fell through to the generic
'internal' -> a red 'something went wrong' toast appeared alongside the Connecting
spinner. Now toGatewayError (moved to the pure retry.ts, unit-tested) collapses every
transport-level code to 'unavailable' so it is retried + flips offline; and handleError
suppresses the toast for any connection code AND whenever the app is mid-reconnect
(!connection.online), covering the race where a unary error lands before the stream
reports the drop. Genuine server-internal / domain errors still toast while online.
Stage 17: cap display-name special characters at 5 (ui + backend)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
d87c0fb10b
display_name validation gains a rule: at most 5 special characters — the '.' / '_'
punctuation (spaces, which separate words, don't count) — so a still-well-formed name
can't be mostly punctuation. Mirrored in the Go ValidateDisplayName and the UI
validDisplayName; both unit-tested (5 ok, 6 rejected, 'J. R. R. Tolkien' ok). Docs:
FUNCTIONAL (+ _ru).
Merge pull request 'Stage 17 #2: Connecting indicator + auto-retry (no more red toasts)' (#29) from feature/connecting-indicator into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
265e442252
Stage 17: fix the robot-nudge frequency + per-game push language
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
bf7dca0a09
Two owner-reported defects from a live contour game.

A. Frequency: the robot's proactive nudge fired hourly for 12h+ (a 12h idle threshold
   then the 1h cooldown, uncapped). Replaced with a lengthening, randomized schedule
   (proactiveNudgeGap): the first nudge ~60-90 min into the human's turn, each later gap
   growing toward 1-6h (uniform sample in [60min, ceil], ceil ramping 90min->6h over 12h
   of idle, measured from the previous nudge), so a long wait gets a handful of
   increasingly-spaced reminders instead of a stream.

B. Language: out-of-app push routed by the recipient's GLOBAL service_language
   (last-login-wins), so after re-logging via the RU bot an English game's nudges came
   from the RU bot. Now a game push (your_turn, game_over, nudge, match_found) carries
   the game's own language (engine.Variant.Language) on push.Event, and the gateway
   routes by it (falling back to service_language for non-game pushes). The New-Game
   variant-gating guarantees the game's bot is one the player has started, so delivery is
   never blocked.

Tests: proactiveNudgeGap unit + retimed TestRobotProactiveNudge; TestVariantLanguage;
emit your_turn/game_over language; TestNudgeRoutedByGameLanguage integration. Docs:
ARCHITECTURE (§7 nudge, §10/§13 routing), FUNCTIONAL (+ _ru), PLAN tracker.
Merge pull request 'Stage 17: robot-nudge frequency + per-game push language' (#30) from feature/nudge-fix into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 54s
70e3fab512
R1: schema & naming reset — squash migrations, rename variants
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
26aa154547
Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod
data; verified schema-identical to the chain via a pg_dump diff + the green
integration suite) and rename the game-variant labels
english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the
backend, the FlatBuffers wire values and the UI.

dawg filenames and the Go enum identifiers are unchanged; the i18n display keys
are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from
CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups.
Merge pull request 'R1: schema & naming reset — squash migrations, rename variants' (#31) from feature/r1-schema-naming-reset into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
bf07f77078
R1: mark done in PRERELEASE.md (post-merge close-out)
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
8bfc44aad0
scrabble-game #31 + scrabble-dictionary #2 merged, v1.0.1 cut, contour DB wiped
and re-migrated to the baseline (verified).
Merge pull request 'R1: mark done in PRERELEASE.md (post-merge close-out)' (#32) from feature/r1-close into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m1s
bf3ee62711
R2: load-test harness + contour resource observability
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s
aa137e3558
New scrabble/loadtest module (the pre-release stress harness): seeds 1000 guest +
10000 durable accounts with pre-created sessions directly in Postgres (token hash
matches backend/internal/session), drives virtual players through the edge protocol
(real 2-4p games assembled via invitations, mid-ranked legal moves generated locally
by the embedded scrabble-solver — the edge carries no board, so the client replays
history), plus nudge/chat/check-word/draft/profile/stats and a gateway-hammer that
verifies the rate limiter. Prints a trip-report summary (per-op latency percentiles,
result codes, live-event tally). Go unit tests cover the pure pieces; the DAWG-backed
move test runs under BACKEND_DICT_DIR.

Contour: add cAdvisor + postgres_exporter + a 'Scrabble - Resources' Grafana
dashboard and the two Prometheus scrape jobs, for the R2/R7 stress-run resource
baseline.

CI: gate ./loadtest/... (path filter + vet/build/test). Docs: TESTING, ARCHITECTURE,
project CLAUDE repo layout.
R2: drop ./loadtest from the backend/gateway/telegram image builds
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
0c55574ddd
Adding the loadtest module to go.work (use ./loadtest + the scrabble/gateway
replace it needs) broke the other services' Docker builds: their reduced
workspace still referenced ./loadtest (not in their build context), failing with
'cannot load module loadtest: open loadtest/go.mod: no such file or directory'.
Each service Dockerfile now also -dropuse=./loadtest; backend and telegram (which
do not COPY ./gateway) additionally -dropreplace the loadtest-only scrabble/gateway
replace. Verified by building all three images plus loadtest locally.
- display-name marker: letters-only 'Zzloadtest' (the editable-name validator
  forbids digits/colons), so profile.update resends the seeded name successfully.
- draft.save: rack_order is a string in the backend draft DTO (was sent as []),
  fixing the bad_request.
Both confirmed ok against the contour. chat_not_your_turn / nudge_own_turn are
by-design turn gates (backend/internal/social/chat.go), correctly exercised.
R2: early-pass trip report + mark R2 done
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 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
a2265a122e
Ran the moderate early pass (50/200/500, 10 min/step) against the contour: ramped
clean to 500 players, 1.2 M edge calls, 48 870 plays, 2 798 games finished, no
crash/deadlock; cleanup removed all 11 000 seeded accounts. The per-user limiter held
under the gateway-hammer (99.97 % rejected, p99 2 ms).

Top finding: ~14 % transport_error on game.state at 500 players under CPU saturation
(backend/gateway/Postgres each ~1 core), amplified by the harness's single shared
http2.Transport (the harness itself peaked at 86 % of a core on the same host).
Observability finding: cAdvisor yields only the root cgroup on the contour host
(separate XFS /var/lib/docker); per-container metrics captured via docker stats; R7
should adopt the otelcol docker_stats receiver. Full report in loadtest/REPORT-R2.md;
PRERELEASE refinements logged; R2 marked done.
Merge pull request 'R2: stress harness + contour resource observability + early run' (#33) from feature/r2-loadtest-observability into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 55s
c23ac94c4e
- GATEWAY_MAX_BODY_BYTES (1 MiB): connect WithReadMaxBytes + http.MaxBytesReader
  on the public mux; explicit http2.Server MaxConcurrentStreams/IdleTimeout and
  an http.Server ReadHeaderTimeout (R2 report follow-up).
- gateway_rate_limited_total{class} counter, Debug per rejection, a rejection
  tracker drained every 30 s into a Warn summary per key and a report POST to
  /api/v1/internal/ratelimit/report (feeds the admin view + auto-flag).
- The dead AdminPerMinute/AdminBurst policy now guards the /_gm mount (429),
  ahead of its Basic-Auth.
- resolve() logs the cause of infra session-resolve failures at Warn (the
  transient unauthenticated dips from the R2 run); unknown tokens stay silent.
- accounts.flagged_high_rate_at baked into the R1 baseline (no prod data; the
  contour schema is wiped after merge); jet regenerated — the regen also picks
  up the previously missing game_drafts/game_hidden models.
- account.Store: FlagHighRate (set-once), ClearHighRateFlag, the flag in
  GetByID/ListUsers and a ListFlaggedHighRate review queue.
- New internal/ratewatch: ingests the gateway rejection reports, keeps a
  bounded in-memory episode window for the console and applies the
  conservative auto-flag (1000 rejected / 10 min, BACKEND_HIGHRATE_FLAG_*).
- POST /api/v1/internal/ratelimit/report (network-trusted, like
  sessions/resolve).
- Admin console: Throttled page (episodes + flagged accounts), a high-rate
  badge in the user list, the marker + operator clear action on the user card.
- Tests: ratewatch unit suite, report-route handler test, renderer cases,
  integration coverage for the store round-trip and the console flow.
- gateway/Dockerfile gains a `landing` target: caddy:2-alpine + the shared
  Vite build (identical build args keep the ui stage a single cached build);
  the gateway target drops landing.html from the embed.
- The contour caddy routes /app/, /telegram/ and the Connect path to the
  gateway; the catch-all — the landing at / and any stray path — goes to the
  new landing service, so junk traffic is absorbed by static file serving.
- deploy/landing/Caddyfile mirrors the webui caching (immutable assets,
  no-cache shells) and falls back unknown paths to the landing shell.
- The gateway's / now 308-redirects to /app/ (keeps a local no-caddy run
  usable); webui placeholder landing.html removed.
- CI deploy probe checks both / (landing) and /app/ (gateway).

Verified: both images build; the landing container serves landing.html at /
(no-cache) with junk-path fallback; the gateway image redirects / to /app/
and carries no landing content.
R3: dashboards, docs and tracker bake-back
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
7e75c32d07
- Edge/UX dashboard: aggregate request-rate vs rejection-rate panel
  (gateway_rate_limited_total by class; no per-user labels).
- ARCHITECTURE §2/§11/§12/§13: body cap + explicit h2c sizing, the rate-limit
  observability pipeline and auto-flag policy, the admin-limiter note (and the
  caddy-path gap), the landing container topology; fixed the stale 120/min
  per-user figure.
- FUNCTIONAL (+_ru): the Throttled view and the reversible high-rate flag.
- gateway/backend/deploy READMEs, TESTING.md, root CLAUDE.md updated.
- PRERELEASE.md: R3 interview decisions + implementation refinements logged;
  tracker R3 -> done (this PR implements it; CI gates the merge).
Merge pull request 'R3: edge hardening — body cap, rate-limit observability, auto-flag, landing split' (#34) from feature/r3-edge-hardening into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
e3b08461f0
R4: push enrichment — events carry a state delta, kill the last poll
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 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
41a642ef97
Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback.

- pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS.
- backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size.
- gateway: MoveResult transcode carries rack+bag_len.
- ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false.
- docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements.
Merge pull request 'R4: push enrichment — events carry a state delta, kill the last poll' (#35) from feature/r4-push-enrichment into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 37s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
7ec17cdd53
R5: bundle slimming — retarget the budget to the app, no code slimming
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 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
d4ef951db9
Analysed the real dist (gzip + sourcemap attribution): the bundle is already minified + tree-shaken and dominated by the Connect/FlatBuffers transport runtime + generated bindings + the Svelte runtime (~2/3 of main), so no in-scope code slimming is warranted. Lazy-loading was rejected (bundle-size.mjs sums every chunk -> zero total-size win, plus +N gateway fetches of latency); i18n lazy-load and chunk-collapsing likewise (caching/HTTP2).

Instead bundle-size.mjs now measures per HTML entry with three independent gates (app entry <=100 KB, Svelte+i18n shared <=30 KB, landing-own <=5 KB): the app's real payload is its entry chunk + the shared chunk (~97 KB), never landing.js. Same CLI + exit-code contract, CI step unchanged. Fixed the stale ~82 KB figure in the script and ui/README.md. No app code change.
Merge pull request 'R5: bundle slimming — retarget the budget to the app, no code slimming' (#36) from feature/r5-bundle-budget 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 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
a372343797
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
Pass (a) removed a stale "(reaping abandoned guest rows is deferred — TODO-3)"
note from ARCHITECTURE §3, but guest reaping is implemented (the background
reaper, BACKEND_GUEST_REAP_INTERVAL / BACKEND_GUEST_RETENTION, covered by
inttest). State the current behaviour instead.

A full section-by-section review of ARCHITECTURE / FUNCTIONAL (+_ru) / TESTING /
UI_DESIGN against the code found no other drift — each R-phase baked its own docs,
and FUNCTIONAL/TESTING already describe the reaper correctly.
These pre-R4 summary scalars on OpponentMovedEvent were redundant with the
move/game delta and read by nobody — the UI codec and mock take only
move/game/bag_len, and the gateway forwards the push payload verbatim. Removed
from scrabble.fbs, the notify emit (notify/events.go) and the round-trip test;
regenerated the FB Go + TS bindings. No prod data, so the wire-slot renumber is
free and there is no DB change.
Extract the FlatBuffers builders for the wire tables shared by the backend push
encoder and the gateway edge transcoder — GameView, MoveRecord, StateView,
AccountRef, Invitation and their nested rows — into a new scrabble/pkg/wire
package. Both callers keep their local builder signatures (no call sites move)
but now map their own source types (the backend's notify.* payloads and the
decoded engine.MoveRecord; the gateway's backendclient.* REST DTOs) to neutral
wire.* structs and delegate the construction to package wire, the single
definition of the nested-table layout.

Behaviour-preserving: the verified-identical field sets mean the wire bytes
decode the same, and the notify + transcode round-trip tests pass unchanged. The
fiddly Start/Add/End + reverse-prepend vector boilerplate now lives once; the two
encode files shrink while pkg/wire carries the shared logic.
Move the cross-file integration fixtures — the service constructors
(newGameService/newSocialService/newRobotService/newMatchmaker), the game-assembly
helpers (newMirror/newGameWithSeats/newDraftGame), account provisioning
(provisionAccount/provisionGuest) and the stats reader — out of the domain test
files (newGameService alone was used by 10 files) into a single
backend/internal/inttest/helpers.go. Helpers used by a single file stay local.

Pure relocation: the helper bodies are unchanged, no test logic changes; the
imports the moves left unused are pruned. go vet -tags=integration is clean.
R6: mark phase done in PRERELEASE.md + log refinements
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 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
40ccfb9514
Merge pull request 'R6: refactor + docs reconciliation + de-staging' (#37) from feature/r6-refactor-destage into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m1s
7210bed560
Each virtual player now builds its own edge.Client (its own h2c connection
carrying both the Subscribe stream and the Execute calls), instead of every
player multiplexing over a single shared http2.Transport. The R2 trip report
traced the ~14% transport_error on game.state at 500 players to that single
shared transport; per-player connections mirror real clients and isolate the
artifact. The assembly burst and the gateway-hammer each get their own client.

playTurn now reports when a game has finished so playerLoop drops it from the
rotation (slices.DeleteFunc); once no active game remains the player idles while
still holding its stream. This stops secondary ops from hammering game_finished
on already-ended games (the other R2 harness finding).
R7: contour docker_stats observability + container limits/GOMAXPROCS
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 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
c16f27475f
Observability: replace cAdvisor (which resolves only the root cgroup on the
contour host — separate-XFS /var/lib/docker) with the otelcol docker_stats
receiver, which reads per-container CPU/memory/network straight from the Docker
API and works the same in prod. The collector joins the host docker group
(DOCKER_GID, default 989) and mounts the socket read-only; its metrics flow out
through the existing prometheus exporter, so the cAdvisor scrape job and the
privileged cAdvisor service are removed. The Resources dashboard panels are
retargeted to the docker_stats metric names (container_name label;
container.cpu.utilization/100 == cores).

Container limits: apply deploy.resources.limits (honoured by Compose v2) across
the contour and pin GOMAXPROCS to the CPU limit on the Go services so the runtime
matches the cgroup quota. Starting values are generous over the R2 peak (~1 core /
<=100 MiB per app service) to avoid skewing or OOM-killing the measurement run;
they are tightened to the agreed prod sizing after the final stress run (R7
Round 2). The privileged VPN sidecar is left unconstrained.
R7: pin docker_stats api_version to 1.44
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
8eee018728
The receiver defaults to Docker API 1.25, but the contour daemon's minimum is
1.40 (it speaks up to 1.54), so otelcol crash-looped on start with "client
version 1.25 is too old". Pinning api_version to 1.44 (accepted by both the
receiver's bundled client and the daemon) starts the receiver cleanly —
verified by running the image against the host socket ("Everything is ready",
no start error).
R7: apply the agreed tuning from the final stress run
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m23s
f23da88028
Round-2 tuning, decided from the 500-player resource profile:
- gateway: 2 -> 3 cores + GOMAXPROCS=3. It holds one h2c connection per player, so
  at 500 players it burst into the 2-core cap (~2.49% transport_error on game.state);
  3 cores absorbs the bursts. The per-connection cost is the realistic prod load.
- tempo: memory 1G -> 2G. It reached the 1 GiB cap during the run (OOM risk).
- backend Postgres pool: MAX_OPEN_CONNS 25 -> 40. The pool sat at its 25-conn cap
  (28 backends) at peak; headroom trims the p99 tail. Postgres (2c/512M) handles it.
- docker log volume: a json-file rotation default (10m x 3 = 30 MiB/container) applied
  contour-wide via a YAML anchor; the backend logs ~14 MiB / 30 min at info under load
  and was previously unbounded. Log level stays info.

backend/postgres stay at 2 cores / 512 MiB (peak ~0.85 / ~1.4 cores — headroom is cheap
on the shared host). A validation re-run confirms the gateway fix before merge.
R7: trip report + docs/tracker bake-back; mark R7 done
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
2a48df9b83
- loadtest/REPORT-R7.md: the final stress-run report — method, the 500-player resource
  profile, the agreed tuning, the validation (transport_error 2.49% -> 0.72% at 3 gateway
  cores; the burst run showing connection-bound behavior), and the prod-sizing
  recommendation for Stage 18.
- loadtest/README.md: per-player transports, --cpus capping, docker_stats (was cAdvisor),
  the absolute BACKEND_DICT_DIR for ./loadtest/... , and report links.
- docs/TESTING.md + docs/ARCHITECTURE.md: observability now uses the otelcol docker_stats
  receiver (cAdvisor removed); links to both trip reports.
- CLAUDE.md: repo-layout line reflects docker_stats + per-service limits.
- PRERELEASE.md: R7 marked done in the tracker + heading; a Refinements entry recording
  the decisions, findings, applied tuning and validation.

This is the final pre-release hardening phase; Stage 18 (prod cutover) is next.
R7: add a VPS/VDS sizing table (min/avg/max) to the trip report
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 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
225188e4b5
A practical single-host ordering guide — CPU cores, RAM, disk at three tiers —
grounded in the R7 profile (~5.5 cores / ~2.5 GiB peak at 500 players) and the
measured on-disk footprint (images ~2.4 GB; Tempo 3.1 GB at 72 h; the game DB
23 MiB and growing). Notes which knobs move disk (Tempo/Prometheus retention,
Postgres growth) and that the gateway scales horizontally past one host.
Merge pull request 'R7: final stress run + tuning' (#38) from feature/r7-final-stress-tuning into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 37s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
f8b6b7f2e3
UI: tab-bar navigation — drop the hamburger
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 39s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
fc1261e078
Replace Menu.svelte (hamburger) everywhere with tab-bar navigation:
- Settings hub (SettingsHub) from the lobby ⚙️ tab: Settings/Profile/
  Friends/About as in-place tabs, back → lobby; the lobby ⚙️ badge counts
  incoming friend requests (invitations keep their own lobby section).
- Comms hub (CommsHub) from the move-history 💬: Chat/Dictionary tabs,
  back → game; Dictionary only while the game is active.
- Game menu items relocate into the open history: 🏁 leave / 📤 export in
  the header, 🤝 add-friend per opponent card, 💬 comms; unread chat is
  badged on the score bar + the 💬.
- TapConfirm (tap → fading  → tap) replaces the Skip/Hint press-and-hold
  popovers and drives the add-friend confirm.
- Fix the move-history "jump": the slid board is inert and the stage can't
  scroll, so a swipe up genuinely closes the history.

Remove Menu.svelte + HoldConfirm.svelte. Docs: UI_DESIGN, FUNCTIONAL(+ru),
PRERELEASE. UI check/unit/build/bundle/e2e (Chromium+WebKit) all green.
UI: taller tg-fullscreen header + labelled hub tabs
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 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
ad91bc728b
- tg-fullscreen: +20px header height — without the (removed) hamburger the
  title bar lost its bulk and sat flush on Telegram's native nav band.
- Settings/Comms hub tabs gain text labels under the icons (Settings /
  Profile / Friends / Info and Chat / Dictionary); the icon is aria-hidden
  so the label names the button. New i18n keys about.tab, game.dictionary.
UI: fix tg-fullscreen header height via padding-top
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 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
1b3d7dc256
min-height was the wrong lever: in Telegram the title is the bar's only
child (the back chevron is hidden), so the bar is sized by padding+content
and min-height (the nav-band height) never binds — the earlier bump did
nothing. Drop the title clear of the native nav band with padding-top
instead (notch + a 20px gap, was +6), and revert the min-height change.
UI: tg-fullscreen header gap +10px (was +20) + font-scale regression test
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 39s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
29f655aacd
The +20px gap was too far; use +10px (padding-top safe-top+16). The gap is
fixed px, so the clearance from Telegram's native nav stays constant when
the user scales the font up — the title grows downward and the bar with it,
no overflow. Locked by a new e2e test that asserts the title top is
unchanged across font sizes and never overflows the bar.
Merge pull request 'UI: tab-bar navigation — drop the hamburger' (#39) from feature/ui-tabbar-nav into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 40s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
7b85f4bd68
UI: widen the edge-swipe-back activation band
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 40s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
fbd67c085c
Replace the very-narrow 24px edge-swipe trigger with a left band of ~20% of the
viewport width (EDGE_FRACTION) so the back-swipe is easy to reach, keeping the
simple instant-navigate behaviour (the route slide plays the animation). A
hit-test keeps the wider band clear of the rack, a draggable pending tile, a
zoomed-in board and text inputs, so it never hijacks those drags.

Test: e2e (Chromium+WebKit) — the band swipe returns to the lobby; a swipe
starting on the rack does not navigate.
Merge pull request 'UI: widen the edge-swipe-back activation band' (#41) from feature/ui-wider-edge-swipe 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 39s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
5c8b8bf658
UI: fix last-move highlight, localize move history, clamp zoom overscroll
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 41s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
ac29dca865
- Highlight tracks the last move overall (not the last word): a trailing
  pass/exchange now highlights nothing, so the board no longer lights up the
  opponent's old word after our own empty move.
- Make the highlight event-driven: refreshed only on a real game event
  (open/refresh, opponent move, our own committed move) and dismissed the moment
  composing starts, so recalling a just-placed tile never re-triggers it.
- Localize non-play move-history labels via new move.* catalog keys
  (pass/exchange/resign/timeout); the label printed the raw English action.
- Clamp the zoomed board's pan at its edge (overscroll-behavior: none), removing
  the native rubber-band past the content.

Tests: lastMoveCells unit coverage (trailing pass/exchange -> empty), i18n RU
label assertions, an e2e overscroll-contract check on the zoomed viewport.
UI: render non-play history moves as a dim lowercase tag
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 40s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
b14cc38919
Per owner feedback: pass/exchange/resign/timeout rows in the move history now read
as a dim, parenthesised, lowercase tag — e.g. «(обмен)» — so they stand apart from a
scored word. The move.* catalog values are lowercased (resign RU → «сдаюсь»); the
parentheses and the muted colour (var(--text-muted)) are applied in the view via a
.ha.sys modifier.
Merge pull request 'UI: fix last-move highlight, localize move history, clamp zoom overscroll' (#42) from feature/ui-recent-highlight-fixes 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 41s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
4c65923544
UI: render move history as a per-seat column grid + swipe-down to 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 42s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
e68fe61e39
Replace the flat chronological move list with a ruled matrix aligned under the
score plaque: one column per seat, each seat's moves filling its column top to
bottom. A cell is the move's word(s) and its score, "WORD (12)", centred; the
player names and the running total are dropped (the plaque heads the column and
shows the live total). Non-play moves keep their dim parenthesised tag; the
awaited opponent's next cell shows a dim "thinking..." (never the viewer's own
turn). Thin 1px rules between columns and rows match the panel's separator.

Re-introduce a swipe-down-on-the-board gesture to open the history, gated to the
zoom-out board scrolled to its top so it never fights the zoomed board's pan or
the stage's own vertical scroll (the conflict that retired this gesture before).

Grid layout extracted to lib/history.ts (unit-tested); add game.thinking to the
EN/RU catalogs; e2e covers the gesture and the grid on Chromium and WebKit.
UI: pin the SPA document so iOS/WKWebView cannot rubber-band the page
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 43s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
6268b9d2a2
On iOS (notably the Telegram Mini App) the document elastic-overscrolls on a
vertical drag even with overscroll-behavior:none — the whole page stretches and
bounces, and it fought the board's swipe-to-open-history. Telegram's own
swipe-to-minimise is already disabled at launch; this removes the remaining
WebKit document bounce by pinning the document (position:fixed + overflow:hidden)
for the game SPA only. Every screen already fits the visual viewport (--vvh) and
scrolls its own inner areas, so the document never needed to scroll.

Scoped to the app via an `app-shell` class set in main.ts; the standalone
landing page (landing.ts) keeps its normal scrolling document. e2e locks the
contract on both entries.
UI: gesture & history polish — pinch/swipe fix, wider back-swipe, nudge align, history overscroll
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 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m10s
a41c35d5f9
- Stop a two-finger pinch-out from also opening the history: the board wrapper arms its
  open/close pull only while a single pointer is down (a 2nd finger is a pinch, owned by Board).
- Widen the edge-swipe-back activation band to the left half of the viewport (was 20%).
- Align a chat nudge by sender like a bubble — your own to the right, the opponent's to the
  left (only the alignment changes).
- Kill the iOS rubber-band inside the history drawer (overscroll-behavior: none).

e2e: a two-finger pinch does not open the history; a back-swipe from the left half navigates back.
Merge pull request 'UI: move history as a per-seat column grid + swipe-down to open' (#43) from feature/ui-history-grid into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 45s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
feee3d6511
Backend infers play direction; UI previews words and gates submit on legality
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
92f48a3b12
A single tile that only extended a word perpendicular to the client-declared
direction was rejected: the UI always sent dir=H for one-tile plays (the
dirOverride/Controls toggle was orphaned in the Stage 7 game rework), so placing
"А" above "БАК" to form "АБАК" failed the solver's main-word-length check even
though the word is in the dictionary.

Make the backend infer a play's orientation from the placed tiles and the board
(internal/engine.resolveDirection): two or more tiles by the line they share, a
lone tile by the axis it abuts (longer word wins, horizontal on a tie). Direction
becomes an output, not an input: drop dir from the SubmitPlay/Eval wire requests
and add it to EvalResult. Journal replay keeps trusting the stored "H"/"V"
(SubmitPlayDir) so a rebuilt game matches the one committed.

UI: stop computing/sending direction; the preview now shows the words a move
forms with its total score (game.previewWords); the make-move control is disabled
until the play is confirmed legal; the "your turn" label hides while tiles are
pending. Delete the orphaned Controls.svelte.

Regenerate the FlatBuffers bindings (Go + TS) and update the gateway transcode
and the loadtest edge client to the new contract. Bake the decision into
ARCHITECTURE.md (§5/§9.1), FUNCTIONAL.md (+ _ru) and the backend README.
UI: remove the lobby game-row tap highlight
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
883212f9d1
A tap/click on a lobby game row flashed a highlight on both tappable areas (the
open body and the right chevron/kebab), and the .open:active background lingered
while the finger was held — pointless feedback that only spoiled the look. Drop
the held :active background and set -webkit-tap-highlight-color: transparent on
the row's buttons.
Merge pull request 'Backend infers play direction; UI previews words and gates submit on legality' (#44) from feature/auto-play-direction into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 44s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
5648f4a0bb
UI: drop tab-bar tap highlight; don't slide the first screen on launch
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 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
9277a70565
Tab bar: tapping a bottom-tab icon flashed a background — the icon square's
:active press tint plus the default WebKit tap flash, the same pair removed from
the lobby rows. Drop the press tint and set -webkit-tap-highlight-color:
transparent on .tab. The selected-tab highlight (Settings / Comms hubs) stays.

Startup slide: the route pane's in:slideX is local to its {#key} block, so it
plays on that block's own first mount when app.ready flips — the lobby slid in on
launch as if navigated into from another screen. Gate the slide duration to 0 for
the first pane shown after boot (a `started` flag set right after it mounts), so
launch is static while every later route change animates as before.
UI: fix the lobby slide on Telegram cold launch (correct the cause)
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 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
c32a15730a
The first attempt (the App.svelte `started` gate) targeted the first pane mount,
but the slide is a second render. On a Telegram cold launch the URL fragment is
Telegram's #tgWebAppData=... launch params, which the router parsed as notfound;
bootstrap's navigate('/') then corrected it to the lobby asynchronously, re-keying
the route pane (notfound -> lobby) and sliding the lobby in as if returning from a
screen. A reload was static because the hash was already #/.

Treat a Telegram launch fragment as the lobby root in the router, so the route is
correct from the first pane (no re-key, no slide). Extract the pure hash->Route
parsing into routeparse.ts so it unit-tests without a DOM, and revert the gate
(the first pane never slid — local transitions skip the initial mount, as clean
browser launches showed).

Tests: routeparse unit tests (incl. the tgWebApp fragment); an e2e that launches
with the fragment in the URL and asserts the lobby plus the normalised #/ hash.
UI: soften the board checkerboard's dark cells
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 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
390b4c756f
The gapless-board dark cells mixed 12% black into --cell-bg, which read too
contrasty and competed visually with the bonus cells. Halve the tint to 6% (both
themes, keyed off --cell-bg as before) for a gentler checkerboard.
Merge pull request 'UI: drop tab-bar tap highlight; don't slide the first screen on launch' (#45) from feature/ui-tap-startup-polish into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 44s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m7s
d4a1616d03
feat: "multiple words per turn" rule for Russian games
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 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
74455c7b12
Add a per-game rule chosen on New Game for Russian variants (default off = the
single-word rule; on = standard Scrabble). Off, only the main word along the play
direction is validated and scored; perpendicular cross-words are ignored,
including in robot move generation. The rule rides every create and enqueue
request and joins the matchmaking key, so games and auto-match stay one uniform
path; "Russian-only" is a UI affordance (English always sends standard and shows
no toggle).

- Engine: consume scrabble-solver v1.1.0's PlayOptions{IgnoreCrossWords}, threaded
  through engine.Options.MultipleWordsPerTurn -> playOpts() into validate, score
  and generate.
- Backend: thread the flag through game CreateParams/Game + store (games column),
  lobby InvitationSettings + invitation row, and the matchmaker queue key (variant
  + rule); persisted, so a rebuilt-from-journal game keeps it. Baseline migration
  gains multiple_words_per_turn (DB not versioned); jet regenerated.
- Edge: multiple_words_per_turn added to the EnqueueRequest / CreateInvitationRequest
  FlatBuffers tables (Go + TS regenerated) and threaded through the gateway.
- UI: a "Multiple words per turn" toggle on New Game, shown for Russian variants
  only (auto-match and friend invite), default off; English silently sends standard.
- Tests: backend engine/matchmaker; UI unit (gating) + Playwright e2e (solver
  corner-case + GCG fixtures ship in v1.1.0). Docs + PRERELEASE tracker updated.
Merge pull request 'Multiple words per turn rule for Russian games' (#46) from feature/single-word-rule into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 46s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
b56a45f0e0
feat(ui): single-word rule indicators + auto-match select redesign
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 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
0b57400c6f
Surface the per-game "single word" rule to the client and refine the
random-opponent New Game screen.

- Wire: thread multiple_words_per_turn into the GameView and Invitation
  FlatBuffers tables (Go + TS regenerated), through pkg/wire builders and both
  the backend push-event and gateway REST paths.
- In-game indicators (single-word games only): a small 1 in the status bar's
  score-preview slot (yields to the live preview) and a centred "One word per
  turn" label in the history-drawer header. Standard games show neither.
- Invitation card gains a "One word per turn" line for single-word invitations.
- Auto-match redesign: variant plaques are mutually-exclusive selects (highlight
  on tap, no longer enqueue); a lone offered variant is pre-selected; a bottom
  "Start game" button (disabled until a variant is chosen) confirms. The rule
  toggle appears once a Russian variant is selected.
- Tests: e2e for the new auto flow and the in-game indicator (mock g3 is a
  single-word game); mock/data + fixtures carry the new field. Docs: UI_DESIGN.
test(ui): cover the invitation-card single-word indicator
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
f73f76220d
Make the mock invitation a Russian single-word game so the card's
"One word per turn" line renders, and assert it in the lobby e2e.
Merge pull request 'Single-word rule indicators + auto-match select redesign' (#47) from feature/rule-indicators-newgame into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 45s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m9s
f1e77b5826
fix(engine): EvaluatePlay honors the single-word rule
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
5fa51d04d9
The move preview (EvaluatePlay) validated under standard rules — it called
ValidatePlay without the game's play options — so under the single-word
rule it rejected a play whose only flaw was incidental invalid perpendicular
cross-words, even though SubmitPlay accepts it. The UI gates the submit
button on the preview, so such a play (e.g. КРАН bridging an existing Р on
the test contour) could not be made.

Pass g.playOpts() via ValidatePlayOpts, mirroring Play, so the preview's
legality and score match submission. Robots are unaffected — they search
via GenerateMovesOpts and submit via Play, both already opts-aware — and a
regression test asserts that too.
Merge pull request 'fix(engine): EvaluatePlay honors the single-word rule' (#48) from feature/single-word-evaluate-fix into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
641ac88b2d
fix(admin): keep the filter query intact in console pager and export links
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
eeb078d528
The paginated users and messages lists interpolate the pre-encoded filter
query (url.Values.Encode) after the "?" in their pager and CSV-export links.
There html/template treats it as a single query value and percent-encodes the
structural "=" and "&" again, so "kind=robots" rendered as "kind%3drobots" and
the multi-pair message filter collapsed -- every page step dropped the active
filter.

Type FilterQuery as template.URL so the already-escaped fragment is emitted
verbatim (the attribute-level "&" -> "&amp;" stays correct, the browser decodes
it back). It is safe because url.Values.Encode output is strictly
percent-encoded. games/complaints use status={{.Status}} -- a single value in
proper query-value context -- and were never affected.
Merge pull request 'fix(admin): keep the filter query intact in console pager and export links' (#49) from feature/admin-pager-filter-encoding into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 16s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
f67a357e62
feat(ui): tint last-word letters for the recent highlight; lift dark bonus contrast
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 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
359758a01a
Dark theme: the 2x/3x bonus-square pairs were too close to tell apart. Soften the 2x squares (sky blue #4a779b, rose #a8636b) and deepen the 3x squares (#2c527a, #9c3f34) so each pair reads as two distinct steps. Light theme is unchanged.

Last-word highlight (both themes): stop tinting the tile background — the tile keeps its normal fill, and instead the placed letters (not the point values) are drawn in the recent-move colour. The opponent-just-moved flash now pulses the letter between its normal colour and the recent colour, with no background animation and no white peak.

Reconcile the explicit [data-theme=dark] --tile-recent with the OS-dark value so the highlight reads the same however dark is selected, and darken --tile-recent a step in every theme. Update docs/UI_DESIGN.md.
feat(ui): use a warm burgundy for the last-word highlight in both themes
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 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
107add13b6
The gold/brown recent colour shared the tile's warm hue, so it could not separate from both the near-black glyph (when dark) and the tan tile (when light) at once. Switch --tile-recent to a burgundy #8c4a3c whose red hue stays distinct from both, in light and dark, and unify the value across all three theme blocks.
feat(ui): lighten the light-theme last-word highlight to a brighter burgundy
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 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
cb75623677
At #8c4a3c the highlight blended into the lighter board in the light theme. Give the light theme a lighter burgundy #9c5849 while the dark theme keeps #8c4a3c — the two are tuned per theme because perceived contrast depends on the surrounding board tone.
Merge pull request 'feat(ui): last-word letter highlight + dark bonus-square contrast' (#50) from feature/board-recent-highlight-and-dark-bonus 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 44s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
10dc1f0d48
feat(lobby): enter the game immediately and wait for the opponent inside it
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
c305363ccd
Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place.

Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent".

Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
fix(lobby): land in the opened game on enqueue + keep open games active in the lobby
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 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
a3cb917ec7
Review fixes for open-game auto-match: decodeMatchResult dropped the game when matched=false (an open game awaiting an opponent), so the client never navigated into it - decode the game whenever present. The lobby grouped open games (status != 'active') into 'finished'; treat 'open' as in progress in groupGames/isMyTurn and resultBadge. The under-board status bar now reads "Opponent's turn" while the empty opponent seat is to move (instead of the searching placeholder). The New Game rule toggle is shown from the start when a Russian variant is available, so selecting a variant no longer shifts the layout.

Regression tests: codec (game decoded with matched=false), lobbysort + result (open is in progress), and the new-game e2e updated. UI-only; no backend or schema change.
fix(gateway): put the opened game on the wire even when the enqueue is not yet matched
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
efaf633691
The real cause of 'Start game does not enter the game': encodeMatch gated the MatchResult game on matched (matched := m.Matched && m.Game != nil), so an open game awaiting an opponent (matched=false, game set) lost its game on the wire and the client had nothing to navigate into. Encode the game whenever m.Game is present; the backend's matched flag is authoritative. Regression test added (matched=false + game reaches the wire). The earlier codec fix guarded the same drop on the decode side.
feat(admin): add an 'open' filter to the console games list
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
94534ad0f2
The games-list status filter offered only active/finished; add 'open' (auto-match games awaiting an opponent) to the subnav and accept it in normalizeGameStatus. Render test covers the new filter link.
Merge pull request 'feat(lobby): enter the game immediately and wait for the opponent inside it' (#51) from feature/quick-game-open-wait into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 45s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
1d41cf8222
feat(ui): merge Exchange/Pass into one action; drop dead Tournaments tab
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
a4e6727c70
Lobby: remove the inert 🏆 Tournaments tab (it only raised a 'coming soon' toast); the lobby is back to three tabs, matching docs/FUNCTIONAL.md.

Game: fold the separate 🥺 Skip (pass) tab into the 🔄 tab, now Exchange/Pass. Its dialog passes when no tile is selected (button 'Pass without exchanging') and exchanges when tiles are ('Exchange N'). The tab is no longer gated on an empty bag (pass must stay reachable in the endgame); inside the dialog tile selection is disabled while the bag is below a full rack (bagLen >= RACK_SIZE).

The merge is UI-only. A pass is NOT an exchange of zero tiles: the rules allow an exchange only with a full rack left in the bag and forbid a zero-tile swap, while a pass is always legal; GCG (Poslfit) writes a pass as a bare '-' and an exchange as '-TILES'. Pass and exchange stay distinct end-to-end (wire, engine, history/GCG); the dialog dispatches the existing gateway.pass / gateway.exchange. No backend/wire/history/GCG change.

Docs: docs/UI_DESIGN.md, docs/FUNCTIONAL.md (+_ru), PRERELEASE.md. Tests: ui/e2e/game.spec.ts.
Merge pull request 'feat(ui): merge Exchange/Pass into one action; drop dead Tournaments tab' (#52) from feature/ui-merge-exchange-pass into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 46s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
5ff07da025
feat(admin): online dictionary update — upload archive, preview word diff, install & activate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
0f3671f42d
Replace the dictionary hot-reload with an online update flow in the GM console
(/_gm/dictionary): the operator uploads scrabble-dawg-vX.Y.Z.tar.gz, previews the
per-variant words added/removed against the active dictionary, and confirms to
install + activate it. Versions are immutable; in-progress games keep their pinned
version while new games use the new one.

- engine: DiffWords (enumerate both DAWGs, decode only the differences), OpenFinder,
  Registry.Finder, DictFiles; OpenWithVersions skips the .staging area.
- dictadmin: hardened release-archive validation + extraction (path-traversal,
  symlink, oversize, entry-count rejection) and staging -> install (atomic rename).
- game: active dictionary version persisted in the dictionary_state singleton
  (single source of truth, restored on boot), concurrency-safe accessor.
- storage: BACKEND_DICT_DIR is a named volume seeded from the image (nonroot-owned),
  so uploaded versions persist across redeploys; the build's DICT_VERSION labels the
  seed and equals the resident tag (BACKEND_DICT_VERSION).
- docs: ARCHITECTURE §5, FUNCTIONAL (+ru), backend README, TESTING, PRERELEASE.

Tests: engine + dictadmin unit; integration upload->preview->install->activate->
restart->pin->immutability->CSRF.
Merge pull request 'feat(admin): online dictionary update — upload archive, preview word diff, install & activate' (#53) from feature/dict-admin-online-upload into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 45s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
315bcf75ae
fix(ui): poll/refetch fallback for a missed opponent_joined in the open-game wait
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
16402e64c0
PR #51 moved the auto-match "wait for an opponent" from the lobby matchmaking
screen into the open game but did not carry over that screen's poll fallback.
The notify hub is best-effort and never replays, the live-stream resubscribe
sends no cursor, and the game screen refreshed only from push events — so an
opponent_joined dropped while the stream was down (e.g. a mobile suspend) left
the starter stuck on "searching for opponent" until they re-entered the game.
Unlike opponent_moved/game_over, opponent_joined has no follow-up event to
trigger the existing move-count gap refetch.

Recover it in Game.svelte: (A) refetch once on stream reconnect (covers the
common suspend/resume case and rescues a missed move/game_over too), and
(B) poll game.state every 2.5s while still waiting with the stream down
(mirrors the old matchmaking startPoll). Add a mock-mode __stream e2e seam and
two specs isolating each path, fix the now-stale streamAlive comment, and
document the fallback in ARCHITECTURE §10.
fix(ui): also resync an open game on a foreground regain without a stream drop
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 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
4409253dce
Closes the residual tail of the previous commit: when the live stream stays
alive across a brief suspend (Telegram/iOS can pause the socket without tearing
it down), an in-game event shed from a full hub buffer is never recovered by
the reconnect refetch (no reconnect) or the open-game poll (it only runs while
the stream is down). Mirror the lobby's focus re-poll: bump app.resync on a
foreground regain that did not drop the stream, and have Game.svelte refetch
the open game once per resync. Also rescues a missed move/game_over after a
suspend. Add a silent-join mock seam + an e2e isolating this path; extend the
ARCHITECTURE §10 fallback note.
Merge pull request 'fix(ui): poll/refetch fallback for a missed opponent_joined in the open-game wait' (#54) from feature/open-game-wait-poll-fallback 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 48s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
38fa104f7f
fix(ui): keep lobby/game caches fresh across screens (no stale-status flash)
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 48s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 58s
cf70e6b1fc
The per-screen in-memory caches (lobbycache, gamecache) were refreshed only by
the screen that owns them while it was mounted, so a state change that crossed
screens left the other screen's cache stale and it visibly redrew on the next
navigation:

- game -> lobby: the player's own move advanced the game cache but not the lobby
  snapshot, and an own move carries no self-directed push event, so returning to
  the lobby painted the pre-move status until the background refresh corrected it.
- lobby -> game (and from any other screen): an opponent's move / game-over
  refreshed the lobby (while mounted) but never the per-game cache, so opening
  that game flashed the pre-move board.

Make cache freshness cross-screen, owned by the single global stream handler
that runs for every live event regardless of the mounted screen:

- patchLobbyGame upserts the affected game's GameView into the lobby snapshot;
  the global handler calls it on opponent_moved / game_over / opponent_joined and
  on a match_found / game_started seed (so a game started elsewhere is present
  too). The game board still mirrors the player's own move and its own load() —
  the two updates no live event carries.
- advanceCached (a pure wrapper over the existing delta reducers) advances a
  not-currently-viewed game's cache from opponent_moved / game_over; the game in
  view is skipped so its mounted board stays the sole owner (no double apply).

End-state behaviour is unchanged (the background refresh always reconciled);
this removes the transient stale frame. Unit-tested patchLobbyGame and
advanceCached; docs/UI_DESIGN.md updated.
Merge pull request 'fix(ui): keep lobby/game caches fresh across screens (no stale-status flash)' (#55) from feature/ui-cross-screen-cache-freshness 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 48s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
5312b11f0e
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
56dbf86472
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.
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
f3914af793
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.
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.
refactor(ui): harden the board's recenter dependency and gate it on a real bump
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
553152e195
The recenter effect declared its dependency with a bare `void recenter`, which a
production minifier could drop as a side-effect-free statement (the e2e runs only
the unminified dev server, so it would not catch that). Read the nonce into a
used variable and gate the pan on it actually changing (`recentered`), so the
reactive dependency is robust to minification and the board pans only on an
explicit hint recenter — never on an incidental re-run such as a viewport resize.
test(ui): run e2e against the minified vite build, not the dev server
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
abe1038333
The e2e booted the unminified Vite dev server, so production-minification bugs
were invisible to it — exactly how a minifier dropping a bare `void recenter`
reactive read slipped through. Build the app in mock mode and serve the minified
artifact via `vite preview` instead, so the smoke exercises the same bundle the
contour ships. Build to dist-e2e/ (gitignored) so it never clobbers the dist/ the
bundle-size gate measures, and with `--base /` so the SPA-fallback also boots a
subpath like /telegram/ (the production relative base needs the gateway's path
mapping, absent under a plain preview). All 118 specs pass against the build on
both engines, including the hint-recenter spec — confirming the hardened
dependency survives minification.
Merge pull request 'feat(ui): cross-screen caches + invitation delta channel + hint recenter on a zoomed board' (#56) from feature/lobby-cache-any-screen-invitation-delta into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 46s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
c7e177f911
fix(engine): single-word rule connects along the play line
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
ff87a3bf62
Bumps the engine to scrabble-solver v1.1.1, where a single-word-per-turn play
must form its word along its own line through an existing tile: a multi-tile play
that touches the board only perpendicular to itself (the contour РЮМ/КЕД/ОР cases)
no longer connects. For a single tile that abuts the board on both axes the engine
now plays the higher-scoring legal orientation instead of the geometrically longer
one (playDirection), so a real word is never rejected in favour of a non-word.

Reworks the single-word solver/engine tests for the corrected rule (no longer a
superset of standard play) and updates ARCHITECTURE/FUNCTIONAL/PRERELEASE.
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
222eaf730f
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.
Merge pull request 'fix(engine): single-word rule connects along the play line' (#57) from feature/single-word-connectivity into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 47s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
4f2fc795ec
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
c9021fc070
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.
Merge pull request 'feat(ui): мгновенное открытие партии — предзагрузка идущих партий + кэш черновика без «прыжка»' (#58) from feature/instant-game-open 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 46s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
7716353f84
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
9ec72c8377
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.
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
02ef31c464
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).
Merge pull request 'feat(ui): горизонтальная раскладка игрового экрана — доска по высоте + левая панель' (#59) from feature/landscape-game-layout 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 45s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 57s
fc28e43e43
feat(chat): limit in-game chat to one message per turn
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
02681ae9e0
Enforce one chat message per turn on both ends. The backend rejects a
second message in the same turn (ErrChatAlreadySentThisTurn -> 409
chat_already_sent), keyed on the move-driven turn start (turn_started_at).
The UI derives "already wrote this turn" from the message list against
GameView.lastActivityUnix (no counter, survives reopening, resets on turn
change), hides the field behind a short caption once the limit is reached,
and now reloads the game state on turn/game-state events so the toggle and
the limit follow the live game. Enter is gated on !busy to avoid a
double-send in the in-flight window.

Backend: new game.TurnStartedAt; social GameReader gains it; PostMessage
enforces the limit reusing lastMessageAt. UI: new lib/chatlimit.ts pure
logic + unit tests; Chat/ChatScreen wiring; chat.sentThisTurn and
error.chat_already_sent i18n (en/ru); extended chat e2e. Docs: FUNCTIONAL
(+ru), ARCHITECTURE, UI_DESIGN.
fix(chat): no chat field or nudge on a finished game
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 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
5f53ec81b9
A finished game is read-only: gate the nudge behind canNudge (active game,
opponent's turn) so it no longer shows on a finished (or otherwise
non-active) game — where the backend rejects it anyway. The message field
is already hidden off your own turn. Extend the finished-game e2e to assert
neither Send nor Nudge is offered.
Merge pull request 'feat(chat): лимит внутриигрового чата — 1 сообщение за ход' (#60) from feature/chat-one-message-per-turn into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 45s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
9d85090075
feat(admin): manual account blocking (suspensions)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
d1ba666495
Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint.

Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
perf(admin): cache the suspension gate lookup
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 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
290874720f
The suspension gate runs CurrentSuspension on every authenticated request. Add a write-through in-memory cache on account.Store keyed by account id, invalidated on Suspend/LiftSuspension, with the cached entry re-evaluated against the wall clock so a temporary block lapses without an explicit invalidation. Single-instance, matching the deployment (one shared Store). Keeps the gate off the database on the hot path while a block still takes effect on the next request.
Merge pull request 'feat(admin): ручная блокировка пользователей в админке' (#61) from feature/admin-user-blocking into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 45s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
a80952a835
feat(ui): name the previous player in the your-turn toast
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 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
ac62d29ef7
The in-app "your turn" toast (shown when it becomes your turn in another
game) now reads "<Opponent>: Your turn!", naming the player who moved just
before this one — the previous seat in turn order, so it reads the same in
games with any number of players. Falls back to the bare "Your turn" label
when no name is present (an older peer, or a gap event without one).

YourTurnEvent already carries opponent_name end to end: the backend sets it
(game.displayName of the last mover) and the gateway forwards the payload
opaquely, so this is a client-only change — decode the field, thread it
through PushEvent, and pick the localized string.
Merge pull request 'feat(ui): тост «Ваш ход» называет предыдущего игрока' (#62) from feature/your-turn-toast-opponent-name 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 45s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
d3bedbb5b6
feat(admin): grant hints to a user's wallet from the console
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
192e4a2433
Add an "Add hints" form on the admin user card that additively tops up a
player's hint wallet (1-100 per grant). The grant is raise-only by
construction (an additive UPDATE never lowers the balance) and stays correct
under a concurrent in-game spend; a per-grant cap bounds a fat-finger, since
the console can never reduce a wallet.

The in-game hint policy is unchanged and already correct: a game offers the
per-seat allowance plus the wallet, spending the allowance first and the
wallet only after (covered by TestHintPolicy).
Merge pull request 'feat(admin): выдача подсказок в кошелёк пользователя из админки' (#63) from feature/admin-grant-hints into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
fc848157d6
feat(feedback): in-app user feedback with admin review and account roles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
419ea11b14
User-facing Feedback screen (Settings -> Info, registered accounts only): a
message (<=1024 runes) plus one optional attachment, an anti-spam gate (one
unreviewed message at a time), and the operator's inline reply with a
Settings/Info badge. Server-rendered admin console section (/_gm/feedback):
unread/read/archived queue with per-user search, detail with read/reply/
archive/delete/delete-all, safe attachment serving (nosniff, images inline via
<img>, others download-only). Introduces account_roles, the first per-account
role table; feedback_banned blocks only feedback submission, granted/revoked
from /users and the delete-with-block action.

- migration 00004_feedback (feedback_messages + account_roles) + jetgen
- backend internal/feedback (store+service), internal/account/roles.go
- wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest,
  is_guest via session resolve) -> guest_forbidden before any backend call
- reply push reuses NotificationEvent with a new admin_reply sub-kind
- UI: /feedback route + screen, attachment picker, badge, channel detection, i18n
- tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e
- docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs
fix(feedback): theme buttons, badge the Info button, simplify admin actions
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
5287794a72
- style the About feedback button and the form's Send/attach/remove buttons with
  the accent/border tokens used by the New Game CTA, so they follow light/dark
  theme (the previous .btn/.ghost classes were not defined globally); the attach
  button is a neutral 📎 icon button with an aria-label
- show a round badge on the About 'Feedback' button when a reply is waiting
- admin: one 'ban from feedback' checkbox shared by Delete and Delete-all (via
  button formaction); hide Mark read when already read and Archive when archived
- e2e: match the About button by substring (its name gains the badge)
fix(feedback): show the operator reply only on the player's latest message
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
1ae43080ec
A reply was bound to 'the latest message that has a reply', so after the player
read a reply and sent a new (unanswered) message, the old reply kept showing as
'Ответ на ваше последнее сообщение'. Bind it to the single most-recent message
instead: sending any new message immediately drops the previous reply (the new
message has no reply yet), well before the one-week window. Client clears the
reply optimistically on submit; the mock mirrors it; inttest covers the case.
feat(feedback): show the sender's interface language and connector bot in admin
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
2a4ce319d9
In the console feedback detail, show the sender's interface language (account
preferred_language) always, and — for a message that arrived through an external
connector (currently Telegram) — the bot they last used (en/ru, from the
account's service_language).
docs(feedback): note interface language + connector bot in the admin detail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m6s
49b67a0354
Store the sender's interface language (lang) and, for a message that arrived
through an external connector (Telegram), the bot language (channel_lang) on the
feedback row at submit time, so the operator console shows the state as it was
rather than the account's current settings (same snapshot discipline as a
suspension reason). Added additively in migration 00005. The console detail reads
these columns instead of loading the account live.
feat(feedback): render links in the operator reply (open in a new tab)
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
277954c47f
Linkify the operator reply on the feedback screen: http/https/ftp/mailto/tel URLs
become anchors (target=_blank rel=noopener), everything else stays escaped text.
A small whitelisted regex (no dependency) — the reply is operator-authored, so
explicit schemes suffice; dangerous schemes (javascript:/data:) are never linked,
and \b avoids matching a scheme inside a word (hotel:, email:).
Merge pull request 'feat(feedback): обратная связь от пользователей с разбором в админке и ролями' (#64) from feature/feedback into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 46s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
711fe6e594
On login the UI no longer overrides the interface language from the account's
preferred_language. The live interface follows the device — the explicit local
choice (saved, locked) or the system-language guess — so opening the mini-app via
the ru-bot on an English system keeps the interface English (it was forced to
Russian by the account seed). preferred_language is still written from Settings and
used for out-of-app push routing; it just no longer dictates the UI on launch.
fix(ui): a board pinch-zoom no longer triggers 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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
800a692766
The edge swipe-back armed on the first finger and fired on release even when a
second finger had joined (a pinch-zoom — the board is not .zoomed yet at the first
touch, so the hit-test could not skip it). Track the live pointer count in the
capture phase and cancel the swipe the moment a second pointer joins; the back
navigation fires only for a lone finger. Synthetic-PointerEvent e2e covers both.
Thread the Telegram bot's service language (en/ru) from the session mint response
through the gateway into the FlatBuffers Session, so the UI knows which bot the
player signed in through. handleTelegramAuth refreshes the account's service
language onto the response before minting (it was set after the fetched copy).
Empty for a non-Telegram login.
feat(ui): real friend-invite share with a per-bot link
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
03eb8044ff
The friend-code 'share' was an <a> that just opened the bot. Make it a real share:
Telegram's native share-to-chat picker inside the Mini App (openTelegramLink +
t.me/share/url), the system share sheet (navigator.share) on the web, else copy the
link. The shared deep link points at the same bot the player is in — it picks
VITE_TELEGRAM_LINK_EN/_RU by the session's service language, falling back to the
single VITE_TELEGRAM_LINK. Adds the per-bot build args across Dockerfile / compose /
ci.yaml / .env / docs; PLAN TODO-5 updated.
fix(ui): per-bot invite caption + friendly self-redeem note
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
01d2d1f368
- The share caption is now in the bot's language: 'Давай играть в Эрудит!' (ru bot),
  "Let's play Scrabble!" (en bot) — picked by the session service language, not the
  interface language.
- Redeeming your own invite (deep link or manual) no longer shows the scary
  'can't do that to yourself' error; it shows a friendly neutral note instead.
feat(ui): refine Telegram invite & close UX
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
853730823b
- Shared invite links open the Mini App fullscreen (mode=fullscreen), so a
  shared link matches the bot's own fullscreen launch.
- A used or expired invite deep-link now lands the visitor in the lobby with a
  gentle notice pointing at the right bot (@<username>, by service language),
  instead of a red "code invalid/expired" error on the Friends screen.
- The in-game close confirmation is armed only on Telegram mobile clients; on
  desktop (tdesktop/macOS/web) it is skipped, where the "changes may not be
  saved" dialog is just noise (drafts auto-save).
feat(ui): force Mini App fullscreen on mobile, not via the share link
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
00129414e5
Replace the shared-link &mode=fullscreen (which would also force fullscreen on
desktop) with an imperative requestFullscreen() on launch, gated to mobile
clients (ios/android/android_x) — mirroring how Telegram's own Mini Apps go
immersive on phones while desktop keeps the bot's full-size window. It triggers
the existing fullscreenChanged -> safe-area resync; a no-op on clients predating
Bot API 8.0.
docs: bake Telegram invite & launch refinements into ARCHITECTURE/UI_DESIGN
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
8073971fca
- UI_DESIGN: mobile immersive fullscreen on launch (desktop keeps full-size); the
  close confirmation is now mobile-only; friend-code share-via-Telegram deep-link
  (per-bot, by service language), the friendly self-redeem note, and the
  outdated-link lobby notice.
- ARCHITECTURE: the service language rides the Session wire so the client builds
  the per-bot invite link; the friend code is shared as a startapp deep-link with
  graceful spent/expired handling.
Merge pull request 'fix(ui): язык интерфейса по системе (не по боту) + pinch-zoom не триггерит back' (#65) from fix/locale-and-pinch-swipe 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 48s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m7s
4b454db219
Merge pull request 'feat(ui): настоящий share friend-code + ссылка по текущему боту (service_language)' (#66) from feat/friend-invite-share into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Successful in 47s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
4d6df4bd8b
feat(ui): external dictionary lookup link on the word-check tool
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 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
8e0d7f9e17
When a checked word is found, show a 'look it up' text link beside the
complaint button that opens an external reference dictionary in a new tab:
gramota.ru for the Russian variants, scrabblewordfinder.org for English
(word lower-cased and percent-encoded). The link hides for a word that is
not found. Inside Telegram it routes through the Mini App SDK's openLink, so
Telegram opens it directly instead of the WebView's 'open this link?'
confirmation; in a browser the anchor's own target=_blank handles it.

Relabel the complaint button to 'Возражаю' (ru); English stays 'Disagree'.
feat(ui): route all in-app external links through Telegram openLink
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
bd0482c376
Extend the openLink routing from the dictionary lookup to every external
link shown inside the Mini App, so none triggers the WebView's 'open this
link?' confirmation. A shared onExternalLinkClick handler resolves the anchor
via closest() (so it also works delegated on {@html} content), backed by a
pure routeExternalLinkInTelegram decision: only inside Telegram, only an
external http(s) target=_blank link, excluding same-origin/in-app and t.me
links (t.me keeps openTelegramLink). Applied to the word-check lookup, the
About rules link, the Feedback operator-reply links, and the feature-gated
announcement banner.

Outside Telegram every anchor keeps its native target=_blank.
Merge pull request 'feat(ui): external dictionary lookup link on the word-check tool' (#67) from feat/dict-lookup-link 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 47s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
91d5c341ef
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
aa765a0c06
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.
feat: AI-game refinements (GCG, your_turn, admin, metrics)
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
d2a9441287
Follow-ups on the honest-AI game, same PR:
- GCG export labels the robot seat "AI" instead of its pool name (ExportGCG
  overrides via accounts.IsRobot); the in-app 🤖 is unchanged.
- vs_ai games emit no your_turn (the robot replies instantly, so it would be
  redundant); opponent_moved still advances the UI.
- Admin console shows the AI flag: a 🤖 column in /games and an "AI game" line
  on the game card (GameRow/GameDetailView gain VsAI).
- games_started_total / games_abandoned_total gain a vs_ai attribute; the
  Grafana Game-domain dashboard splits started/abandoned into human and AI
  panels.

Tests: metrics unit (vs_ai split); integration (no your_turn, GCG "AI").
Merge pull request 'feat: honest AI opponent in quick game' (#68) from feature/ai-opponent into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Successful in 48s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
9e6899bb7d
feat(robot): occasional off-strategy deviation, strict in the endgame
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
3bceafbc12
The robot followed its per-game playToWin/lose intent on every move, which made
the outcome too predictable. It now flips that intent for a single move on ~20%
of opening/midgame turns (a winning robot eases off, a losing one surges ahead),
so the chosen strategy may not pan out — which favours the human. The chance
tapers linearly to 0 over the last 14 tiles in the bag and is 0 once the bag is
empty, so the endgame follows the chosen strategy strictly.

The decision is deterministic from the seed (mix(seed,"deviate",moveCount)) and
applies to both robot paths via the shared selectMove; the per-game play-to-win
intent the admin card shows is unchanged. Adds deviateProb/deviates helpers and
unit tests (taper bounds + monotonicity, never-in-endgame, determinism, ~20%
distribution); bakes the behaviour into ARCHITECTURE §7, FUNCTIONAL (+_ru),
backend/README, PRERELEASE and PLAN Stage 5.
Merge pull request 'feat(robot): occasional off-strategy deviation, strict in the endgame' (#69) from feature/ai-strategy-deviation into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
f59c8dcd43
feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
0946a3f66c
Turn the gated-off mock banner into a real advertising subsystem (backend +
admin half; the UI rotation lands in PR2).

- internal/ads: campaigns (percent weight + validity window; a perpetual,
  undeletable default that fills the remainder up to 100%), 1..N bilingual
  messages (en+ru), global display timings; ActiveSet computes the
  window-filtered, default-remainder, GCD-reduced, language-resolved rotation
  feed. Smooth-weighted-round-robin math is unit-tested.
- migration 00006 (+ jetgen): ad_campaigns / ad_messages / ad_settings, seeded
  default campaign + house message + default timings.
- eligibility = !paid_account && hint_balance==0 && !no_banner role (new role;
  guests qualify). The resolved feed rides the profile.get response (no new RPC,
  works for guests, nothing distinct to filter); language by service_language.
- live update: a notify `banner` sub-kind (re-poll signal) published when an
  operator grants hints or grants/revokes no_banner, so the client shows/hides
  in place.
- admin console /_gm/banners (+ /_gm/banner-settings): campaign + message CRUD
  with reorder, default protection, clamped timings.
- wire: fbs BannerInfo/BannerCampaign on Profile; gateway transcode forwards it.
- docs: ARCHITECTURE §10, FUNCTIONAL (+ _ru), backend README, PRERELEASE tracker
  (incl. the deferred app.load aggregator note).
fix(ads): verify a message belongs to its campaign before edit/delete
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
00a33c227b
DeleteMessage/EditMessage acted on the message id without checking it belonged
to the campaign id in the URL. A mismatched pair could edit an unrelated
campaign's message or — worse — delete the default campaign's last message via
another campaign's URL, bypassing the "default keeps >=1 message" guard. Both
now load the campaign and return ErrNotFound unless it owns the message
(MoveMessage already did). Operator-only, but a real business-logic bypass.
Regression test: TestBannerMessageOwnership.
Merge pull request 'feat(ads): server-driven ad-banner backend, wire & admin console (PR1)' (#70) from feature/ad-network-backend into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 48s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
a5f066224c
feat(ads): client banner rotation, fade UX & live toggle (PR2)
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 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
cb4a31a860
Consume the server-driven banner block (PR1) in the UI and retire the gate.

- banner.ts: createScheduler — a smooth weighted round-robin over campaigns (each
  appears its weight share per cycle, evenly interleaved) with round-robin over a
  campaign's messages; the rotator drives fade-in -> hold/scroll -> fade-out -> gap
  -> fade-in, a lone message stays put, reduce-motion swaps instantly without scroll.
- model.ts/codec.ts: Profile.banner (Banner/BannerCampaign/BannerTimings) decoded
  from the fbs block.
- Screen.svelte: drop the compile-time SHOW_AD_BANNER; render AdBanner from
  app.profile.banner (campaigns + timings + reduceMotion).
- AdBanner.svelte: opacity-driven fades + scroll host; the rotator is recreated when
  the campaigns/timings change (a `banner` notify re-fetch swaps them in place).
- app.svelte.ts: on the `notify` `banner` sub-kind, refreshProfile() so the banner
  shows/hides in place.
- tests: scheduler distribution + round-robin, the fade sequence, single-message,
  reduce-motion, stop(); codec banner decode. UI_DESIGN.md + trackers updated.
feat(ads): add a link-formatting help aside to the banner message editor
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 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
53c6e34c13
On the campaign detail page, beside the "Add message" form, a static aside
explains the message markdown: plain text is escaped, `[text](url)` becomes a
link, and only http(s)/root-relative targets are linkified (others show as
plain text). New .form-help (flex row) + .help (muted aside) console styles;
wraps below the form on a narrow viewport.
feat(ads): banner message editor — top help aside + multi-line fields
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
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 1m10s
9e72e2c799
Admin campaign editor polish:
- move the link-formatting help aside to the top of the Messages section,
  beside the intro note (~40% width), so it no longer drops below and stretches
  the form fields.
- make the English/Russian message fields 3-row, vertically resizable textareas
  (was single-line inputs) so long text wraps instead of scrolling off to the
  right. The strip is white-space:nowrap, so a stray newline collapses to a space
  on display.
feat(ads): banner under the header, continuous across navigation, robust fades
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 1m1s
3b20abe0bd
Banner UX refinements (owner feedback):
- Position: render the banner inside Header (under the title) instead of in
  Screen, so it sits in the same place on every screen. In the game the grown
  nav's spare height now falls below the banner (banner under title, board
  pinned to the bottom) — it no longer jumps to the game area.
- Continuity: move the rotation into a persistent module engine
  (lib/bannerEngine) — the scheduler + timer live outside the components, so a
  navigation (which remounts the view) continues the cycle instead of restarting
  it. Each AdBanner only attaches as the DOM host and resyncs to the live message.
- Fades: a long, scrolling message now fades at both ends. The fade is a
  {#if} transition:fade layer, independent of the scroll (the inner track's
  transform), so the two no longer interfere.

Verified live (mock + Playwright): same position in lobby and game; the cycle
continues across lobby↔game; opacity sampling shows fade-out + fade-in for the
long message. Engine continuity unit-tested.
fix(ads): banner truly continuous across navigation + re-measure on resize
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 8s
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 1m6s
5fb0daa746
The previous engine kept the scheduler running but the view re-`show()`-ed the
current message on every (re)mount, replaying the fade on each navigation — which
looked like the cycle restarting (especially for a single message). Now:

- A mounted AdBanner reads the engine's live message (bannerCurrent) and renders
  it immediately, with no fade; attach no longer re-shows. Only a real advance
  fades. Verified: opacity stays 1.0 across a navigation, message preserved.
- The fade is manual opacity on a .fadewrap layer (not transition:fade), kept
  independent of the scroll (inner track transform), so a long message still
  fades at both ends and a {#key} remount cannot force an intro fade.
- A viewport size change (portrait↔landscape) re-measures the current message
  (remeasureBanner on resize/orientationchange, debounced) so the scroll
  re-evaluates for the new width — the owner accepts the restart on resize.
  Rotator gains restart(); engine gains bannerCurrent()/remeasureBanner().

Engine continuity + remeasure unit-tested.
fix(ads): restore reliable banner fades + keep banner on profile update
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
dc582e9f73
Two regressions from the previous banner pass:

- Fade (#2): the manual-opacity fade could paint opacity 0 and 1 in one frame and
  skip the transition — most visible for a single (default) campaign message,
  whose only fade is the first show. Revert the fade to Svelte transition:fade
  (which forces the from-state, so even the first/only message fades), keeping it
  on its own {#if} layer independent of the scroll. A freshly-mounted view onto a
  running cycle still renders the live message instantly (inFade duration 0 once),
  so navigation does not replay the fade. Verified by opacity sampling: advances
  fade, navigation stays at opacity 1.

- Profile update (#3): the banner block was attached only to GET /profile, so a
  profile.update (e.g. a language switch) returned a profile without it and the
  banner vanished until reload. A shared profileResponse() now attaches the banner
  to GET, PUT and the link/merge profile responses. Regression test added
  (TestBannerSurvivesProfileUpdate).

Still open: the scroll position is not preserved across navigation (the view
remounts); discussed separately.
feat(ads): carry the banner scroll position across navigation
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
9f83962bf7
Per the owner's idea: instead of moving the banner out of the per-screen header
(which would change its position), remember the banner's "life stage" and resume
it on the next screen. The engine already keeps the message + rotation timing;
this adds the scroll offset:

- bannerEngine tracks the in-flight scroll (target, duration, start). On attach,
  if a scroll is still running, it computes the current offset and calls the new
  host's resumeScroll(fromTx, toPx, remaining) — the view jumps to the carried
  offset and continues to the end over the remaining time, instead of restarting
  at the left.
- A finished scroll is left at its end; the rotator's own loop then takes over.

Verified: spot-checked in the browser (a long message at offset -785 resumes at
-788 on the next screen, not 0) and unit-tested (attach mid-scroll calls
resumeScroll with a partial offset and the remaining duration).
fix(ads): pulse a lone banner message through the fade cycle
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 57s
115c92b39a
A single campaign message (e.g. the default campaign's one message) faded in once
on load and then sat frozen — the rotator only ran the fade/advance cycle when
more than one message existed, so with one message there were no further fades.
Drop the `total > 1` guards: every message now runs the full hold → fade-out →
gap → fade-in cycle, so a lone message pulses (the same message fades back in)
and a lone long message fades at each scroll-loop boundary. Multi-message
rotation is unchanged. Verified by opacity sampling (single message pulses
1→0→gap→0→1 without navigation); the single-message test now asserts the pulse.
fix(ads): fade the scroll rewind + keep the banner strip height constant
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
dd45af20ef
Two polish fixes (owner feedback):

- Scroll loop: a long message that scrolled to its right edge rewound with a hard
  jump (no fade). It now runs the same fade as a message change at each rewind:
  fade out at the edge, reset the scroll while hidden, fade the same message back
  in, then scroll again.
- Strip height: during the fade gap the message layer is removed, which let the
  strip collapse by ~1-2px. An always-present invisible spacer now reserves one
  line of height and the message is overlaid absolutely, so the strip height is
  constant whether or not the message is showing.

Verified live: opacity sampling shows a full fade-out → gap → fade-in at each
scroll rewind (~every 6s), and the .ad height stays a single constant value
(30.31px) across the whole cycle including the gap. Loop-fade unit-tested.
Merge pull request 'feat(ads): client banner rotation, fade UX & live toggle (PR2)' (#71) from feature/ad-network-ui into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Successful in 50s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m2s
27871f2a1d
docs(ui): correct the banner section to the shipped rotation behaviour
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
52f2caae51
The banner UX evolved across the PR2 polish iterations past what UI_DESIGN
described. Bring the section in line with the shipped behaviour:

- a lone message pulses (fades out and back in) instead of sitting frozen;
- a long message's scroll rewind fades (out → rewind hidden → fade the same
  message back in) instead of every-message-only fades;
- the scroll position carries across a navigation (resumeScroll), rather than
  restarting at the left;
- the strip reserves a constant line height (invisible spacer + absolute
  message layer) so it does not jump while the message is gone during the gap.

ARCHITECTURE §10 already deferred these client-fade details to UI_DESIGN, so
this is the only doc that drifted. Docs-only, no code change.
Merge pull request 'docs(ui): correct the banner section to the shipped rotation behaviour' (#72) from docs/banner-ui-accuracy into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m8s
ac1c89c0ee
feat(robot): per-game display names from a wide name corpus
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 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
183e08ec80
Decouple the displayed opponent name from the small pool of durable robot
accounts: the disguised auto-match robot now gets a freshly composed name each
game, stamped on a new game_players.display_name seat snapshot. The snapshot
also captures humans' names, freezing what an opponent sees for the life of a
game (a later rename no longer rewrites past games); readers fall back to the
account's current name for pre-migration rows.

Names come from a wide composed corpus (internal/robot/namevariety.go): Western
locales (EN/DE/ES/IT/FR/PT), native Japanese/Chinese names, a gender-agreed
Russian pool, and human-style handles. Routing keeps Pick's spirit -- a Russian
game draws Cyrillic + <=20% Latin and never a CJK script; an English game the
full corpus -- via robot.PickNamed.

Loosen account.ValidateDisplayName (and the UI mirror) to admit a trailing run
of up to five digits, so "Player2007"-style handles are valid for humans too
and the disguised robot stays indistinguishable.

Migration 00007 adds game_players.display_name (additive, NOT NULL DEFAULT '');
jet regenerated. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru, PLAN, README) updated.
fix(robot): show the per-game name in REST game views, not the account name
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 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
6d66545062
The seat display-name snapshot only reached the live-event path (seatNames);
the REST DTO layer still resolved seat names from the account store
(fillSeatNames), so the game screen and lobby list showed the robot's seeded
account name ("Женя") while the your_turn toast showed its per-game name
("Звёздный_Барс2"). Carry the snapshot into gameDTOFromGame and have
fillSeatNames fall back to the account only for a seat with no snapshot (a
pre-snapshot legacy row). Friends and invitations keep account names (the
persistent identity, not a per-game disguise).
feat(robot): gender-agree Cyrillic handle adjectives
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
14b5f61af9
Cyrillic adjective+noun handles paired a masculine adjective with any noun, so a
feminine noun read ungrammatically ("Вольный Комета"). Carry masculine + feminine
adjective forms (cyrAdjective) and tag each noun's gender (cyrNoun); cyrillicNick
now renders the adjective in the agreeing form ("Рыжая Комета", "Дикий Волк"), and a
few feminine nouns (Комета/Звезда/Молния/Пантера/Рысь/Буря/Сова/Акула) are added for variety.
Merge pull request 'feat(robot): per-game opponent names from a wide corpus + frozen seat-name snapshot' (#73) from feature/robot-name-variety into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
51d7c4e005
fix(robot): make a dot between two handle words rare, prefer underscore
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
9c30bdf8e7
A handle joining two meaningful words with a dot ("Тихий.Воин", "Hidden.Hunter")
reads as machine-generated — people use "_" there, a dot only rarely. assembleHandle
now picks "_" about nine times in ten and "." only about one in ten for the
separator-joined form; the camelCase and bare-noun forms are unchanged.
Merge pull request 'fix(robot): rare dot between handle words, prefer underscore' (#74) from feature/robot-nick-separator into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
e9dfa4ccb8
feat(nudge): name the sender; blink lobby cards; re-animate toasts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
12d128f1cc
The "waiting for your move" popup was the nudge (chat.nudge), shown without a
sender name. Resolve the nudger's per-game seat name server-side and carry it on
a new NudgeEvent.sender_name field, so:

- the in-app toast reads "<opponent>: Waiting for your move 🤭" (chat.nudgeBy);
- the out-of-app Telegram push names the sender too (render nudgeBy);

falling back to the plain phrase when the name is absent (RU mirrored). The
your_turn toast already named the opponent and is unchanged.

Lobby: when a card transitions into "your turn" or "finished" while the lobby is
open, its status emoji blinks twice (two 1s fades); the opponent's-turn change
stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer)
so overlapping events animate in isolation; suppressed under reduce-motion.

Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the
previous one and replays its entrance, uniformly on every screen.

Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName
(integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru,
UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated.
fix(ui): don't refetch the lobby on heartbeats
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
2a034ff9be
The lobby refetched /user/games on every stream event, including the 10s
keep-alive heartbeat, turning it into a 10s poll. That poll's REST view of a
just-committed opponent move could flip a card (and now blink it) seconds before
the matching your_turn event — and its toast — arrived over the slower live
stream, so the new lobby-card blink appeared to lead the toast by 5-7s (variable
with stream delivery; in sync when prompt).

Gate the refetch to real events (kind !== 'heartbeat'): the card, its blink and
the toast now ride the same event (opponent_moved + your_turn are published
together), and the constant 10s full-lobby poll per client is gone.
Merge pull request 'feat(nudge): name the sender; blink lobby cards; re-animate toasts' (#75) from feature/nudge-name-lobby-blink into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
05d83ced86
feat(lobby): cap simultaneous quick games at 10
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 1m8s
63ab85a5e5
Limit a player to 10 active quick games (auto-match + AI); friend games created
by invitation are not counted. At the cap the backend refuses both new-game
entry points — quick enqueue and invitation creation — with 409
game_limit_reached, while accepting an incoming invitation stays allowed, so
friend games are capped from the other end. The lobby disables "New Game" and
shows a low-emphasis notice, driven by a new at_game_limit flag on games.list
(no per-event payload: a turn change does not move the count, and the lobby
already re-fetches games.list on entry and every game event).

- game.MaxActiveQuickGames + Store/Service.CountActiveQuickGames (active/open
  seats, no game_invitations row; hidden games still count -> dedicated count)
- Server.ensureUnderGameLimit gating handleEnqueue + handleCreateInvitation;
  game.ErrGameLimitReached -> 409 game_limit_reached
- FB GameList.at_game_limit (regenerated Go + TS) through the gateway transcode
  and UI codec; gameListDTO + lobbycache snapshot + Lobby.svelte + i18n
- tests: integration count rule + HTTP gate + accept bypass; server error map;
  gateway transcode round-trip; UI codec + lobbycache unit; e2e gamelimit
- docs: PRERELEASE (GL), FUNCTIONAL(+ru), ARCHITECTURE 8, UI_DESIGN, backend README
Merge pull request 'feat(lobby): cap simultaneous quick games at 10' (#76) from feature/quick-game-limit into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 50s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
d53ff18a67
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
aaac816dc2
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.
fix(chat): don't restore unread on a failed read-ack (would loop the in-game effect; REST re-seed self-heals)
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
a7f3df9346
feat(chat): a message to a disguised robot opponent is born read
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
20f2a5a011
A pooled robot substituted into an ordinary (non-AI) game never opens the
chat, so a text message to it would linger unread forever — skewing the unread
count and the publish-to-read metric. Clear its recipient bit at PostMessage
time (robotRecipients via account.IsRobot), so the message is born read. The
human sender never had their own message unread, so this is invisible to them;
a nudge to a robot already self-clears when the robot answers by moving.
Merge pull request 'feat(chat): unread read-receipts — lobby/game dot, history-open ack, admin + metrics' (#77) from feature/chat-read-receipts into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
b71f006ddf
feat(observability): add the Scrabble — Messages Grafana dashboard
CI / changes (pull_request) Successful in 1s
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 59s
c3b3cafcdd
Visualises the chat read-receipt metrics added with the read-receipt feature
(the dashboard was deferred there per the owner): posted rate by kind, the
unread backlog (chat_unread_messages gauge), and the publish-to-read latency
(chat_read_duration — average by kind plus overall p50/p95). Mirrors the
game-domain dashboard's structure; the file provider auto-discovers it.
Merge pull request 'feat(observability): Scrabble — Messages Grafana dashboard' (#78) from feature/messages-dashboard into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Successful in 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
236152ea7b
fix(ui): pin the in-game ad-banner height on a short viewport
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
7cd4474945
The ad banner lives inside the grown game header (`.nav.grow`, portrait
game only). That rule carried `flex: 1 1 auto`, so on a short viewport the
flex algorithm shrank the header — clipping the banner (`.ad` is
`overflow:hidden`) — *and* the board's scroll area at the same time. The
banner and the board ended up splitting the vertical squeeze.

Drop the shrink (`flex: 1 0 auto`): the header still grows into spare height
(banner under the title, board pinned to the bottom), but on a short viewport
it holds its natural height and the board's own scroll (`.stage`) absorbs the
whole squeeze. The banner now keeps a constant height.

Portrait-only: in landscape and on every other screen `.nav` is already
`flex: 0 0 auto`, so the banner never shrank there.

Verified in the mock UI (portrait, live banner): at 440px tall the banner
held 30px (was clipped to 14px) while the board scrolled; the tall-viewport
layout is unchanged. Full UI suite green locally (check, 272 unit, build,
bundle-size, 140 e2e).
Merge pull request 'fix(ui): pin the in-game ad-banner height on a short viewport' (#79) from feature/game-banner-fixed-height 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 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
712ef205c1
feat(lobby): drop left honest-AI games from the finished list
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
2f4aa1b75b
A finished honest-AI (vs_ai) game the player left — by resigning or by
abandoning it to the 7-day inactivity timeout (end_reason 'resign'/'timeout')
— no longer appears in that player's own lobby finished list.

The new game.Service.ListForLobby filters ListForAccount for the lobby
endpoint only; the admin console and the account-merge count keep the full
set. The filter keys on the game's end reason, not on which seat left, so it
extends to any player should the robot ever resign.
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
2c24d54047
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.
On iOS WKWebView (the Telegram Mini App), cancelling the Web Share sheet fell
through to the Blob <a download> fallback. iOS ignores the download attribute,
so clicking the anchor navigated the webview to the blob: URL — replacing the
SPA with the raw GCG file, with no way back (force-quit only).

The share path no longer falls back to a download: Web Share is available on
that platform, so a cancelled or failed share is a no-op and the user can
retry. The Blob download stays the desktop-only path (no Web Share).
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
fb0ddab0f1
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).
Merge pull request 'feat: AI-game & resign UX cleanup (lobby drop, board reveal, GCG share fix, hide AI export)' (#80) from feature/lobby-drop-left-ai-games into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 51s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
5a3f0951ae
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
8793bd34f2
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.
fix(matchmaking): re-enqueue opens a new game, not the caller's own
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
9d52885a6e
A second "random opponent" enqueue with the same variant and per-turn
rule, while the caller's first game was still open (awaiting an
opponent), returned that same open game, so a player could never start a
fresh random game while one was still searching.

Drop the own-open short-circuit (step 1) in store.OpenOrJoin: a
re-enqueue now joins another player's open game or opens a fresh one.
Accumulation stays bounded by MaxActiveQuickGames, which counts open
games. Update the matchmaker/service/store doc comments and
ARCHITECTURE.md, and flip the pinning test to assert the new behavior.
Merge pull request 'fix(matchmaking): re-enqueue opens a new game, not the caller's own' (#82) from feature/reenqueue-opens-new-game into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
9074417762
Reviewed-on: https://gitea.lan/developer/scrabble-game/pulls/82
Reviewed-by: Ilia <3+owner@noreply.gitea.iliadenisov.ru>
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
81b9e1529e
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.
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
64be0572b3
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
Merge pull request 'feat(social): asymmetric per-user block, in-game block control, admin lists' (#83) from feature/in-game-block-and-friend-ux into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m13s
1c87313a78
Reviewed-on: https://gitea.lan/developer/scrabble-game/pulls/83
Reviewed-by: Ilia <3+owner@noreply.gitea.iliadenisov.ru>
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
ab1ad998aa
- 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.
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
b5688d4848
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.
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
4adb608ad1
- 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).
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
b156d3403c
- 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.
Merge pull request 'feat(ui): UI polish batch (toast, gestures, lobby, settings, admin count)' (#84) from feature/ui-polish-batch into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
b65a3ecc9c
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
0dd4099d68
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.
fix(ui): lower-case hint preview words to match 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 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
d0681c5efe
A hint's MoveRecord words are upper-cased on decode (for the move-history view),
whereas an EvalResult keeps the backend's lower case. Seeding the move preview
from the hint verbatim flipped the score caption to upper case and nudged its
height. Lower-case the words in previewFromHint so the caption reads the same
as the evaluate path it replaces.
Merge pull request 'perf(ui): reuse hint result as move preview, skip redundant evaluate' (#85) from feature/hint-preview-reuse 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 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
0eb1e0ef47
feat(robot): shrink endgame think time when both sides pass
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
f3768d20f2
In a dead-drawn endgame — the two most recent journal moves are both
passes, so the board and the robot's rack are frozen and the robot is
bound to pass again — the robot still waited out its long late-game think
time (up to 90 min) before passing, needlessly dragging out a decided game.

Shorten that delay to a [0.8, 1.5]x band around the human's last-move think
time (the gap between the last two journal entries), clamped to [30s, 8min]
and taken as a min with the normal schedule, so the robot never moves
slower. A slow human collapses to the 8-min cap; a fast human is tracked,
with the floor keeping the robot from passing suspiciously instantly. The
anchor reads the move journal only (no schema change), stays deterministic
from the seed, and still defers to the sleep window.

RobotTurns now carries EndgamePass + OppLastMove, filled by one batched
journal query on the scan; the honest-AI single-game trigger keeps the
normal path (it moves at once). NextMoveAt (admin ETA) is left as the
normal-schedule upper bound.
Merge pull request 'feat(robot): shrink endgame think time when both sides pass' (#86) from feature/robot-endgame-shrink into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
c67a5d51f1
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
6e77de4c1e
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.
Merge pull request 'feat: sparser robot nudges, typed unread badge, lobby unread bump' (#87) from feature/robot-nudge-badge-sort into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m10s
9ded5d3d86
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
c127bc9f0e
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.
style(lobby): bold the score line, a touch smaller
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
9644bd6e5e
Per owner follow-up: the whole lobby score line is now bold (font-weight 700,
matching the over-the-board score plaques) and a hair smaller (0.8rem) to offset
the heavier weight. The viewer's own number keeps its green/red standing tint.
fix(lobby): keep the spaces around the score separator
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
c9a5ca3ed7
Svelte trims leading/trailing whitespace inside an element, so the literal
`<span class="sep"> : </span>` rendered as a bare ":" ("123:123"). Emit the
separator as a string expression `{' : '}`, which Svelte preserves, restoring
"123 : 123".
Merge pull request 'feat(social): per-game friend request to disguised robots + lobby/stats/tile cosmetics' (#88) from feature/robot-friend-request into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
76d4610e6f
feat(game): official first-move tile draw + admin step-by-step replay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
caefc8f579
Decide who moves first by the official rule: each seated player draws one
tile, the one closest to "A" leads (a blank beats every letter), ties
re-drawing until a single leader remains. Each draw uses honest per-draw
crypto/rand entropy (not the deterministic bag seed), so the recorded draw —
not a seed — is the only account of the outcome. The leader takes seat 0, so
the engine and journal replay are unchanged.

The draw is recorded with the game (game_setup_draws, migration 00013) for
future tournaments, designed as a discrete "player N draws a tile" step.
Friend/AI games draw at creation. Auto-match draws when the game opens,
against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are
back-filled to the real opponent on join — so the opener's seat is fixed up
front and the existing open-game pre-move is preserved with no reseating.

Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step
board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with
A-O/1-15 headers and highlighted premium squares, placed letters with their
tile value as a subscript, rack panels around the board (seat 0 top, 1
bottom, 2 left, 3 right) with the current player highlighted, and a per-move
log with the tiles drawn and the bag remainder.

Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
docs: drop the design-spec artifact (content lives in ARCHITECTURE/FUNCTIONAL/PRERELEASE)
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
e06c6ff67f
Merge pull request 'feat(game): official first-move tile draw + admin step-by-step replay' (#89) from feature/first-move-draw into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
1933849dba
feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
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 57s
57c778f9b2
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
Merge pull request 'feat(telegram,game): single bot + per-user variant preferences' (#90) from feature/single-bot-variant-preferences into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m10s
a21ba23e5e
refactor(db): squash migrations into a single baseline
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
483e945209
Consolidate the incremental goose migrations (00001-00014) into one
baseline. There is no production data, so the squash carries no data
migration. The baseline was generated from the end-state schema and
verified schema-identical to the squashed set (pg_dump diff) plus a full
integration run; the default house ad-campaign seed is carried over (a
schema-only dump omits it). The per-feature narrative that lived in the
squashed migrations is preserved in git history and docs/ARCHITECTURE.md.

Deploy note: this breaks goose's version continuity, so the test contour
DB must be wiped once — DROP SCHEMA backend CASCADE + restart backend —
for goose to re-apply the single baseline fresh. No prod data exists.
Merge pull request 'refactor(db): squash migrations into a single baseline' (#91) from feature/squash-migrations into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m11s
c739f12d3d
feat(engine,deploy): seed-drift guard + track current dictionary release
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
a5db10c46e
The dictionary release moved to v1.2.1 while DICT_VERSION stayed pinned at
v1.0.0 in CI and the image/compose seed defaults. Two problems:

1. CI validated against a stale dictionary.
2. The contour seed could be bumped on a live volume, which silently relabels
   the already-seeded bytes — voiding games pinned to the prior label and
   serving the wrong dictionary for new ones. The flat DAWGs carry no embedded
   version, so this drift was undetectable.

Changes:

- Seed-drift guard: OpenWithVersions records the flat dir's version in a
  .seed_version marker on first boot and refuses to start when a later
  BACKEND_DICT_VERSION disagrees. DICT_VERSION is now the seed for a *fresh*
  volume only; a live contour migrates through the admin console (old versions
  stay resident, in-progress games keep replaying).
- Track the current release: CI's DICT_VERSION centralised to one workflow-level
  env (v1.2.1); image/compose/.env seed defaults bumped to v1.2.1. The deploy
  job keeps reading the per-contour vars.TEST_DICT_VERSION.
- Docs: ARCHITECTURE §5 (decision record), backend/deploy READMEs, PRERELEASE
  tracker (DV row).

Verified locally against the v1.2.1 artifact: gofmt, build, vet, unit and
integration (-tags=integration) all green.
Merge pull request 'feat(engine,deploy): seed-drift guard + track current dictionary release (v1.2.1)' (#92) from feature/dict-version-track-release into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m4s
d40fe1edec
fix(engine): make .seed_version marker authoritative (no boot refusal)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
95f5703372
The seed-drift guard shipped as refuse-boot: the backend exited when
BACKEND_DICT_VERSION disagreed with the flat dir's recorded .seed_version. On
the test contour that turned a harmless-in-intent action — bumping the
TEST_DICT_VERSION variable to the active release (v1.2.1) on a volume seeded as
v1.0.0 — into a crash loop, because DICT_VERSION is the *seed* of a fresh
volume, not the active version (which the admin console drives).

Make the marker authoritative instead: OpenWithVersions resolves the flat dir's
version from .seed_version when present and ignores bootVersion on an
already-seeded volume; bootVersion only seeds a fresh volume's marker. So a
bumped build seed on a live volume is a no-op (it can't relabel live bytes and
can't void games pinned to the prior label), and it correctly seeds the next
fresh volume. The subdirectory scan now skips the resolved seed, so a version
also present as a subdir (e.g. v1.2.1 uploaded via the console while the build
seed is bumped to v1.2.1) is still loaded rather than shadowed by the flat bytes.

Tests: marker-wins over a bumped boot version; a bumped boot keeps the matching
subdir resident (the live-contour case). Docs updated (ARCHITECTURE §5, READMEs,
compose/.env, PRERELEASE DV) from "refuses to boot" to "marker wins / ignored".

Verified locally against v1.2.1: gofmt, build, vet, unit, integration green.
Merge pull request 'fix(engine): make .seed_version marker authoritative (no boot refusal)' (#93) from feature/dict-seed-marker-wins into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Failing after 21s
c72adddb91
ci(deploy): probe backend /readyz so a dead backend fails the deploy (#94)
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m11s
9824214fd7
The post-deploy probe checked only the static landing and the gateway-served SPA shell, so a crash-looping backend passed the deploy green. Add an http://backend:8080/readyz probe on the internal network (and dump backend logs on failure) so an unready backend fails the deploy loudly.
fix(ui): green both lobby scores on a tie, mute a 0:0 board
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 1m7s
264097bbf6
The lobby tinted only the viewer's own number, and a tie counted as
"leading" — so an even score showed only the viewer's number green,
reading as if the viewer were ahead. A fresh 0:0 board did the same,
accenting the start of a game where nobody has scored.

scoreStanding is now per-seat: the viewer's seat stays green when
leading or tied and red when trailing; an opponent's seat greens only
when it ties the viewer for the lead, so an equal non-zero score paints
both numbers green. When the top score is 0 (nobody has moved) every
number is left muted, like a finished game.
Merge pull request 'fix(ui): green both lobby scores on a tie, mute a 0:0 board' (#95) from feature/lobby-equal-score-color 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 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
2a8717c930
feat(telegram): split connector into home validator + remote bot
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 53s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Failing after 2m6s
6aeb529f13
Move all Telegram egress off the main host. The single connector held the
bot token, long-polled Telegram and answered the gateway/backend over the
trusted internal network, so the whole component (including login validation)
shared fate with its VPN sidecar. Split it into two binaries that share the
token:

- cmd/validator (home, no VPN): Mini App initData + Login Widget HMAC only,
  never calls the Bot API. The gateway dials it for Telegram auth, so game
  login is now independent of Telegram reachability.
- cmd/bot (remote): Bot API long-poll + sendMessage, the only component
  reaching Telegram. It holds no inbound port — it dials the gateway over a
  new reverse mTLS bot-link (pkg/proto/botlink/v1) and executes the send
  commands the gateway pushes.

The gateway funnels sends to the bot-link: out-of-app push is fire-and-forget
(at-most-once, dropped if no bot is connected); the backend admin broadcasts
reach a gateway-served relay that forwards them and awaits the bot's ack
(SendToUser/SendToGameChannel contract preserved). mTLS (pkg/mtls) is the one
inter-service link that leaves the trusted segment; validator<->gateway and
the relay stay plaintext internal. The bot is Telegram-rate-limited.

One bot now; the gateway bot registry, an owns_updates flag and per-command
ids leave seams for N later. Webhook rejected (one URL per token, adds inbound
+ a static address).

The unified test contour runs the split (the bot keeps its VPN sidecar and
dials the gateway by its internal name; bot-link certs from deploy/gen-certs.sh,
generated in CI). The prod wiring — the bot on a separate host (no VPN), the
gateway bot-link port published, PROD_ certs with scheduled rotation, an SSH
deploy of both hosts together — is the deferred final stage (PRERELEASE.md TX,
Stage 18).

Docs: ARCHITECTURE, PRERELEASE (phase TX), platform/telegram + gateway +
backend + deploy READMEs, FUNCTIONAL(+ru), CLAUDE.md, .env.example.
fix(deploy): make bot-link cert leaves readable by the distroless nonroot UID
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
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 53s
860cfeb30f
The gateway and bot run on distroless nonroot (UID 65532) and bind-mount the
cert dir read-only, but gen-certs.sh wrote the keys 0600 (owner-only, the deploy
user), so both crash-looped at boot with "open /certs/*.key: permission denied"
and the deploy probe correctly failed (the contour's gateway was down).

The .crt files were already 0644 (openssl default); make the leaf keys 0644 too
so UID 65532 can read them. These are ephemeral TEST certificates regenerated
every deploy on the trusted runner; prod keys come from PROD_ secrets. The CA
private key stays 0600 (containers never read it).
Merge pull request 'feat(telegram): split connector into home validator + remote bot (mTLS bot-link)' (#96) from feature/telegram-egress-botlink into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m24s
3fffee7817
feat(gateway): temporary IP ban (fail2ban) fed by rejections + honeypot/honeytoken
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
041106d623
Add a prod-only, in-memory IP ban enforced at the edge, fed by three signals:
sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the
user class stays the backend soft-flag's concern), a honeypot decoy-path hit (the
contour caddy tags decoys with X-Scrabble-Honeypot and routes them to the gateway),
and a honeytoken (a planted bearer, GATEWAY_HONEYTOKEN). A banned IP is refused with
429 by the abuseGuard middleware before any work — covering the Connect edge, the
live stream and the static SPA/landing the per-op limiter never gated.

The ban is off by default: it keys by the real client IP the shared-NAT test contour
does not expose, so a ban there would be self-inflicted; detection still logs in the
contour, only the ban action is gated (GATEWAY_ABUSE_BAN_ENABLED). Rejection bans last
GATEWAY_ABUSE_BAN_DURATION; tripwire/honeytoken hits are near-zero-false-positive and
earn longer fixed bans. Each ban increments gateway_abuse_banned_total{reason}.

Operators see and lift active bans on the admin console's Throttled page; the gateway
syncs its active set to the backend every 30s (POST /api/v1/internal/bans/sync,
backend/internal/banview) and applies the operator unbans the response returns.

PRERELEASE phase AG. Docs baked into ARCHITECTURE / FUNCTIONAL (+ru) / both READMEs.
docs(prerelease): mark phase AG done (code + test contour)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
81b44c2b02
fix(deploy): honeypot tag dropped — Caddy applies header_up delete after set
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
cf9fa75d62
The @honeypot block both deleted and set X-Scrabble-Honeypot in one reverse_proxy.
Caddy applies header_up deletions *after* sets, so the tag we set was immediately
stripped: the gateway never saw it, and a decoy hit (e.g. GET /.env) fell through
to the gateway's /app redirect (308) instead of tripping the honeypot. Drop the
delete — the bare set already replaces any client-supplied value. The real
endpoints keep stripping the header in the @gateway block (delete-only, no
conflicting set). Caught on the live test contour (no caddy locally).
Merge pull request 'feat(gateway): temporary IP ban (fail2ban) + honeypot/honeytoken (PRERELEASE AG)' (#97) from feature/abuse-ip-ban-honeypot into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m15s
e79c1ea891
feat(ui): tile-crossword loading splash for cold lobby open
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 1m17s
ba6ee90278
On a cold app open the lobby's game list arrives over the network; on a
slow link the empty "no games yet" line flashed before the games loaded.
Add a full-screen tile splash that lays a Scrabble crossword of ЭРУДИТ /
ЗАГРУЗКА / ОЖИДАНИЕ (Эрудит point values, hardcoded since the alphabet
table is not cached at boot) until the lobby's first load settles, then
removes itself to reveal the populated list.

- lib/splash.ts: pure layout + reveal schedule (unit-tested).
- components/Splash.svelte: App-level overlay; per-tile drop-in; loops
  ЗАГРУЗКА → ОЖИДАНИЕ until ready, dismisses on a word boundary. Static
  ЭРУДИТ under reduced motion / the mock build.
- app state: lobbyReady (set by Lobby on first settle) + splashDone,
  reset on logout.
- App.svelte: overlay while routeIsLobby && !splashDone; the plain text
  splash now only covers non-lobby deep-links during bootstrap.
- docs: UI_DESIGN + FUNCTIONAL (+ _ru).
fix(ui): hold each splash word for the pause before the readiness check
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 1m18s
9642cafc1f
The dismiss check fired at the instant a word finished laying, so a word
was never held: ЭРУДИТ fell straight into the lobby (too fast) and
ЗАГРУЗКА got no readable pause. The pause was a *leading* gap before the
next word, not a hold after the current one.

Move the hold to after each word and run the check after it: every word
(ЭРУДИТ included) now stays up for PAUSE_MS before the splash either
dismisses or lays the next word. prefixMs = WORD_MS + PAUSE_MS,
cycleMs = 2*(WORD_MS + PAUSE_MS).
Merge pull request 'feat(ui): tile-crossword loading splash for cold lobby open' (#98) from feature/lobby-splash-tiles 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 1s
CI / deploy (push) Successful in 1m15s
41d21f3f6f
feat(telegram): promo bot + channel-chat moderation gate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
e71e40eef5
Add a second standalone promo bot to the bot container (answers /start with a
localized message + a URL button into the main bot's Mini App) and gate write
access in a channel's linked discussion chat: grant on join when the Telegram
user is registered and neither admin-suspended nor holding a new chat_muted
role, and revoke/grant on the matching moderation change for a member currently
in the chat.

Eligibility (registered AND NOT suspended AND NOT chat_muted; the game
suspension dominates) is resolved once in the backend and reached two ways: the
bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link,
and a backend chat_access_changed event -> gateway -> ChatGate command
(idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the
block/unblock path with getChatMember, since bots cannot list members.

A web_app button cannot open another bot's Mini App (it signs initData with the
sending bot's token), so the promo button is a t.me ?startapp URL reusing the
UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members
right and chat_member in its allowed updates.

No schema change: chat_muted reuses the data-driven account_roles table.
fix(telegram): keep a failed promo-bot construction non-fatal
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
b22b624d28
tgbot.New validates the token with getMe, so a bad or unreachable promo token
would otherwise return an error from run() and crash-loop the whole bot process
— taking the main game bot down with it, since they share the container. Log it
and skip the promo bot instead; the main bot and bot-link are unaffected.
feat(telegram): chat-gate observability + grant on first registration
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
a404513037
Two follow-ups from a contour test where a user joined the chat, then
registered, and got no write access — with silent logs.

Observability: log every chat_member update (chat id, configured id, user,
old->new status), the eligibility result and the grant outcome; plus a startup
self-check that warns loudly when the bot is not an administrator in the chat
with the restrict-members ("Ban users") right — the common misconfiguration,
previously invisible in the logs.

Grant on first registration: a user who joins the moderated chat BEFORE
registering is covered by no chat_member event, so the join-time grant never
fires for them. ProvisionTelegram now reports first contact, and the Telegram
auth handler emits chat_access_changed on it, so the gateway re-evaluates and
grants write access if the user is already in the chat.
fix(telegram): grant write to restricted members in default-deny chats
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
380f82438c
A default-deny discussion group reports a present or freshly joined member as
`restricted` (no send right), not `member`. The join filter required `member`,
so the real case never matched and a registered user stayed muted. Grant any
eligible in-chat member (member or restricted) that still lacks the send right,
with a loop guard (skip when send is already allowed) so the bot's own grant
does not re-fire. Revoking a now-ineligible user stays the chat-gate path's job,
so this never fights a chat_muted/block.
fix(telegram): grant in-chat members regardless of reported can_send
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
0ab1719ee9
The CanSendMessages loop-guard skipped exactly the stuck case — a restricted
member whose chat_member event reports can_send=true yet who cannot actually
write. Replace it with a precise loop guard (skip only the bot's own restrict
action, i.e. the update whose performer is the bot) and grant any eligible
in-chat member (member or restricted) otherwise. Also log the new member's
can_send, is_member and the actor id for full visibility.
fix(telegram): invert the chat gate — mute the ineligible (default-allow)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
bdd1cc7d85
Telegram intersects the chat default with each user's permissions, so a per-user
grant can never exceed a deny-by-default group: the original default-deny +
grant design could not let any user write (can_send=true was AND-ed with the
denying default). Invert it — the chat allows sending by default and the bot
MUTES an ineligible member (unregistered, admin-suspended, or chat_muted) and
restores an eligible one it had muted, acting only when the current state
differs (idempotent, no self-loop). The block/unblock/chat_muted/registration
path already sets can_send to the eligibility, so it is unchanged.
docs(telegram): invert chat-gate strategy in docs; tune logs; i18n text
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
1ba789a1f1
- Bake the final default-allow + mute-the-ineligible strategy into
  docs/ARCHITECTURE.md, docs/FUNCTIONAL.md (+_ru), platform/telegram/README.md,
  the deploy compose comment and the PRERELEASE tracker. The live test proved a
  per-user grant cannot exceed a deny-by-default group (Telegram intersects the
  chat default with the per-user permission), so the chat allows sending by
  default and the bot restricts the ineligible instead of granting the eligible.
- Lower the per-event chat_member trace and eligibility evaluation to Debug;
  keep the actual mute/unmute actions, the startup self-check and warnings at
  Info, so prod logs only what the bot did.
- Update game.searchingForOpponent (Searching -> Waiting for opponent / Поиск ->
  Ждём соперника) and the quickmatch e2e assertions to match.
Merge pull request 'feat(telegram): promo bot + channel-chat moderation gate' (#99) from feature/telegram-promo-bot-chat-moderation into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m6s
fa8abf22db
fix(telegram): reply to /start only in private chats
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
c494da553a
The main bot is now an admin in the moderated discussion group and receives its
messages (allowed_updates includes message). Its default handler replied to
every message with a Mini App launch button — an inline web_app button, which
Telegram permits only in private chats — so replying in the group failed with
BUTTON_TYPE_INVALID (silently: the send fails, no user-facing error). Reply only
in a private chat; in the group the bot only manages permissions. The promo bot
gets the same guard.
Merge pull request 'fix(telegram): reply to /start only in private chats' (#100) from feature/telegram-private-reply-guard into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 16s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
dec6fac013
perf(gateway): pool backend conns; loadtest evaluate hot path
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
e2771826fd
The loadtest harness never modelled game.evaluate — the debounced per-tile
play preview a real client fires several times per turn, the hottest gameplay
call. Model it (one evaluate per placed tile + reconsideration re-previews +
draft.save, human-paced; --eval / --eval-recon toggle it).

That realistic load surfaced the real bottleneck: the gateway's backend HTTP
client used the default transport (MaxIdleConnsPerHost=2), so every sync call
to the single backend host churned a fresh TCP connection — ~26500 TIME_WAIT
sockets at 500 players (near the ephemeral-port ceiling), burning ~1.75 gateway
cores while the backend sat near-idle. It was the unfixed root of the residual
transport_error the earlier passes chased on the client side.

Widen the keep-alive pool (backendMaxIdleConns=512, ~2x the observed 225-conn
peak). At 500 players the churn collapses to ~0 and peak gateway CPU drops ~7x
(~1.75 -> ~0.26 cores); postgres (~1.65 cores) becomes the busiest service.
This overturns the earlier "gateway is the binding constraint, scale it
horizontally" sizing — that was sizing around this bug, not a real floor.

Consolidate the loadtest trip reports into one loadtest/REPORT.md (drop the
R2/R7 split) and bake the finding into README / PRERELEASE / ARCHITECTURE /
TESTING.
perf(backend): cut evaluate's DB round-trips; load the game in one query
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m18s
ecb21bd218
EvaluatePlay (the hottest gameplay call, fired on every tile placement) now uses
the warm live-game cache directly: an active game stays cached (mutated in place
across moves, evicted only on finish), so the cached engine game and its immutable
seat list answer the membership check and the score with no DB read. The cold path
(eviction / first load) still loads and validates via the store. The seat list is
cached alongside the engine game for the membership fast path.

GetGame also folds its two round-trips (game, then seats) into one LEFT JOIN,
preserving the contract (same Game, a seatless game still returns empty seats, seat
order kept) — one round-trip for every remaining caller.

Measured at 500 players: evaluate p99 halves (200 -> 100 ms) and the per-op query
count drops. It does NOT cut postgres CPU — that is write-bound (per-move CommitMove
plus draft upserts and journal replays), the cheap indexed GetGame reads were never
its bottleneck, and postgres runs with headroom (~1.5 of 2 cores). So this is a
latency / query-volume optimization, not a DB-CPU one.

Regression cover: a non-player evaluate against a warm game asserts the cached-seat
membership path; the integration suite exercises GetGame's join across every game op.
Merge pull request 'perf(gateway): pool backend conns; loadtest evaluate hot path' (#101) from feature/loadtest-evaluate-hotpath into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m26s
62f42ed102
fix(ui): retry Mini App launch on backend failure; hide account linking
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 1m8s
e336638ca8
Inside Telegram, a failed initData authentication (e.g. the backend down
during a deploy) dropped the user onto the web login screen — the /app/
experience, which has no place inside the Mini App. bootstrap now retries the
launch a few times in silence and then renders a dedicated boot-error screen
with a Retry button (new BootError.svelte, app.bootError), never falling back
to the web sign-in. A blocked account is still terminal and goes straight to
the blocked screen.

The profile "Link an account" section (email + Telegram link) is hidden while
sign-in is provider-only; the anonymous /app/ guest whose upgrade path this is
comes later. The flow is kept wired (`hidden` on .emailbox) and its two e2e
specs are skipped, both to be re-enabled together.

Adds i18n boot.* copy (en/ru), a mock authTelegram failure hook plus an e2e
covering the retry screen, and bakes both behaviours into FUNCTIONAL(.md/_ru).
Merge pull request 'fix(ui): retry Mini App launch on backend failure; hide account linking' (#102) from feature/tg-boot-retry-hide-linking 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 1m6s
b54cb8878d
Idempotent playbooks under deploy/ansible/ prepare both production hosts:
docker-ce + compose plugin, a non-sudo deploy service account holding the CI
deploy key, key-only sshd, default-deny ufw, fail2ban, unattended upgrades and
chrony. The main host also opens 80/443/9443 and creates the external edge
network; the tg host verifies direct Bot API egress (the no-VPN assumption).

The application is deployed separately by the prod-deploy workflow (later
phase), running as the deploy account this playbook provisions.
Split the contour across the two prod hosts and retune for the small main host:

- Gate vpn+bot to the telegram-local profile. The CI test deploy now passes
  --profile telegram-local so the test contour still brings them; the prod main
  host omits both, and the prod bot runs standalone from docker-compose.bot.yml.
- docker-compose.prod.yml (main-host overlay): publish caddy 80/443 (no host
  caddy in prod; caddy owns ACME) and gateway 9443 (the remote bot dials in over
  mTLS); GOMAXPROCS=2, smaller memory caps and 7d Prometheus retention for the
  2 vCPU / 1.9 GiB host. It launches deliberately undersized; resize reactively.
- docker-compose.bot.yml: standalone bot for the tg host (no VPN, OTLP off since
  otelcol is unreachable from there, dials the main host's bot-link).
- Add node_exporter + a Prometheus scrape so host memory pressure (the OOM
  signal on the tight main host), not just per-container docker_stats, is visible.
- Soften AWG_CONF to a default: only the profiled vpn sidecar consumes it, and
  compose interpolates profiled-out services too, so prod must not require it.
A workflow_dispatch-only rollout from master (confirm=deploy):

- .gitea/workflows/prod-deploy.yaml builds + pushes the images to the registry,
  ships the compose/config/certs/env over SSH, deploys the main host via
  prod-deploy.sh, then the bot host, then verifies the public site.
- deploy/prod-deploy.sh rolls the main stack one service at a time in dependency
  order (postgres->backend->gateway->landing->validator->caddy), health-checking
  after each; any failure rolls the whole stack back to the previous tag. A schema
  migration adds a maintenance window: the backend (sole writer) is stopped for a
  consistent pg_dump before migrating; image rollback stays DB-safe (expand-contract),
  the dump is kept for a manual restore.
- prod overlay: pull the four main images from the registry by tag.
- Runtime secrets reach the host via a sourced env.sh (single-quoted values keep the
  bcrypt hash's literal $ intact, unlike a --env-file).
docs(deploy): bake Stage 18 prod-deploy decisions into the live docs
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
3d9ba3ac3d
- ARCHITECTURE §13 prod bullet -> the realized mechanism: registry transport,
  two-host, rolling + auto-rollback, migration maintenance window, node_exporter,
  the undersized launch; the contour paragraph notes node_exporter + the
  telegram-local profile.
- deploy/README gains a prod rollout runbook (how to run, migrations/restore, cert
  rotation, sizing/monitoring, the full PROD_ set) + node_exporter row, the
  telegram-local profile note, and the soft AWG_CONF note.
- PLAN Stage 18 records the resolved open details and the remaining live cutover
  (pending erudit-game.ru DNS); the tracker reads 'machinery built; cutover pending DNS'.
- PRERELEASE TX/AG note the prod wiring is built.
fix(deploy): guard cd and split DOCKER_GID assignment (shellcheck)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
9f970495ee
cd $COMPOSE_DIR now aborts on failure instead of deploying from the wrong dir;
DOCKER_GID is declared then exported so the subshell exit isn't masked.
Merge pull request 'Stage 18 — prod contour deploy (two-host registry rollout, rolling + auto-rollback)' (#103) from feature/prod-contour-deploy into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 57s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m4s
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) Has been skipped
520a9092fe
owner approved these changes 2026-06-22 05:04:22 +00:00
developer merged commit 2c4f4b10dc into master 2026-06-22 05:05:48 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#104