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.
renderer — the finished-game image-render sidecar
An internal-only Node service that rasterizes the finished-game export PNG. It runs the
same ui/src/lib/gameimage.ts the web project unit-tests — bundled verbatim at image
build time (src/entry.ts → esbuild → dist/gameimage.mjs) — on
skia-canvas, so the server render is
pixel-identical to the design the owner signed off in the browser.
Interface
POST /render—{game, moves, alphabet, labels, dateLocale, hostname, scale?}→image/png.game/movesare the ui-model shapes (GameView/MoveRecord[]);alphabetis the per-variant(index, letter, value)table tile values are drawn from;labelslocalizes the non-play moves (pass/exchange/resign/timeout);hostname+dateLocalefeed the footer.GET /healthz— liveness (the compose healthcheck the backend'sdepends_ongates on).
The service draws and nothing else: authentication, the participant check and the signed
public download URL all live in the backend (backend/internal/server/export.go); the
network path is client → gateway /dl/* → backend → this sidecar.
Development
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
# (allowBuilds) or the native binary is silently never fetched
pnpm test # bundles, then node --test against testdata/request.json
node src/server.mjs # local run on :8090 (RENDERER_PORT overrides)
The runtime image (renderer/Dockerfile, node:22-slim) bakes in Liberation Sans (the
UI font stack's Linux face) and Noto Color Emoji (the scoresheet 🏆); skia-canvas picks
both up through fontconfig. The fixture testdata/request.json is a real 35-move
self-played Russian Scrabble game.