A fresh email login that hit the per-IP email rate limit stranded the user in an unrecoverable "offline" state that survived a full PWA restart, even though the network was fine. Root cause: the gateway email class (auth.email.request + auth.email.login, keyed per IP, 5/10min burst 2) trips on the third event, which in the natural "request code -> wrong code -> correct code" sequence is the correct-code login. The gateway returns ResourceExhausted; the client mapped it to 'rate_limited', which retry.ts classified as retryable + a connection code, so exec() called reportOffline(). That pushes the net-state machine into connecting, whose recovery probe is an authenticated profile.get -- but the login screen has no session, so the probe can never succeed and the machine latches offline. The transport kill switch (assertOnline) then refuses the very login that would fix it, and because the IP's email bucket refills only 1 token / 120s, each fresh attempt after a restart is rate-limited again -> offline again. Fix (client, narrow -- the trigger): - A rate-limit is no longer treated as connectivity. retry.ts no longer marks 'rate_limited' retryable or a connection code, so exec() never reports it offline; it surfaces as the existing error.rate_limited "slow down" message. Not auto-retrying it also stops the ~20s button freeze and avoids feeding the gateway's ban tripwire with 6 extra rejections per attempt. Fix (server): - Raise the email-code burst 2 -> 4 so the honest request + a mistyped code + the correct one is not throttled mid-login. Defence-in-depth over the backend's own per-code guards (5-attempt cap + 15-min TTL + send throttle). Tests: retry classification units updated to the new semantics; a gateway regression guard asserts the honest three-event email flow passes under the default policy. gateway/README.md rate-limit note updated. The deeper gap (the net-state recovery probe is session-gated, so any real transport failure on the session-less login/confirm screens still cannot self-heal) is left as a known residual, deferred by the owner.
scrabble-ui
Pure-HTML5 game client — plain Svelte 5 (runes) + TypeScript + Vite, no
SvelteKit. Talks to the gateway over Connect-RPC + FlatBuffers; embeddable in
platform webviews and packageable to native via Capacitor.
The playable slice: sign in (guest / email), the "my games" lobby, auto-match, the board (place tiles by drag or tap, pass, exchange, resign), hint, word-check + complaint, per-game chat and nudge, the live in-app stream, i18n (en/ru), theme, and the profile. Social surfaces add friends/blocks (with one-time friend codes), friend-game invitations, profile editing + email binding, the statistics screen, the lobby notification badge, and the in-game history + GCG export (share or download, finished games only).
Scripts
pnpm install
pnpm start # mock mode (VITE_MOCK): lobby -> game with no backend, :5173
pnpm dev # against a running gateway (Vite proxies /scrabble.edge.v1.Gateway -> :8081)
pnpm check # svelte-check / tsc
pnpm test:unit # Vitest (pure logic + FlatBuffers codec)
pnpm test:e2e # Playwright smoke against the mock
pnpm build # static bundle into dist/ (prod app ~97 KB gzip JS; per-chunk budget: scripts/bundle-size.mjs)
pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
GATEWAY_URL overrides the dev proxy target; VITE_GATEWAY_URL sets the runtime
gateway origin for a packaged (non-proxied) build. VITE_TELEGRAM_BOT_ID
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
domain is registered with BotFather (/setdomain); VITE_TELEGRAM_LINK is the
friend-invite Mini App link for the single bot (full URL https://t.me/<bot>/<app>).
VITE_TELEGRAM_GAME_CHANNEL_NAME is the "Play in Telegram" link shown on the landing page.
The native (Capacitor) build additionally reads VITE_DICT_VERSION (the bundled-dictionary version
the offline path requests, matching the packaged DAWGs), VITE_PAYMENTS_DISABLED=1 (hide in-app purchases
in the RuStore MVP), VITE_RUSTORE_URL (the update overlay's store target; empty until published) and
VITE_APP_VERSION (git describe --tags → the X-Client-Version gate header) — all wired by
.gitea/workflows/android-build.yaml.
The build has two entries: the game SPA (index.html, served at /app/ and
/telegram/) and a lightweight landing page (landing.html, served at /).
How it talks to the gateway
A single Connect Execute(message_type, payload) carries every unary op; the request
and response bodies are FlatBuffers tables (pkg/fbs/scrabble.fbs) in payload.
The session token rides in Authorization: Bearer; a domain failure comes back in
result_code. Subscribe is the live event stream; its game events carry a state delta
that lib/gamedelta.ts applies to the per-game cache (lib/gamecache.ts), so a move renders without
a follow-up game.state (a gap falls back to a refetch). lib/transport.ts is the real
client; lib/mock/ is an in-memory fake selected by MODE === 'mock' (and tree-shaken
out of production). Both speak the plain lib/model.ts types via lib/codec.ts.
No board on the wire: StateView is a summary + rack only, so the client
reconstructs the 15×15 board by replaying the decoded move journal (game.history).
The play loop is alphabet-agnostic: the rack and the play / exchange /
word-check requests carry alphabet indices, and the client caches each variant's
(index, letter, value) table — sent once behind StateRequest.include_alphabet — in
lib/alphabet.ts, rendering the rack and blank chooser from it. Premium squares
(lib/premiums.ts) stay a client-side geometry map ported from
scrabble-solver/rules/rules.go (pinned by a Vitest parity test); tile values and the
alphabet now come from the server table (their parity lives in the Go engine.AlphabetTable
test). Board, tiles and effects are pure CSS + Unicode — no image/font/SVG assets.
Codegen
src/gen/ is committed; CI builds it, it is not regenerated there (the same model
as the Go committed jet/fbs output). pnpm codegen runs flatc --ts on
../pkg/fbs/scrabble.fbs and buf generate (protoc-gen-es) on the edge proto. Needs
flatc 23.5.26 and buf on PATH.
Theming
Design tokens are CSS custom properties (src/app.css); light/dark follows
prefers-color-scheme or an explicit choice in Settings. The token system is
Telegram-themeParams-ready (lib/theme.ts) — a Mini App can override the tokens at
runtime; the Telegram SDK itself is wired in the Telegram stage.
Layout
src/
lib/ model, client facade, transport (+ mock), codec, board replay,
placement state machine, premiums (geometry), alphabet cache, stats, share,
i18n, theme, session, router, app store
components/ Header, Menu (+ badge), Modal, Toast, TabBar, Screen
screens/ Login, Lobby, NewGame, Profile, Settings, About, Friends, Stats
game/ Game, Board, Rack, Controls, MakeMove, Chat
gen/ committed edge codegen (FlatBuffers + Connect)
e2e/ Playwright smoke + social specs (mock)