The finished-game export now works identically on every platform: the client mints a signed relative URL (game.export_url) carrying its date locale, IANA time zone and localized non-play labels, resolves it against its own origin and hands it to the platform's native download — Telegram downloadFile, VKWebAppDownloadFile, or a plain browser anchor (also the desktop VK iframe). Both artifacts ride the route: the .gcg text (no more clipboard mode, except the legacy pre-8.0 Telegram fallback) and the PNG of the final position. The PNG is rasterized by a new internal 'renderer' sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the web project unit-tests, bundled at image build time — one renderer, no drift; the browser no longer draws or delivers bytes itself. The backend rebuilds the render payload from the journal + engine.AlphabetTable, verifies the HMAC (10-minute TTL, BACKEND_EXPORT_SIGN_KEY, constant-time, uniform 404s) on its public group, and streams the artifact as a named attachment; the gateway forwards /dl/* behind the per-IP public rate limiter (caddy @gateway matcher extended — the landing catch-all trap). Deploy: renderer service (compose + prod overlay + roll before backend + prod push list), EXPORT_SIGN_KEY env (TEST_/PROD_ secrets), CI runs the sidecar smoke in the ui job. Docs: ARCHITECTURE export-delivery section, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README.
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 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)