Commit Graph

113 Commits

Author SHA1 Message Date
Ilia Denisov 00129414e5 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
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.
2026-06-15 17:34:07 +02:00
Ilia Denisov 853730823b 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
- 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).
2026-06-15 17:22:09 +02:00
Ilia Denisov 01d2d1f368 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
- 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.
2026-06-15 16:09:10 +02:00
Ilia Denisov 03eb8044ff 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
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.
2026-06-15 15:49:36 +02:00
Ilia Denisov 6679260d0a feat(session): carry the bot service_language on the Session wire
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.
2026-06-15 15:49:26 +02:00
Ilia Denisov 277954c47f 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
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:).
2026-06-15 13:25:36 +02:00
Ilia Denisov 1ae43080ec 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
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.
2026-06-15 12:59:18 +02:00
Ilia Denisov 5287794a72 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
- 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)
2026-06-15 12:45:00 +02:00
Ilia Denisov 419ea11b14 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
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
2026-06-15 12:23:10 +02:00
Ilia Denisov ac62d29ef7 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
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.
2026-06-14 22:52:15 +02:00
Ilia Denisov d1ba666495 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
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).
2026-06-14 21:55:59 +02:00
Ilia Denisov 5f53ec81b9 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
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.
2026-06-14 20:29:47 +02:00
Ilia Denisov 02681ae9e0 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
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.
2026-06-14 20:18:58 +02:00
Ilia Denisov 02ef31c464 feat(ui): landscape iteration — reorder left panel, enable board zoom
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
Left panel order is now score / history / status / rack / controls (the history
fills the middle and scrolls). Board zoom is re-enabled in landscape with the
same gestures as portrait, but height-driven: the viewport is the full right
pane, the board fits by height as a centred square when zoomed out, and on
zoom-in it magnifies past the pane and pans within it (focus-centred scroll that
rides the magnify transition), occupying the full width up to the left panel.
Board.svelte gains a landscape prop; portrait stays width-driven and unchanged.
landscape.spec.ts now asserts zoom works (the zoomed board overflows the pane).
2026-06-14 19:39:06 +02:00
Ilia Denisov 9ec72c8377 feat(ui): landscape two-column game layout, board fitted by height
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
When the viewport is wider than tall (matchMedia orientation: landscape) the
game screen switches from the portrait stack to a two-column layout: the board
fills the right column as the largest square that fits the height (no zoom,
shrinking by width when cramped — lowest priority), while the left panel stacks
the rack (+ make), the status line, the score plaques, the always-open docked
history and the controls. Board zoom, the history slide-drawer gestures and the
growing nav bar are gated off in landscape; the portrait layout is unchanged and
both render from the same snippets so behaviour stays single-sourced.

The mock e2e now defaults to a portrait viewport (the mobile-first app the
gesture/zoom/history specs are written for); landscape.spec.ts covers the wide
layout in its own viewport.
2026-06-14 19:06:35 +02:00
Ilia Denisov c9021fc070 feat(ui): preload ongoing games and cache the draft for an instant, jump-free game open
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
Opening a game from the lobby for the first time this session showed a brief
loading flash, and every open showed a two-step rack->board jump: the saved
draft (pending composition) was fetched separately and applied only after the
board had already painted the full rack.

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

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

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

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

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

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

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

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

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

Tests: applyOpponentJoined + patchLobbyInvitation unit tests (TDD), the
NotificationInvitationUpdate encoding unit test, and integration assertions that
decline/cancel/accept publish invitation_update to every participant. Full local
suite green (backend unit+integration, UI check/unit/build/bundle/e2e). Docs:
ARCHITECTURE §10 (notify catalog + in-app-only note) and UI_DESIGN updated.
2026-06-14 11:22:47 +02:00
Ilia Denisov cf70e6b1fc 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
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.
2026-06-14 10:42:43 +02:00
Ilia Denisov 4409253dce 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
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.
2026-06-14 00:14:49 +02:00
Ilia Denisov 16402e64c0 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
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.
2026-06-14 00:02:57 +02:00
Ilia Denisov a4e6727c70 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
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.
2026-06-13 16:41:55 +02:00
Ilia Denisov a3cb917ec7 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
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.
2026-06-13 10:29:56 +02:00
Ilia Denisov c305363ccd 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
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.
2026-06-12 16:00:22 +02:00
Ilia Denisov cb75623677 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
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.
2026-06-12 13:07:47 +02:00
Ilia Denisov 107add13b6 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
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.
2026-06-12 12:59:39 +02:00
Ilia Denisov 359758a01a 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
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.
2026-06-12 12:31:39 +02:00
Ilia Denisov f73f76220d 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
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.
2026-06-12 10:36:13 +02:00
Ilia Denisov 0b57400c6f 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
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.
2026-06-12 10:28:29 +02:00
Ilia Denisov 74455c7b12 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
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.
2026-06-12 02:17:30 +02:00
Ilia Denisov 390b4c756f 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
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.
2026-06-11 23:51:39 +02:00
Ilia Denisov c32a15730a 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
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.
2026-06-11 23:40:40 +02:00
Ilia Denisov 9277a70565 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
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.
2026-06-11 23:19:16 +02:00
Ilia Denisov 883212f9d1 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
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.
2026-06-11 22:58:37 +02:00
Ilia Denisov 92f48a3b12 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
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.
2026-06-11 22:42:33 +02:00
Ilia Denisov a41c35d5f9 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
- 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.
2026-06-11 21:01:43 +02:00
Ilia Denisov 6268b9d2a2 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
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.
2026-06-11 20:43:12 +02:00
Ilia Denisov e68fe61e39 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
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.
2026-06-11 20:18:23 +02:00
Ilia Denisov b14cc38919 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
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.
2026-06-11 19:23:55 +02:00
Ilia Denisov ac29dca865 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
- 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.
2026-06-11 18:50:10 +02:00
Ilia Denisov fbd67c085c 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
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.
2026-06-11 17:13:24 +02:00
Ilia Denisov 29f655aacd 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
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.
2026-06-11 15:37:06 +02:00
Ilia Denisov 1b3d7dc256 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
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.
2026-06-11 15:27:43 +02:00
Ilia Denisov ad91bc728b 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
- 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.
2026-06-11 15:12:40 +02:00
Ilia Denisov fc1261e078 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
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.
2026-06-11 14:13:54 +02:00
Ilia Denisov 1079878654 R6(c): drop dead opponent_moved scalars (seat/action/score/total)
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.
2026-06-10 17:06:25 +02:00