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.
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'.
- 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.
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.
- 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).
- 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.
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.
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.
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.
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.
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:).
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.
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).
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.
- 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)
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).
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.
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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.