Files
galaxy-game/ui/PLAN.md
T
Ilia Denisov 1556d36511
Tests · Integration / integration (pull_request) Successful in 1m43s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · UI / test (pull_request) Successful in 2m20s
Phase 28: mark stage done after CI gate green
Gitea runs at commit 6d0272b:
- go-unit #134 → success
- ui-test #136 → success
- integration #135 → success

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:56:29 +02:00

170 KiB
Raw Blame History

UI Client Implementation Plan

This plan stages the implementation of the cross-platform UI client for Galaxy. The client builds from a single TypeScript + Svelte codebase to five targets: web, web-mobile, standalone PC (mac/win/linux), iOS, and Android. A shared Go module (ui/core) carries envelope cryptography, FlatBuffers codec, keypair management, and a thin bridge over pkg/calc/ for UI-side game math; it is compiled to WASM (web), gomobile native libraries (iOS/Android), and embedded directly in Wails (desktop). All network I/O lives on the TypeScript side via ConnectRPC, so the Go module is a pure compute boundary on every platform.

The existing Fyne client in client/ is deprecated and is not modified or imported by the new code. The strategy and rationale behind these choices live in the plan file at /Users/id/.claude/plans/buzzing-questing-fountain.md; the architectural overview is mirrored into ui/README.md as part of Phase 1.

Each phase ends with a runnable artifact. The visual progression is: empty page → navigation skeleton → stubbed views → live data → real actions. Phases are sized so that any one of them can be shipped, run, and reviewed before the next starts; if a direction proves wrong, the plan can be adjusted with at most one phase of rework.


Summary

This plan breaks implementation into 36 small reviewable phases. Each phase has a single primary goal, clear deliverables, explicit dependencies, acceptance criteria, and focused tests. Tests live alongside the code added in the phase; a phase is not closed until its tests are green on the targets it claims to support.

The intended v1 architecture is:

  • TypeScript + Svelte 5 frontend, shared across all five build targets;
  • PixiJS v8 with dual WebGPU/WebGL backend for the world map renderer;
  • Go module ui/core as a compute-only library (canonical bytes, sign/verify, FlatBuffers codec, keypair, thin bridge to pkg/calc/) compiled to WASM, gomobile, and Wails-embedded native;
  • TypeScript-side Core interface with three adapters (WasmCore, WailsCore, CapacitorCore) selected at build time;
  • GalaxyClient on top of Core performs all network I/O via ConnectRPC (@connectrpc/connect-web) on every platform;
  • per-platform storage: WebCrypto + IndexedDB on web, OS keychain + SQLite on desktop, iOS Keychain / Android Keystore + SQLite on mobile, all behind a single KeyStore and Cache TypeScript interface;
  • mobile-first navigation: one active view occupies the main area at a time; sidebar holds a single tool (calculator, inspector, or order) with persistent state on switch.

Assumptions and Defaults

  • Target Go version follows go.mod of the parent module; TinyGo for WASM must support crypto/ed25519 and crypto/sha256. If TinyGo support is insufficient, fall back to standard Go GOOS=js GOARCH=wasm with a larger bundle (~2 MB).
  • The gateway exposes server-streaming gRPC. Browsers cannot speak raw gRPC; ConnectRPC support is added to the gateway so a single set of Go handlers serves native gRPC and browser clients simultaneously.
  • TypeScript-side network code uses @connectrpc/connect-web for unary calls and server-streaming push events on every platform.
  • Ed25519 private keys never leave the device. Loss of secure storage is acceptable on every platform and triggers a re-login flow.
  • Build pipeline is a single pnpm workspace at ui/; Make targets wrap TinyGo, gomobile, Wails CLI, Capacitor CLI, and Vite.
  • All file/directory names, code, comments, identifiers, and docs in ui/ are written in English. Russian appears only in i18n bundles delivered in Phase 35.
  • Pre-production migration rule from the project root applies: schema changes are inlined into the existing init schema rather than producing new migrations; clean rebuilds on every checkout.
  • The existing galaxy/client Go module is deprecated in full. New code does not import from it; this includes client/world/, which is no longer the reference algorithm for the TypeScript renderer. Existing types in pkg/model/client/ are not migrated; UI types are written from scratch in ui/core/types/ as needed.
  • The TypeScript map renderer is specified in ui/docs/renderer.md, derived from the renderer's own requirements rather than from any earlier Go code. Tile-based spatial indexing is intentionally omitted in the first iteration; PixiJS native culling and bounds-based hit testing carry the renderer until profiling proves otherwise.
  • Game math that must stay synchronised between server and client lives in pkg/calc/. The UI client never duplicates calc functions; instead a bridge layer in ui/core/calc/ wraps selected pkg/calc/ functions for the Core API. New shared math is added to pkg/calc/ first; gaps are surfaced at the start of each phase that needs them.
  • State preservation is a global rule: switching active view or sidebar tab does not reset state. State resets only on explicit user discard actions or logout.
  • History mode is a global read-only toggle that applies to every active view. The Order sidebar tab is hidden in history mode.
  • Wails v2 is the desktop baseline. At the start of Phase 31, the current state of Wails v3 is re-evaluated; if v3 has reached a stable release, the migration is folded into that phase.
  • CI uses Gitea Actions (workflow files under .gitea/workflows/, format-compatible with GitHub Actions). Linux runners cover Tier 1 tests; a macOS runner is provisioned only when Tier 2 iOS smoke is needed.
  • Synthetic-report parser parity is a global rule. A DEV-only loader on the lobby (import.meta.env.DEV) lets the developer feed the UI a JSON file that mimics a server Report, so the map and inspectors can be exercised against rich game states without playing many turns end-to-end. The JSON is produced by the Go CLI in tools/local-dev/legacy-report/, which converts legacy text reports under tools/local-dev/reports/ into the shape of pkg/model/report.Report (whatever subset the UI currently decodes). Every phase that extends the server→UI report contract — adding decoding for a new Report field in ui/frontend/src/api/game-state.ts — must, in the same PR, extend the legacy parser to populate that field, or explicitly note in the parser's README.md that the field cannot be derived from the legacy text format and is left empty in synthetic JSON. The point is to keep tools/local-dev/legacy-report/ a faithful (and type-checked, via pkg/model/report import) generator of test inputs as the UI grows; otherwise synthetic data silently lags behind the contract and visual tests stop covering the new behaviour.

Information Architecture and Navigation

The client is a single-page application with one active view at a time. Navigation is mobile-first: floating panels never overlap the map, the main area never splits into multiple visible panels on small screens. Desktop and mobile share the same model; on desktop, the sidebar sits beside the active view, on mobile it lives behind a bottom-tab bar.

View model

ActiveView ∈ {
  /login,                            (anonymous only)
  /lobby,                            (auth required)
  /games/:id/map,                    (default in-game view)
  /games/:id/table/:entity,          (entity ∈
                                      planets | ship-classes |
                                      ship-groups | fleets |
                                      sciences | races)
  /games/:id/report,
  /games/:id/battle/:battleId,
  /games/:id/mail,
  /games/:id/designer/ship-class/:id?,
  /games/:id/designer/science/:id?,
}

Switching between views happens through the header dropdown (desktop) or hamburger menu (mobile). Double-tapping a row in a table: view returns to /map with focus=<objectId>. Some views can push a transient map overlay with a back affordance (for example, ship-class designer pushes a range-preview overlay onto the map). The transient overlay clears when the user navigates to any other view.

Layout per breakpoint

Desktop (≥ 1024 px):

┌──────────────────────────────────────────────────────────┐
│ Header: race · turn N · countdown · view dropdown · ⚙    │
├────────────────────────────────────────────┬─────────────┤
│                                            │ tabs        │
│              active view                   │ ┌─────────┐ │
│             (map / table /                 │ │ Calc    │ │
│              battle / mail /               │ │ Inspect │ │
│              designer / report)            │ │ Order   │ │
│                                            │ └─────────┘ │
│                                            │ tool        │
│                                            │ content     │
│                                            │             │
└────────────────────────────────────────────┴─────────────┘

Tablet (7681024 px): same as desktop but sidebar collapses to a swipe-from-right drawer; a tab bar of three icons sits in the header right corner.

Mobile (< 768 px):

┌──────────────────────┐
│ ☰  race · turn N · ⚙ │
├──────────────────────┤
│                      │
│     active view      │
│                      │
│                      │
│                      │
├──────────────────────┤
│  ▣    🧮    📝   ☰   │
│ Map  Calc  Order More│
└──────────────────────┘

On mobile, Inspector is not a bottom tab — tapping an object on the map raises a bottom-sheet showing inspector content. The sheet swipes down to dismiss. More opens a hamburger menu that lists Mail, Battle log, Tables (planets, ship classes, ship groups, fleets, sciences, races), History, Settings, Logout.

Sidebar tools (single-tool with state preservation)

  • Calculator — independent ship/path calculator, callable from any view. Holds in-progress inputs across navigation.
  • Inspector — context-sensitive details for the currently selected map object. Empty state when nothing is selected: select an object on the map.
  • Order — the draft order being composed. Vertical list of commands, top-to-bottom. Each command shows its local-validation result while composing and its server result after submit. Order persists across page reloads and across view switches.

Map active view

PixiJS canvas with pan/zoom over the torus. A gear icon in the corner opens a popover (desktop) or bottom sheet (mobile) with category toggles:

Toggleable Default
Hyperspace groups on
Incoming groups (not necessarily enemy) on
Cargo routes on
Reach / visibility zones off
Battle and bombing markers on

Planets are always shown and cannot be hidden.

Header turn counter and history mode

The turn counter is clickable. Click expands to a turn navigator (popover desktop, bottom sheet mobile) listing recent turns with a search field for jumping to a specific turn number. Selecting a past turn enters history mode: every active view switches its data source to that turn's snapshot, the Order sidebar tab disappears, and a persistent banner reads Viewing turn N · read-only with a Return to turn current action.

Cross-cutting shell

  • Push-event toasts surface from the top of the screen for: turn ready, lobby state changes, invitations, session revoked, incoming attack.
  • A connection-state indicator in the header shows online / reconnecting / offline based on push-stream state and last successful unary call.
  • The account menu (top-right on desktop, last hamburger entry on mobile) holds Settings, Sessions, Theme, Language, Logout.

Authenticated route transitions

  • /login/lobby after successful confirm-email-code.
  • /lobby/games/:id/map when a game card is selected.
  • Any view → /login immediately on session revocation push event.
  • Designer views can push a transient overlay onto /map; the back affordance returns to the originating designer.

Per-screen behaviour (validations, exact field names, error mappings) is derived from docs/FUNCTIONAL.md sections cited in the relevant phases below. UI-specific decisions (animations, layout, microcopy) live in per-phase topic docs under ui/docs/.


Phase 1. Workspace Skeleton

Status: done.

Goal: bring up the ui/ workspace with a runnable empty SvelteKit + Vite frontend and architectural anchors.

Artifacts:

  • ui/README.md mirroring the architectural overview from this plan
  • ui/Makefile with placeholder targets for every build type (web, wasm, gomobile, desktop-{mac,win,linux}, ios, android, all)
  • ui/pnpm-workspace.yaml declaring the single-package pnpm workspace
  • ui/frontend/ Svelte 5 + SvelteKit + Vite + TypeScript project (the SvelteKit scaffold provides +layout.svelte, +page.svelte, static/, and the file-system router used by later phases)
  • ui/frontend/src/routes/+page.svelte minimal landing page rendering the app version string in the page footer; the version is read at build time by Vite define from ui/frontend/package.json
  • ui/frontend/{vitest.config.ts, tests/} minimum Vitest harness needed to run the smoke test below (vitest, jsdom, @testing-library/svelte); the rest of the test toolchain (Playwright, @testing-library/jest-dom, CI workflows) lands in Phase 2
  • ui/.gitignore covering node_modules, dist, *.wasm, build outputs for Wails and Capacitor, Playwright artefacts
  • ui/docs/ empty directory ready for per-phase topic docs

Dependencies: none.

Acceptance criteria:

  • pnpm install && pnpm dev from ui/frontend starts a dev server that serves the landing page at a free local port;
  • make lists every planned build target as a placeholder;
  • ui/README.md lists the five target platforms, the layered architecture, and points readers to per-phase topic docs under ui/docs/.

Targeted tests:

  • a single Vitest smoke test that mounts the landing component and asserts the rendered version string is non-empty.

Phase 2. Testing Infrastructure

Status: done.

Goal: install and configure the test toolchain that every later phase depends on, including Tier 1 (per-PR) and Tier 2 (release) targets.

Artifacts:

  • ui/frontend/package.json dev-dependencies (added on top of the Phase 1 minimum of vitest, jsdom, @testing-library/svelte): @testing-library/jest-dom, playwright, @playwright/test
  • ui/frontend/vitest.config.ts extended with setupFiles: ["./tests/setup.ts"] to wire @testing-library/jest-dom matchers into Vitest (the JSDOM environment itself is wired in Phase 1)
  • ui/frontend/tests/setup.ts registering jest-dom matchers
  • ui/frontend/tests/e2e/landing.spec.ts placeholder Playwright test asserting the version footer renders
  • ui/frontend/playwright.config.ts with four projects: chromium-desktop, webkit-desktop, chromium-mobile-iphone-13, chromium-mobile-pixel-5; tracing and screenshots enabled on failure; webServer: pnpm run dev on port 5173
  • .gitea/workflows/ui-test.yaml running Tier 1 on every push and PR on a Linux runner: monorepo Go service tests for backend/, gateway/, game/, and every pkg/<name>/ module (each pkg module is enumerated explicitly because they sit as independent go.work modules under a shared pkg/ directory, and ./pkg/... does not recurse across module boundaries). All Go tests run with -count=1 so the cache never masks a failing run; backend tests additionally run with -p 1 because most backend packages spawn their own Postgres testcontainer and parallel bootstraps starve each other on the runner. The integration suite stays gated behind make -C integration integration and lives outside Tier 1; the deprecated client/ Fyne client (see §74) is also excluded — its tests, code, and documentation are frozen and CI must not run them. Then pnpm install --frozen-lockfile from ui/, pnpm exec playwright install --with-deps, pnpm test, pnpm exec playwright test; Playwright reports and traces uploaded as artefacts on failure
  • .gitea/workflows/ui-release.yaml running Tier 2 on tag push (v*): same Tier 1 step set today; visual-regression and macOS-runner iOS-smoke jobs live as commented sections marked with the phase number that re-enables them (Phase 33 and Phase 32 respectively)
  • ui/docs/testing.md topic doc naming the two tiers, the tools per tier, and the rule that visual regression baselines live in ui/frontend/tests/__snapshots__/ until shifted to Argos

Dependencies: Phase 1.

Acceptance criteria:

  • a placeholder Vitest test passes locally and in CI;
  • a placeholder Playwright test passes in chromium-desktop and webkit-desktop projects locally;
  • the Gitea Actions Tier 1 workflow runs end-to-end against a clean clone of the repo on a Linux runner. Until the Gitea runner is provisioned, the workflow is exercised locally with act -W .gitea/workflows/ui-test.yaml.

Targeted tests:

  • placeholder Vitest test from Phase 1 runs in CI and passes;
  • placeholder Playwright test runs in CI on Linux runner and passes in both chromium-desktop and webkit-desktop projects;
  • intentional failure produces a Playwright trace artefact in CI.

Phase 3. Go Core: Canonical Bytes and Keypair

Status: done.

Goal: implement the canonical-bytes serializer and Ed25519 keypair management in pure Go, with bit-for-bit parity to the gateway-side implementation. No network, no UI.

Artifacts:

  • ui/core/go.mod module galaxy/core declared in the project Go workspace (go.work use and replace directives)
  • .gitea/workflows/ui-test.yaml and .gitea/workflows/ui-release.yaml extended to add ./ui/core/... to the Tier 1 / Tier 2 go test command list introduced in Phase 2
  • ui/core/canon/ canonical bytes for galaxy-request-v1, galaxy-response-v1, and galaxy-event-v1, matching docs/ARCHITECTURE.md §15 byte-for-byte. Server-only signers (Ed25519ResponseSigner, PKCS#8 PEM loaders) intentionally stay in gateway/authnui/core is verify-only on the server side
  • ui/core/keypair/ Ed25519 generate, marshal, unmarshal helpers over opaque []byte blobs; Generate accepts an injected io.Reader so the WASM build can wire in crypto.getRandomValues
  • ui/core/types/ full v1 transport-envelope structs with SigningFields() projection helpers; result-code and protocol-version constants (ProtocolVersionV1, ResultCodeOK). TraceID is part of the request envelope but deliberately excluded from the request signing input (matches §15)
  • ui/core/canon/testdata/ golden JSON test vectors for the three Phase-3 message types plus one response and one event
  • ui/core/README.md documenting the public API and the network-free / storage-free / no-x509 / no-PEM / no-os invariant
  • gateway/authn/parity_with_ui_core_test.go (cross-module test) proving canonical-bytes parity and bidirectional sign/verify acceptance between gateway/authn and galaxy/core. The test adds require galaxy/core to gateway/go.mod (test-only in practice — gateway production binary does not link ui/core)

Dependencies: Phase 1.

Acceptance criteria:

  • canonical-bytes output matches gateway-side output byte-for-byte for the three Phase-3 message types (user.account.get, lobby.my.games.list, user.games.command);
  • a request signed by ui/core is accepted by the gateway's own verifier in a unit test (TestParityRequestSignedByUICoreAcceptedByGateway);
  • a response signed by gateway/authn's Ed25519ResponseSigner is accepted by ui/core's verifier (TestParityResponseSignedByGatewayAcceptedByUICore); the same applies to gateway-signed events;
  • tampered payload_hash, mismatched request_id, mismatched timestamp_ms, and invalid signature length are rejected with stable error codes from ui/core/canon. Server-side freshness enforcement (the symmetric ±5 minutes around server time) stays in gateway/internal/grpcapi/freshness_replay.go and is not duplicated in ui/core.

Targeted tests:

  • canonical-bytes equality tests on golden JSON fixtures (testdata/) for every envelope kind;
  • round-trip sign-then-verify across all three envelope kinds;
  • negative tests: tampered payload_hash, mismatched request_id, mismatched timestamp_ms, invalid signature lengths (too short, too long, empty), bit-flipped signature, wrong public key, malformed base64 public key;
  • gateway/authn cross-module parity tests as listed under Artifacts.

Phase 4. ConnectRPC Support in Gateway

Status: done. Cross-service phase — work happened in gateway/ and integration/, not ui/.

Goal: enable browsers to call the gateway's authenticated edge surface through ConnectRPC, without keeping a separate gRPC server bootstrap alive purely for test clients.

Decision (taken with the project owner before implementation): the existing native-gRPC grpc.NewServer bootstrap was replaced with a single connectrpc.com/connect HTTP/h2c listener, since Connect-Go natively serves the Connect, gRPC, and gRPC-Web protocols on the same port. No production gRPC clients existed to preserve. The package gateway/internal/grpcapi keeps its name for diff-size reasons and documents the historical labelling in its package doc.

Artifacts (delivered):

  • gateway/buf.gen.yaml extended with buf.build/connectrpc/go, generating gateway/proto/galaxy/gateway/v1/gatewayv1connect/edge_gateway.connect.go
  • gateway/internal/grpcapi/server.go rewritten around http.Server
    • h2c.NewHandler + gatewayv1connect.NewEdgeGatewayHandler
  • new gateway/internal/grpcapi/connect_handler.go adapting the existing gatewayv1.EdgeGatewayServer decorator stack to the Connect handler interface, including a grpc.ServerStreamingServer shim around *connect.ServerStream[GatewayEvent] and a gRPC status.Error*connect.Error translation helper
  • new gateway/internal/grpcapi/connect_observability.go Connect interceptor recording the same metric and structured-log shape the gRPC interceptors emitted; the rate-limit decorator now reads peer IP from a context value populated by the interceptor instead of peer.FromContext
  • updated gateway/README.md (Transport Matrix + "Authenticated Edge Surface"), gateway/docs/runtime.md, gateway/docs/flows.md, gateway/docs/runbook.md, and docs/ARCHITECTURE.md §15
  • migrated tests: gateway/internal/grpcapi/server_test.go, test_fixtures_test.go, and every *_integration_test.go in that package now drive a gatewayv1connect.EdgeGatewayClient over HTTP/2 cleartext loopback
  • migrated harness: integration/testenv/grpc_client.goconnect_client.go. SignedGatewayClient keeps the same public shape (Execute, SubscribeEvents, Close) but speaks Connect internally; Is* helpers now use connect.CodeOf

Dependencies: Phase 3 (canonical bytes are needed for the fixture-level signing the migrated tests use).

Acceptance criteria (met):

  • unary Connect calls from outside the gateway process succeed end-to-end against the authenticated surface — verified by the migrated grpcapi/server_test.go and command_routing_integration_test.go scenarios driving the Connect client over loopback h2c;
  • server-streaming SubscribeEvents works over Connect with the signed gateway.server_time bootstrap event delivered first — verified by TestSubscribeEventsValidEnvelopeSendsBootstrapEventAndWaitsForCancellation;
  • the unified listener still natively accepts gRPC and gRPC-Web framing for any future native client (Connect-Go's documented multi-protocol support);
  • the Connect handler shares the same upstream business code as the unified listener — there is exactly one decorator stack (grpcapi.NewServers.service).

Targeted tests (delivered):

  • Connect unary integration tests in gateway/internal/grpcapi/ exercising the full envelope → signature → freshness/replay → rate-limit → routing pipeline through the new Connect transport;
  • Connect streaming integration tests asserting bootstrap-event delivery, replay rejection on stream open, and shutdown closure;
  • the existing gateway test suite (go test ./gateway/...) stays green.

Decision deviation note: the planned standalone gateway/internal/grpcapi/connect_server_test.go was not added as a separate file because the migrated *_test.go files in the same package already cover unary happy + streaming bootstrap + protocol- version reject through the Connect client. A duplicate file would not add coverage. Future contributors looking for "the Connect tests" can read any file in gateway/internal/grpcapi/ — they all use the Connect client now.

Phase 5. WASM Build, WasmCore Adapter, GalaxyClient Skeleton

Status: done.

Goal: package ui/core as a WASM module, expose it to TypeScript through a typed adapter, and prove the WASM-side crypto pipeline at unit level. End-to-end Connect round-trip is validated in Phase 7 (authenticated calls only become possible after login).

Decisions taken with the project owner before implementation:

  1. TinyGo as primary toolchain. core.wasm lands at 903 KB — well under the 1 MB acceptance bar. The GOOS=js GOARCH=wasm fallback path stays documented in ui/docs/wasm-toolchain.md.
  2. Core.signRequest returns canonical bytes only. No private key inside WASM; Phase 6 plugs WebCrypto's non-exportable keys at the orchestration layer. GalaxyClient takes a pluggable Signer so Phase 5 tests pass a fixture-key signer and Phase 6 swaps in WebCrypto without touching the orchestration.
  3. TS codegen runs locally, not against buf.build BSR. A new ui/buf.gen.yaml invokes frontend/node_modules/.bin/protoc-gen-es (added as a devDependency). This sidesteps BSR rate limiting and removes the network dependency from the codegen step.
  4. Field naming is camelCase end-to-end. Both the TS Core interface and the Go bridge in ui/wasm/main.go use camelCase field names; there is no snake-case translation layer.

Artifacts (delivered):

  • ui/wasm/main.go TinyGo entry point on globalThis.galaxyCore with four functions: signRequest, verifyResponse, verifyEvent, verifyPayloadHash.
  • ui/Makefile wasm and ts-protos targets.
  • ui/buf.gen.yaml with the local Protobuf-ES plugin (single plugin — protobuf-es v2 emits both message types and Connect service descriptors in one file).
  • ui/frontend/src/platform/core/index.ts — typed Core interface plus a loadCore() resolver (Phase 5 ships only the WASM adapter).
  • ui/frontend/src/platform/core/wasm.tsWasmCore adapter for browsers; the JSDOM test path lives next to it in ui/frontend/tests/setup-wasm.ts.
  • ui/frontend/src/api/connect.ts — typed Connect-Web transport + EdgeGatewayClient factory.
  • ui/frontend/src/api/galaxy-client.tsGalaxyClient skeleton with injected Signer and Sha256 dependencies.
  • ui/frontend/src/proto/galaxy/gateway/v1/edge_gateway_pb.ts (generated) and ui/frontend/src/proto/buf/validate/validate_pb.ts (generated as a transitive import via --include-imports).
  • ui/frontend/static/core.wasm (903 KB) + wasm_exec.js (TinyGo shim).
  • Three Vitest files exercising the bridge end-to-end: tests/wasm-core.test.ts (each Core method, including a sanity signRequest check that the canonical bytes start with the v1 domain marker), tests/wasm-core-canon-parity.test.ts (byte-for- byte parity against three request fixtures plus the response and event signature fixtures from ui/core/canon/testdata/), and tests/galaxy-client.test.ts (orchestration through a mock Core and createRouterTransport from @connectrpc/connect).
  • Topic doc ui/docs/wasm-toolchain.md.
  • ui/README.md repository-layout block.

Dependencies: Phases 2, 3, 4.

Acceptance criteria (met):

  • make wasm produces core.wasm deterministically under 1 MB (903 KB measured);
  • WasmCore.signRequest produces canonical bytes byte-for-byte identical to the gateway-side fixtures for three message types (request_user_account_get, request_user_games_command, request_lobby_my_games_list);
  • WasmCore exposes the same Core TypeScript types future WailsCore and CapacitorCore adapters will satisfy.

Targeted tests (delivered):

  • Vitest unit tests for WasmCore calling each public method with the WASM module loaded in JSDOM via tests/setup-wasm.ts;
  • Vitest unit tests for GalaxyClient using a mock Core and the in-memory createRouterTransport;
  • Vitest tests asserting WasmCore.signRequest output matches the committed gateway fixtures byte-for-byte for the three request message types listed above.

Decision deviation note: the initial plan listed protoc-gen-es and protoc-gen-connect-es as separate plugins. Protobuf-ES v2 generates service descriptors in the _pb.ts file directly, so a single @bufbuild/protoc-gen-es plugin is sufficient — @connectrpc/connect v2 consumes those descriptors via createClient. The connect-es plugin is a v1-only path and is intentionally not used here.

Phase 6. Storage Layer (Web)

Status: done.

Goal: persist the device session keypair securely in browsers, and provide a generic local cache for game state. Defines the TypeScript-side KeyStore and Cache interfaces that desktop and mobile adapters will satisfy in later phases.

Decisions taken with the project owner before implementation:

  1. Phase 6 stops at the storage boundary. The PLAN previously listed a Playwright check that the gateway accepts a signed request. Public-key registration happens through the email-code confirm endpoint, which Phase 7 wires; building a temporary test-only registration path was rejected as throw-away scaffolding. The live-gateway round-trip is therefore covered by Phase 7's existing acceptance bullet "the first authenticated Connect call after login … succeeds end-to-end" instead, which cannot pass unless the Phase 6 keystore persists and signs correctly.
  2. Modern-browser baseline, no JS Ed25519 fallback. WebCrypto Ed25519 lands in Chrome ≥137, Firefox ≥130, Safari ≥17.4. Phase 7 surfaces a clear "browser not supported" message for older engines instead of carrying a parallel @noble/ed25519 code path. The full matrix and rationale live in ui/docs/storage.md.

Artifacts:

  • ui/frontend/src/platform/store/index.ts — public KeyStore, Cache, DeviceKeypair interfaces and the loadStore() resolver, with no web-specific imports in any public signature
  • ui/frontend/src/platform/store/idb.ts — shared galaxy-ui IndexedDB connection (typed via idb's DBSchema) used by both the keystore and the cache
  • ui/frontend/src/platform/store/idb-cache.ts — IndexedDB-backed Cache keyed by compound [namespace, key]
  • ui/frontend/src/platform/store/webcrypto-keystore.ts — WebCrypto non-exportable Ed25519 key generation, structured-cloned through IDB
  • ui/frontend/src/platform/store/web.ts — the loadWebStore factory wired into loadStore
  • ui/frontend/src/api/session.ts thin layer with loadDeviceSession, setDeviceSessionId, clearDeviceSession
  • ui/frontend/src/routes/__debug/store/+page.svelte (+ +page.ts with prerender = false; ssr = false;) — dev-only debug surface the Phase 6 Playwright spec drives through window.__galaxyDebug
  • topic doc ui/docs/storage.md describing the browser baseline, IDB schema, keystore lifecycle, and cache contract

Dependencies: Phase 5.

Acceptance criteria:

  • a freshly generated keypair survives page reloads (the loaded handle still produces signatures verifiable under the persisted public key); the live-gateway round-trip is covered by Phase 7;
  • clearing site data removes the keypair, and the next request triggers a re-login flow;
  • KeyStore and Cache interfaces have full TypeScript types and zero web-specific imports in their public signatures.

Targeted tests:

  • Vitest unit tests for IDBCache with fake-indexeddb (round-trip, namespace isolation, delete, clear-with-namespace, full clear);
  • Vitest unit tests for WebCryptoKeyStore (generate, load, signature determinism under Node WebCrypto, signature verifiability after a simulated reload, third-party verify of the public key, clear, fresh-keypair-after-clear);
  • Playwright e2e (storage-keypair-persistence.spec.ts, all four projects): generate keypair, sign canonical bytes, capture the signature, reload, assert the previous signature still verifies under the public key (works on every engine in the baseline including non-deterministic WebKit), and that clearDeviceSession forces a fresh keypair on next load.

Phase 7. Auth Flow UI

Status: done.

Goal: implement the full email-code login flow with device session registration and post-login redirect to a placeholder lobby.

Decisions taken with the project owner before implementation:

  1. Playwright e2e against a mocked gateway. page.route(...) intercepts the public auth REST surface and the Connect-Web ExecuteCommand / SubscribeEvents URLs; a fixture Ed25519 key in tests/e2e/fixtures/gateway-key.ts signs the forged responses so GalaxyClient.verifyResponse accepts them under the matching public key the dev server picks up via VITE_GATEWAY_RESPONSE_PUBLIC_KEY. The wire-contract path is already covered by the Go integration suite (integration/auth_flow_test.go).
  2. Build-time gateway response public key delivery. The browser reads VITE_GATEWAY_RESPONSE_PUBLIC_KEY (standard base64 of the raw 32-byte key) on module load. A future phase may switch to a /api/v1/public/well-known/... endpoint when prod distribution is wired up; Phase 7 stops at the env-var.
  3. Minimal SubscribeEvents-based revocation watcher. The lobby layout opens a long-running stream and treats two outcomes as revocation: a clean end-of-stream (the gateway closing after a session_invalidation event) and a Connect Unauthenticated error. Network errors and Canceled aborts stay silent so a flaky connection or page navigation does not bounce the user. The per-event dispatch path lands in Phase 24.
  4. Browser-not-supported blocker. The root layout runs a one-time crypto.subtle.generateKey({name:"Ed25519"}, ...) probe on boot and renders a blocker page when the probe rejects. This closes Phase 6's "no JS Ed25519 fallback" follow-up.

Artifacts (delivered):

  • ui/frontend/src/routes/login/+page.svelte (+ +page.ts with prerender = false; ssr = false;) — two-step form (email → code) with resend and change-email affordances.
  • ui/frontend/src/routes/lobby/+page.svelte (+ +page.ts) — placeholder lobby that issues the first authenticated user.account.get through GalaxyClient and surfaces the decoded display name. The placeholder used JSON.parse(TextDecoder) to read the response payload; that worked with the mocked Playwright setup but did not match the gateway's FlatBuffers wire format. Phase 8 introduces the TS-side FlatBuffers integration and rewrites this page to decode AccountResponse via the generated bindings, so the greeting now also works against a real local stack.
  • ui/frontend/src/routes/+layout.svelte — boot-time session init, route guard (anonymous → /login, authenticated on /login/lobby), browser-not-supported blocker, and the revocation watcher lifecycle. +layout.ts puts the whole tree into SPA mode (ssr = false; prerender = false;).
  • ui/frontend/src/api/auth.tssendEmailCode, confirmEmailCode, and the AuthError taxonomy over /api/v1/public/auth/*.
  • ui/frontend/src/lib/env.tsGATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY (decoded once on module load).
  • ui/frontend/src/lib/session-store.svelte.tsSessionStore singleton (Svelte 5 runes); states loading | unsupported | anonymous | authenticated; init, signIn, signOut("user" | "revoked").
  • ui/frontend/src/lib/revocation-watcher.ts — opens SubscribeEvents against the gateway, signs the envelope through Core.signRequest, treats clean stream end / Unauthenticated as revocation.
  • ui/frontend/.env.exampleVITE_GATEWAY_BASE_URL, VITE_GATEWAY_RESPONSE_PUBLIC_KEY.
  • Topic doc ui/docs/auth-flow.md; cross-references from ui/docs/storage.md and ui/README.md.
  • Vitest: tests/auth-api.test.ts, tests/session-store.test.ts, tests/login-page.test.ts.
  • Playwright: tests/e2e/auth-flow.spec.ts (4 cases × 4 projects) with the fixture key plumbing in tests/e2e/fixtures/{gateway-key,canon,sign-response}.ts.
  • Pre-existing tests/e2e/landing.spec.ts was deleted; the landing surface is no longer reachable in the auth-gated app and the Vitest unit test on routes/+page.svelte retains the version footer assertion.

Dependencies: Phase 6.

Acceptance criteria (met):

  • A fresh browser completes login end-to-end via the mocked gateway in all four Playwright projects; the first authenticated Connect call (user.account.get) succeeds end-to-end through WasmCoreGalaxyClient → ConnectRPC and the response signature is verified under VITE_GATEWAY_RESPONSE_PUBLIC_KEY. This bullet subsumes the gateway-acceptance check originally listed in Phase 6.
  • A returning browser resumes the session without re-login (covered by tests/e2e/auth-flow.spec.ts::"returning user lands on the lobby without re-login").
  • Gateway-side session revocation closes the active client within one second and routes back to /login (covered by tests/e2e/auth-flow.spec.ts::"server-side revocation closes the active client within one second").

Targeted tests (delivered):

  • Vitest component tests for the login form with mocked auth.ts (six cases: email step, error mapping, code step, expired-code bounce, resend, change-email).
  • Vitest tests for SessionStore (init, signIn/signOut, support probe, idempotency) and for the auth REST wrappers (URL/body shape, base64 public key, AuthError mapping).
  • Playwright e2e suite (auth-flow.spec.ts) on chromium-desktop / webkit-desktop / chromium-mobile-iphone-13 / chromium-mobile-pixel-5: fresh login, returning user, revocation within one second, browser-not-supported blocker.

Phase 8. Lobby UI

Status: done.

Goal: replace the placeholder lobby with a working list of games allowing the user to view membership, see and act on invitations and applications, submit applications to public games, and create new private games. The phase also introduces the TS-side FlatBuffers codec the rest of the client relies on for authenticated payloads.

Decisions taken with the project owner before implementation:

  1. Cross-stack catalog extension. Phase 8 expands the lobby command catalog beyond lobby.my.games.list and lobby.game.open-enrollment (the only routes shipped before this phase). Seven new authenticated message types now flow through gateway/internal/backendclient/lobby_commands.go: lobby.public.games.list, lobby.my.applications.list, lobby.my.invites.list, lobby.game.create, lobby.application.submit, lobby.invite.redeem, lobby.invite.decline. Each carries its FlatBuffers schema in pkg/schema/fbs/lobby.fbs, its Go request/response struct in pkg/model/lobby/lobby.go, and its transcoder pair in pkg/transcoder/lobby.go.
  2. My applications projection. FUNCTIONAL.md §4.5 lists three "my" projections (games, applications, invites). The original plan text omitted applications; the lobby now renders a fourth "my applications" section so the user sees the pending status of any application they submit.
  3. Submit-application UX. Per FUNCTIONAL.md §4.2, joining a public game means submitting an application that lands in the pending state until the owner approves. The button label is Submit application, the inline race-name form lives on the public-game card itself (no overlay/modal infrastructure yet — that lands later when the in-game shell does), and a successful submit refreshes the applications section so the user sees the pending entry immediately.
  4. TS-side FlatBuffers integration. The placeholder lobby in Phase 7 used JSON.parse(TextDecoder) to read the user.account.get payload; that was a mismatch with the gateway's FlatBuffers transcoder and only worked under mocked tests. Phase 8 adds a flatbuffers runtime dep to ui/frontend/package.json, a fbs-ts Make target in ui/Makefile that drives flatc --ts, and checks the generated bindings into ui/frontend/src/proto/galaxy/fbs/{lobby,user}/. Phase 7's user.account.get decode is rewritten to use those bindings as part of this phase, fixing the wire-format gap.
  5. Create-game form scope. The form keeps game_name, description, turn_schedule (5-field cron), and enrollment_ends_at always visible; the rest (min_players, max_players, start_gap_hours, start_gap_players, target_engine_version) sit behind a <details> "Advanced" toggle with TS-side defaults (2 / 8 / 24 / 2 / v1). The gateway forces visibility to private regardless of input — public games come exclusively through the admin surface per FUNCTIONAL.md §3.3.

Artifacts (delivered):

  • ui/frontend/src/routes/lobby/+page.svelte — full lobby landing page. Header preserves the Phase 7 device-session-id and greeting affordances; below it five sections render in a single mobile-first column: a top-level "create new game" action (§3.3), my games cards routing to /games/:id/map (placeholder until Phase 10, §4.5), pending invitations cards with Accept / Decline (§4.3), my applications cards with localised pending / approved / rejected status (§4.5), and public games cards with an inline race-name form behind a Submit application button (§4.2). Convention follows the Phase 7 login page — single max-width: 32rem cap, no @media queries.
  • ui/frontend/src/routes/lobby/create/+page.svelte (+ +page.ts with ssr = false; prerender = false;) — create-game form with always-visible name / description / turn-schedule / enrollment-end, Advanced fields under <details> for the rest, and TS-side defaults for the advanced inputs.
  • ui/frontend/src/api/lobby.ts — typed wrappers around GalaxyClient.executeCommand for all eight lobby commands plus a LobbyError class that surfaces canonical lobby error codes (invalid_request, subject_not_found, forbidden, conflict, internal_error).
  • ui/frontend/src/api/galaxy-client.tsexecuteCommand now returns { resultCode, payloadBytes }; lobby.ts uses the result-code branch to throw LobbyError.
  • pkg/model/lobby/lobby.go — seven new message-type constants and matching request/response structs.
  • pkg/schema/fbs/lobby.fbsPublicGamesListRequest, PublicGamesListResponse, ApplicationSummary, MyApplicationsListRequest, MyApplicationsListResponse, InviteSummary, MyInvitesListRequest, MyInvitesListResponse, GameCreateRequest, GameCreateResponse, ApplicationSubmitRequest, ApplicationSubmitResponse, InviteRedeemRequest, InviteRedeemResponse, InviteDeclineRequest, InviteDeclineResponse tables. Reused GameSummary for GameCreateResponse.game and MyGamesListResponse.
  • pkg/transcoder/lobby.go — encode/decode pairs for all new types plus shared helpers encodeApplicationSummary, decodeApplicationSummary, encodeInviteSummary, decodeInviteSummary, unixMilliFromOptional. Reuses encodeGameSummary / decodeGameSummary from before.
  • gateway/internal/backendclient/lobby_commands.go — switch cases for the seven new message types and the per-command REST helpers (executeLobbyPublicGames, executeLobbyMyApplications, executeLobbyMyInvites, executeLobbyGameCreate, executeLobbyApplicationSubmit, executeLobbyInviteRedeem, executeLobbyInviteDecline); the JSON wire types from backend's user-lobby handlers are mirrored locally for non-strict decoding.
  • gateway/internal/backendclient/routes.go — the new message types are wired into LobbyRoutes.
  • ui/frontend/src/proto/galaxy/fbs/{lobby,user}/... — generated TS FlatBuffers bindings (regenerated from pkg/schema/fbs/*.fbs via the fbs-ts Make target, checked into the repo like the Connect bindings).
  • ui/Makefile — new fbs-ts target.
  • ui/frontend/package.jsonflatbuffers runtime dep.
  • ui/frontend/src/lib/i18n/locales/{en,ru}.ts — full lobby.* catalogue covering sections, empty states, application form, create form, status badges, and lobby error code translations.
  • Topic doc ui/docs/lobby.md.
  • Vitest: tests/lobby-fbs.test.ts (binding round-trips), tests/lobby-api.test.ts (wrapper unit tests over a stub client), tests/lobby-page.test.ts, tests/lobby-create.test.ts.
  • Playwright: tests/e2e/lobby-flow.spec.ts (3 cases × 4 projects) with tests/e2e/fixtures/lobby-fbs.ts building forged FlatBuffers payloads through the same generated bindings the production code uses. The Phase 7 spec was migrated to the same fixture so user.account.get is now FlatBuffers end-to-end.
  • Phase 7 e2e specs were updated to click → fill the readonly inputs (the readonly attribute is the documented Safari autofill-suppression workaround; fill checks editability before Playwright's own focus call, so a deliberate click is required).
  • pkg/transcoder/lobby_test.go — round-trip and corruption-recover cases for every new pair.
  • gateway/internal/backendclient/lobby_commands_test.go — per-RPC success / 4xx / 5xx / 503 cases against an httptest.Server.

Dependencies: Phase 7.

Acceptance criteria (met):

  • the user can list, create, submit-application, and accept an invitation end-to-end against a local stack — the gateway routes every required envelope, and the FlatBuffers wire path is the same in production and in mocked tests;
  • mobile viewport renders without horizontal scroll on chromium-mobile-iphone-13 and chromium-mobile-pixel-5;
  • empty states are explicit (no games yet, no invitations, no applications, no public games).

Targeted tests (delivered):

  • Vitest binding round-trips for every lobby request/response;
  • Vitest API wrapper coverage for every wrapper plus the LobbyError surfacing path;
  • Vitest component tests for the lobby page (every section, empty states, race-name validation, Accept / Decline) and the create-game form (validation, submission, cancel);
  • Playwright e2e (3 flows × 4 projects): full create-game flow to My Games, submit-application to My Applications pending, accept invitation removes card and adds the game to My Games. Phase 7 auth flow now also runs over the FlatBuffers wire.

Phase 9. Map Renderer with Fixture Data

Status: done.

Goal: stand up the PixiJS map renderer with pan/zoom, primitive drawing, torus wrap behaviour and bounded-plane (no-wrap) mode against a fixture dataset, before wiring it to live game state. Both modes are first-class — no-wrap is a real user-selectable view option, not a deferred nicety.

Artifacts:

  • ui/frontend/src/map/world.ts data model (Primitive = Point | Circle | Line, Style, single-theme bindings) over plain float64 world coordinates; the renderer is a vector renderer and Pixi's transform pipeline owns the world→screen mapping
  • ui/frontend/src/map/math.ts geometry primitives: torusShortestDelta, distSqPointToSegment, clamp, and screenToWorld/worldToScreen round-trip transforms
  • ui/frontend/src/map/render.ts PixiJS v8 scene graph driven by pixi-viewport@^6 for pan/zoom/pinch with WebGPU/WebGL backend selection via Application.init({ preference }); torus wrap is rendered through nine container copies at (±W, 0) × (±H, 0)
  • ui/frontend/src/map/hit-test.ts brute-force hit-test pass over the world primitives with [-priority, distSq, kindOrder, id] ordering and torus-shortest distance in 'torus' mode
  • ui/frontend/src/map/no-wrap.ts camera clamp helpers (clampCameraNoWrap, minScaleNoWrap, pivotZoom) for bounded plane mode; pixi-viewport's clamp/clampZoom plugins are used at the renderer level with a centring hook on 'moved' so the viewport-larger-than-world case stays centred
  • ui/frontend/src/map/fixtures.ts deterministic 1000-primitive sample world used by the playground and by manual perf checks
  • ui/frontend/src/routes/__debug/map/+page.svelte development page rendering the fixture world with a mode switch between torus and no-wrap, plus a window.__galaxyMap debug surface for tests
  • topic doc ui/docs/renderer.md specifying the data model, hit-test math, torus copy rule, no-wrap camera semantics, and the deprecation status of galaxy/client

Dependencies: Phase 1.

Acceptance criteria:

  • a 1000-primitive fixture world pans and zooms on a mid-range laptop with WebGPU, falling back to WebGL when WebGPU is unavailable, in both torus and no-wrap modes; the 60 fps target is documented in ui/docs/renderer.md as a manual gate, not a CI assertion (CI runners vary too much in CPU/GPU);
  • hit testing returns the expected primitive on a hand-built fixture set covering wrap copies, line slop, ring vs filled circles, ordering, and zoom-dependent slop;
  • torus wrap renders all relevant corner copies correctly across the viewport edges;
  • no-wrap mode clamps the camera at world boundaries; pivot zoom keeps the world point under the cursor stable; viewport never becomes larger than the world.

Targeted tests:

  • Vitest unit tests for geometry primitives, torus-shortest distance, no-wrap clamps, pivot-zoom invariants (tests/map-math.test.ts, tests/map-no-wrap.test.ts);
  • Vitest hit-test cases for every rule in the algorithm spec (tests/map-hit-test.test.ts, ~22 cases);
  • Playwright visual smoke test of the playground page across all four configured projects (chromium-desktop forces WebGPU, webkit-desktop forces WebGL, mobile projects auto-pick), exercising mode switch torus → no-wrap and back, wheel zoom, no-wrap clamp after a drag past the edge, and live hit-test plumbing (tests/e2e/playground-map.spec.ts).

Phase 10. In-Game Shell with View-Replacement Skeleton

Status: done.

Goal: assemble the in-game layout shell (header, sidebar, main area) with empty placeholder content for every view, so navigation works end-to-end before any data is wired.

Decisions taken with the project owner during implementation:

  1. Routing — file-system based, no extra dispatcher. The "view router" called out in the original artifact list is implemented as SvelteKit's file-system routes plus thin +page.svelte wrappers that mount the matching lib/active-view/<name>.svelte stub. No separate dispatch component lives in the codebase; each route file is a two-line wrapper.
  2. Optional designer ID segments. Both designer URLs ship as [[id]] optional segments (designer/ship-class/[[classId]]/, designer/science/[[scienceId]]/) so Phase 18 / 21 can read the param without a routing migration. Phase 10 stubs ignore the param.
  3. Battle URL — optional id. battle/[[battleId]]/ accepts both the list URL (/battle) and a specific battle URL (/battle/<id>). Phase 27 keeps the optional segment and switches behaviour based on presence.
  4. Tablet sidebar — click toggle, not swipe. The 7681024 px tablet sidebar slides in from a header-button click rather than the IA section's swipe-from-right gesture. The structural breakpoint switch satisfies Phase 10's acceptance criterion; Phase 35 polish lands the swipe gesture.
  5. Mobile tool overlay — mobileTool state, gated by URL. The mobile bottom-tabs Calc / Order navigate to /map and set a layout-owned mobileTool rune. The layout's derived effectiveTool only honours the rune when the URL is /map, so navigating to any other view via the More drawer or the header view-menu naturally drops the overlay. The desktop sidebar separately accepts a ?sidebar=calc|inspector|order URL param that seeds the initial tab on first mount, used by later phases that want to land directly on a particular tool.
  6. Sidebar tool filenames — *-tab.svelte. Phase 12 / 13 / 30 each name their final implementation (order-tab.svelte, inspector-tab.svelte, calculator-tab.svelte). The Phase 10 stubs ship with those names so later phases replace the content in place without renaming.
  7. Race-name and turn-counter placeholders. The header race name is the static race ? string from i18n, mirroring the spec's static turn ? placeholder. Phase 11 wires both from user.games.report data through lib/header/turn-counter.svelte.
  8. Auth gate inherited. The root +layout.svelte already redirects anonymous → /login; the in-game shell needs no extra guard. Phase 10 verified this by booting the e2e shell spec via __galaxyDebug.setDeviceSessionId and observing the post-session.init authenticated status.
  9. More drawer mirrors the view-menu. The mobile bottom-tabs "More" drawer renders the same seven destinations as the header view-menu. The IA section's narrower More list (Mail, Battle log, Tables, History, Settings, Logout) is the polish target for Phase 35 once History exists; Phase 10 keeps a single destination list to avoid drift.

Artifacts (delivered):

  • ui/frontend/src/routes/games/[id]/+layout.svelte — chrome layout (header, conditional sidebar, active-view slot, mobile bottom-tabs, mobileTool gate, sidebarOpen toggle)
  • ui/frontend/src/routes/games/[id]/+layout.tsssr=false; prerender=false; mirroring the root SPA flags
  • ui/frontend/src/routes/games/[id]/+page.ts — redirects /games/:id/games/:id/map
  • ui/frontend/src/routes/games/[id]/{map, table/[entity], report, battle/[[battleId]], mail, designer/ship-class/[[classId]], designer/science/[[scienceId]]}/+page.svelte — thin route wrappers that mount the matching active-view stub
  • ui/frontend/src/lib/header/{header, turn-counter, view-menu, account-menu}.svelte — header composition with race placeholder, turn counter (static ?), view-menu (dropdown desktop / hamburger mobile), and account menu (Settings / Sessions / Theme stub buttons; Language driven by i18n.setLocale; Logout calls session.signOut("user"))
  • ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab, inspector-tab, order-tab, bottom-tabs}.svelte — three-tab sidebar with inspector default and ?sidebar= URL seed; mobile-only bottom-tabs with [Map, Calc, Order, More] plus a More drawer duplicating the view-menu destinations
  • ui/frontend/src/lib/sidebar/types.ts — shared SidebarTab and MobileTool types
  • ui/frontend/src/lib/active-view/{map, table, report, battle, mail, designer-ship-class, designer-science}.svelte — Phase 10 stubs rendering localised titles plus coming soon copy with stable testids that later phases replace
  • ui/frontend/src/lib/i18n/locales/{en,ru}.ts — full game.shell.*, game.view.*, game.sidebar.*, game.bottom_tabs.* catalogue
  • Topic doc ui/docs/navigation.md
  • Vitest: tests/game-shell-{header,sidebar,stubs}.test.ts
  • Playwright: tests/e2e/game-shell.spec.ts (7 cases × 4 projects; mobile-only and viewport-switch cases conditionally skipped on non-matching projects)

Dependencies: Phase 8.

Acceptance criteria (met):

  • entering /games/:id/map from the lobby renders the shell with all navigation chrome;
  • header dropdown switches to every other view; mobile hamburger does the same;
  • sidebar tabs preserve their stub state across switches;
  • the responsive layout matches the breakpoint diagrams in Information Architecture and Navigation (with the swipe gesture deferred to Phase 35).

Targeted tests (delivered):

  • Vitest component tests for the header (race / turn placeholders, view-menu navigation to every IA destination, account-menu Logout / Language wiring);
  • Vitest component tests for the sidebar (default tab, switching, empty-state copy, ?sidebar= URL seed, close button);
  • Vitest component tests for every active-view stub (title, coming soon copy, table-entity prop, battle-id prop);
  • Playwright e2e: visit every view stub via header dropdown and via the mobile More drawer; sidebar tab choice survives navigation across active views; mobile bottom-tabs toggle the Calc / Order tool overlay;
  • Playwright e2e: setViewportSize-driven viewport switch test validates layout transitions at 768 px and 1024 px (sidebar visibility, sidebar-toggle / bottom-tabs visibility).

Verified on local-ci run 3 (success, fc371c7).

Phase 11. Map Wired to Live Game State

Status: done.

Goal: replace the map fixture with real planet data fetched from the gateway for the selected game; planets only, read-only.

Decisions taken with the project owner during implementation:

  1. current_turn on GameSummary. The user-facing lobby.my.games.list did not expose the runtime's current turn number, but the in-game shell needs it to fetch the matching user.games.report. Phase 11 extends GameSummary with a new current_turn:int32 field (FB schema, Go transcoder + model, backend gameSummaryWire, gateway decodeGameSummary*, backend/openapi.yaml, TS bindings, api/lobby.ts). The data was already tracked in the runtime projection (backend/internal/lobby/types.go RuntimeSnapshot.CurrentTurn); exposing it is purely a wire change. Two alternatives were rejected: a brand-new user.games.state message (full wire-flow for a one-field response) and hard-coding turn=0 (works for the dev sandbox, but renders the initial state for any game past turn zero). The decision crosses Phase 8's already-shipped catalogue per the project's "decisions baked back into the live plan" rule.
  2. Per-game state store with context. A GameStateStore lives in lib/game-state.svelte.ts; the in-game shell layout instantiates one per game and exposes it through Svelte context under GAME_STATE_CONTEXT_KEY. Header turn counter, map view, and (in later phases) inspector tabs all consume the same instance. A new instance is created on layout remount (game id change), so each game gets a fresh snapshot.
  3. Lobby lookup for current turn. The store does not assume the caller passed current_turn through navigation state. On setGame, it calls lobby.my.games.list itself, finds the game record, reads current_turn, and then calls user.games.report. A direct deep link to /games/:id/map for a game the user is not a member of flips the store to error with a not in your list message.
  4. Refresh on tab focus. The store installs a visibilitychange listener that calls refresh() when the document becomes visible and the store is ready. The map view's mount effect skips a re-render when the new snapshot's turn matches the previously-mounted turn (and the wrap mode is unchanged), so a no-op refresh does not flicker the canvas.
  5. Wrap-mode preference. Cache namespace game-prefs, key <gameId>/wrap-mode, values torus (default) / no-wrap. Phase 11 reads through wrapMode; setWrapMode writes back. Phase 29 wires the toggle UI on top of these primitives.
  6. State binding. map/state-binding.ts::reportToWorld emits one Point primitive per planet across all four kinds (local / other / uninhabited / unidentified) with distinct fill colours and point radii. Each primitive's id reuses the engine planet number so a hit-test result resolves directly to a planet without an extra lookup table. Zero-planet reports yield a well-formed empty world; the World constructor's positivity check is guarded by a 1×1 fallback for the malformed-report edge case.
  7. Renderer remount on snapshot change. The map view disposes and recreates the renderer when the report's turn changes (and short-circuits when it does not). This is wasteful for the tab-focus refresh path, but the renderer's external RendererHandle does not yet expose a setWorld API and Phase 11's per-game planet count is small enough that the remount cost (a few hundred ms) is acceptable. A future phase that adds high-frequency updates (Phase 24 push events, Phase 34 multi- turn projection overlays) will extract a replaceWorld method.
  8. e2e bootstrap reuses __galaxyDebug. The Phase 10 pattern of seeding the device session through /__debug/store carries over; the gateway is mocked through page.route for lobby.my.games.list, user.games.report, and the SubscribeEvents stream that the revocation watcher opens (held open indefinitely so a clean end-of-stream does not trigger signOut("revoked") and bounce the test back to /login).

Artifacts (delivered):

  • ui/frontend/src/api/game-state.ts — typed wrapper for user.games.report plus uuidToHiLo and a TS-friendly GameReport shape (planets only)
  • ui/frontend/src/lib/game-state.svelte.ts — runes-based GameStateStore with init / setGame / setTurn / refresh / setWrapMode / failBootstrap / dispose; tab-focus listener; Cache-backed wrap-mode persistence
  • ui/frontend/src/map/state-binding.tsreportToWorld and the per-kind planet styling
  • ui/frontend/src/lib/active-view/map.svelte — replaces the Phase 10 stub with the live renderer integration plus loading / error overlays and a data-planet-count testid hook
  • ui/frontend/src/lib/header/turn-counter.svelte — reads store.report.turn through context, falls back to the static ? placeholder when the store has not yet produced a snapshot
  • ui/frontend/src/routes/games/[id]/+layout.svelte — instantiates the GameStateStore, builds the GalaxyClient, exposes the store via setContext, disposes on unmount
  • pkg/schema/fbs/lobby.fbscurrent_turn:int32 field
  • pkg/schema/fbs/lobby/GameSummary.go (regenerated)
  • pkg/transcoder/lobby.go — encode/decode current_turn
  • pkg/transcoder/lobby_test.go — non-zero current_turn in the round-trip fixture
  • pkg/model/lobby/lobby.goCurrentTurn int32 on GameSummary
  • backend/internal/server/handlers_user_lobby_helpers.gogameSummaryWire.CurrentTurn + gameSummaryToWire reads it from RuntimeSnapshot.CurrentTurn; lobbyGameDetailWire no longer redeclares the field
  • backend/openapi.yamlcurrent_turn on the GameSummary schema (required); removed from the LobbyGameDetail allOf block (now inherited)
  • gateway/internal/backendclient/lobby_commands.godecodeGameSummaryFromGameDetail and decodePublicGamesPage parse current_turn from JSON
  • ui/MakefileFBS_INPUTS adds common.fbs (so the common/uuid.ts directory is generated) and report.fbs
  • ui/frontend/src/proto/galaxy/fbs/{common,report}/... — regenerated TS bindings
  • ui/frontend/src/api/lobby.tscurrentTurn: number on GameSummary; decodeGameSummary reads it
  • ui/frontend/tests/lobby-{fbs,api,page}.test.ts and tests/e2e/fixtures/lobby-fbs.ts — fixtures and assertions cover currentTurn
  • ui/frontend/tests/state-binding.test.ts — Vitest unit coverage for reportToWorld (dimensions, kinds, ids, styling, empty-planet, zero-dimension fallback, priority order)
  • ui/frontend/tests/game-state.test.ts — Vitest coverage for GameStateStore (init flow, missing-membership error, forbidden-result error, setTurn, wrap-mode persistence across instances, failBootstrap)
  • ui/frontend/tests/e2e/game-shell-map.spec.ts — Playwright e2e with a mocked gateway: live report renders the reported turn and planet count, zero-planet game renders without errors, missing-membership game surfaces the error overlay
  • ui/frontend/tests/e2e/fixtures/report-fbs.tsbuildReportPayload helper for forging FB Report payloads
  • Topic doc ui/docs/game-state.md
  • ui/docs/lobby.mdcurrent_turn note pointing at the new game-state doc

Dependencies: Phases 9, 10.

Acceptance criteria (met):

  • entering /games/:id/map for a game with real planets renders them on the map;
  • the turn counter in the header reflects the actual turn number;
  • map state refreshes on tab focus;
  • view mode (torus / no-wrap) honours the per-game preference if set, defaults to torus otherwise.

Targeted tests (delivered):

  • Vitest: tests/state-binding.test.ts covers the report→world translation across every planet kind plus malformed-dimension guards; tests/game-state.test.ts covers the store lifecycle end-to-end with a stubbed listMyGames and a fake GalaxyClient;
  • Playwright e2e: tests/e2e/game-shell-map.spec.ts exercises the live data path with a mocked gateway across all four projects, including the zero-planet regression and the missing-membership error path;
  • per-game wrap-scrolling preference round-trips through Cache in game-state.test.ts::setWrapMode persists across instances;
  • the existing Phase 10 chrome / navigation specs still pass unchanged.

Verified on local-ci run 4 (success, ce7a66b).

Phase 12. Order Composer Skeleton

Status: done.

Goal: implement the empty order composer as a persistent vertical list that survives navigation and reloads, ready to receive commands in later phases.

Decisions taken with the project owner during implementation:

  1. Store filename uses the runes extension. PLAN.md originally listed ui/frontend/src/sync/order-draft.ts. Svelte 5 runes only compile inside *.svelte / *.svelte.ts files; the draft state has to be reactive so order-tab.svelte re-renders on add/remove/move. The artifact ships as ui/frontend/src/sync/order-draft.svelte.ts, mirroring the Phase 11 lib/game-state.svelte.ts pattern.
  2. Single placeholder variant in the discriminated union. The project compactness rule rejects defining surface for the next phase. Phase 14 owns planetRename end-to-end (inspector UI, command type, submit pipeline, server-result merging) and is the right place to add the first real variant. Phase 12 ships exactly one variant — { kind: "placeholder"; id: string; label: string } — sufficient for the add/remove/reorder/persist tests.
  3. Reorder API is move(fromIndex, toIndex). One canonical operation; up/down at the call site is a one-line index arithmetic. No moveUp/moveDown aliases.
  4. Write-on-every-mutation persistence. add/remove/move each call Cache.put with the full draft snapshot. Phase 25 may profile the submit pipeline and batch writes if needed; until then deterministic writes are easier to test.
  5. Per-game scoping via Svelte context. One OrderDraftStore instance per game is created in routes/games/[id]/+layout.svelte alongside GameStateStore, exposed through ORDER_DRAFT_CONTEXT_KEY, disposed on layout destroy.
  6. historyMode as a prop, not a module. Layout passes historyMode={false} (a constant in Phase 12) to Sidebar and BottomTabs; both forward to their tab-bar children which omit the order entry when the flag is true. Phase 26 superseded the "introduce lib/history-mode.ts" half of this decision: the single derivation historyMode = $derived(gameState.historyMode) lives directly in +layout.svelte, the rune split between currentTurn and viewedTurn lives in GameStateStore, and no separate module is introduced. See Phase 26 decisions for the rationale.
  7. Empty-state copy is order is empty / приказ пуст. The coming soon placeholder text is replaced; per-row delete button reads delete / удалить.
  8. e2e seeding via __galaxyDebug.seedOrderDraft. The existing debug surface in routes/__debug/store/+page.svelte is extended with seedOrderDraft(gameId, commands) and clearOrderDraft(gameId) helpers that write directly to the order-drafts cache namespace. The store loads the seeded draft on the next layout mount the same way it would after a real reload.
  9. Race / disposal hygiene mirrors GameStateStore. Mutations are gated on status === "ready" so calls before init resolves are no-ops, and persist checks a destroyed flag so in-flight writes after dispose resolve into nothing.

Artifacts (delivered):

  • ui/frontend/src/sync/order-types.tsOrderCommand discriminated union (single placeholder variant) and CommandStatus lifecycle type.
  • ui/frontend/src/sync/order-draft.svelte.tsOrderDraftStore runes class with init / add / remove / move / dispose, plus ORDER_DRAFT_CONTEXT_KEY. Persists the full draft on every mutation under namespace order-drafts, key {gameId}/draft.
  • ui/frontend/src/lib/sidebar/order-tab.svelte — replaces the Phase 10 stub. Empty state from game.sidebar.empty.order; ordered list with stable data-testid="order-command-{i}" rows and a per-row delete button.
  • ui/frontend/src/lib/sidebar/sidebar.svelte, tab-bar.svelte, bottom-tabs.sveltehistoryMode prop on the sidebar forwards to hideOrder on tab-bar / bottom-tabs; active-tab order is reset to inspector if the flag flips on, and the ?sidebar=order URL seed falls back to inspector while the flag is true.
  • ui/frontend/src/routes/games/[id]/+layout.svelte — instantiates OrderDraftStore, sets context, runs init({ cache, gameId }) next to gameState.init through one Promise.all, disposes on destroy, passes historyMode={false} down.
  • ui/frontend/src/routes/__debug/store/+page.svelte — extended DebugSurface with seedOrderDraft / clearOrderDraft.
  • ui/frontend/src/lib/i18n/locales/{en,ru}.ts — new game.sidebar.order.command_delete key plus updated game.sidebar.empty.order copy.
  • ui/docs/order-composer.md — topic doc describing the draft-replaces-server-order model, local-validation invariant, command status state machine, persistence, history-mode wiring, and test layout. Cross-references storage.md and navigation.md.
  • ui/docs/storage.md — namespace registry row for order-drafts.
  • ui/docs/navigation.md — describes the historyMode prop wiring through Sidebar / BottomTabs.
  • ui/README.md — new entry under topic docs for order-composer.md.
  • Vitest: ui/frontend/tests/order-draft.test.ts.
  • Playwright: ui/frontend/tests/e2e/order-composer.spec.ts.

Dependencies: Phases 6, 10.

Acceptance criteria:

  • programmatically adding a stub command shows it in the order tab;
  • closing and reopening the browser preserves the draft;
  • removing a command from the order tab persists the removal;
  • the order tab is hidden in history mode (Phase 26) — wired here as a prop so later phases can flip it.

Targeted tests:

  • Vitest unit tests for order-draft covering add, remove, reorder, persistence;
  • Playwright e2e: programmatically add three stub commands, reload, assert all three persist.

Verified on local-ci run 7 (success, 460591c).

Phase 13. Inspector — Planet (Read-Only)

Status: done.

Goal: show planet details in the inspector when a planet is clicked on the map; read-only, no actions yet.

Artifacts:

  • ui/frontend/src/lib/sidebar/inspector-tab.svelte empty state (select an object on the map) and routing per selected-object kind. The tab reads the selection and game-state stores from context and hands a resolved ReportPlanet to the planet inspector component.
  • ui/frontend/src/lib/inspectors/planet.svelte read-only display of every planet field carried by the FBS report and documented in the rules.txt planet section: name, coordinates, size, population, colonists, industry, industry stockpile (capital, $), materials stockpile (material, M), natural resources, current production type, free production potential. Per-kind nullable fields collapse silently — uninhabited and unidentified planets render the smaller field set the engine carries for them.
  • ui/frontend/src/lib/inspectors/planet-sheet.svelte mobile-only bottom-sheet that wraps the same planet component for the < 768 px breakpoint. Visibility is gated on effectiveTool === "map" so the sheet does not stack with the calc / order overlays.
  • ui/frontend/src/lib/active-view/map.svelte registers a click handler against the new RendererHandle.onClick (built on pixi-viewport's clicked event), translates the hit into a planet, and calls SelectionStore.selectPlanet(number).
  • ui/frontend/src/lib/selection.svelte.ts runes store with the selected-object union ({ kind: "planet"; id: number } | null), exposed via setContext from the in-game shell layout. Lifetime matches the layout instance — selection survives every active-view switch but does not persist across reloads.
  • ui/frontend/src/api/game-state.ts projection extended to surface every planet field needed by the inspector (industryStockpile, materialsStockpile, industry, population, colonists, production, freeIndustry, plus the existing owner).
  • ui/frontend/src/routes/games/[id]/+layout.svelte lifts activeTab into a layout-level rune bound into the sidebar, owns the SelectionStore, mounts the bottom-sheet, and runs the reveal $effect that flips the sidebar to the inspector tab and opens the tablet drawer when a new selection lands.

Dependencies: Phase 11.

Acceptance criteria:

  • clicking any visible planet on the map shows its details in the inspector tab on desktop and tablet (drawer auto-opens), and in a bottom-sheet on mobile;
  • selection state persists across view switches inside /games/:id/* (per global state-preservation rule); reload starts fresh;
  • a click on empty map area is a no-op — selection is cleared only by the explicit close button () on the mobile sheet;
  • empty inspector renders the empty-state message when no planet is selected;
  • mobile dismissal is the close button only; swipe-to-dismiss and tap-outside-to-dismiss are deferred to Phase 35;
  • a selection that no longer matches a visible planet (visibility lost between turns) collapses to the empty state instead of showing stale rows;
  • selected-planet visual feedback on the map (ring / halo) is intentionally out of scope and rolls into Phase 35.

Targeted tests:

  • Vitest unit (tests/selection-store.test.ts) for the runes store;
  • Vitest component (tests/inspector-planet.test.ts) for per-kind field rendering against synthetic ReportPlanet fixtures;
  • Vitest component (tests/game-shell-sidebar.test.ts) extended for the selection-driven inspector content and the missing-planet fallback;
  • Playwright e2e (tests/e2e/game-shell-inspector.spec.ts) clicks a seeded planet on chromium-desktop and asserts the sidebar inspector content, and on chromium-mobile-iphone-13 asserts the bottom-sheet appears and the close button clears it.

Phase 14. First End-to-End Command — Rename Planet

Status: done.

Goal: prove the entire pipeline (inspector → composer → submit → server → state refresh) by wiring up exactly one action: renaming a planet.

Decisions taken with the project owner during implementation:

  1. Optimistic overlay over user.games.order. The plan's acceptance criterion ("name change within one second") is inconsistent with the engine's order endpoint, which only validates and stores; rename takes effect at turn cutoff. Phase 14 keeps user.games.order for the wire path and adds a pure projection applyOrderOverlay(report, commands, statuses) in api/game-state.ts. Inspector, mobile sheet, and map renderer read a derived renderedReport (context key RENDERED_REPORT_CONTEXT_KEY) that swaps planet names in for every applied or in-flight rename. Raw gameState.report stays available for debugging / history mode.
  2. Read-back endpoint user.games.order.get. Without a server snapshot of stored orders the optimistic overlay would not survive a cache wipe. Phase 14 adds the new authenticated message type with a backend route GET /api/v1/user/games/{game_id}/orders?turn=N (pass-through to the engine's existing GET /api/v1/order). The frontend calls it from OrderDraftStore.hydrateFromServer only when the local cache row is absent — an explicitly empty cache row honours the user's empty draft. The turn query is required (the frontend always knows the current turn from the lobby record).
  3. Per-command results from real engine response. The engine now answers PUT /api/v1/order with 202 Accepted and a populated UserGamesOrder body (per-command cmdApplied, cmdErrorCode, plus an engine-assigned updatedAt). The gateway parses that JSON into the extended FBS UserGamesOrderResponse envelope and the frontend reads the per-command outcome through submitOrder. A defensive batch-level fallback covers an empty commands array.
  4. Applied commands stay in the draft. Per the gameplay model, the order is the player's intent surface — submitted commands stay until the user removes them or until turn cutoff (Phase 24 wires the auto-clear). Statuses are runtime-only; on reload the draft re-validates as valid and the overlay re-applies.
  5. Validator parity through a TS port. ValidateTypeName from pkg/util/string.go is mirrored in ui/frontend/src/lib/util/entity-name.ts. The inspector's inline editor disables the confirm button until the input passes; the draft store re-runs the validator on every add and exposes per-row valid / invalid to the order tab.
  6. updatedAt plumbing without enforcement. Phase 14 sends 0 on every submit (no client-side stale-order detection yet); the engine still writes a real timestamp, the gateway surfaces it in the FBS response, and the draft stashes it. Future phases can wire conditional updates without a wire change.

Artifacts (delivered):

  • pkg/schema/fbs/order.fbs — extended UserGamesOrderResponse (game_id, updated_at, commands); new UserGamesOrderGet / UserGamesOrderGetResponse tables.
  • pkg/model/order/order.goMessageTypeUserGamesOrderGet and UserGamesOrderGet typed payload.
  • pkg/transcoder/order.goJSONToUserGamesOrder, UserGamesOrderResponseToPayload, UserGamesOrderGetToPayload, PayloadToUserGamesOrderGet, PayloadToUserGamesOrderResponse, UserGamesOrderGetResponseToPayload, PayloadToUserGamesOrderGetResponse. Replaces the old EmptyUserGamesOrderResponsePayload helper.
  • backend/internal/server/handlers_user_games.go — new GetOrders handler. engineclient.GetOrder forwards to the engine's GET /api/v1/order with the player rebound. backend/openapi.yaml documents the new GET operation; contract_test.go extended with a queryParamStubs map for required query parameters.
  • gateway/internal/backendclient/games_commands.go — updated executeUserGamesOrder (parses real engine JSON via JSONToUserGamesOrder); new executeUserGamesOrderGet and projectUserGamesOrderGetResponse. gateway/internal/backendclient/routes.go registers the new message type.
  • ui/Makefileorder.fbs joins FBS_INPUTS; regenerated TS bindings under ui/frontend/src/proto/galaxy/fbs/order/.
  • ui/frontend/src/sync/order-types.tsPlanetRenameCommand variant added to the discriminated union.
  • ui/frontend/src/sync/submit.tssubmitOrder posts the FBS request and parses per-command verdicts.
  • ui/frontend/src/sync/order-load.tsfetchOrder issues user.games.order.get.
  • ui/frontend/src/sync/order-draft.svelte.ts — extended with per-command statuses, validate / markSubmitting / applyResults / markRejected / revertSubmittingToValid / hydrateFromServer, and the needsServerHydration flag.
  • ui/frontend/src/lib/util/entity-name.ts — TS port of ValidateTypeName.
  • ui/frontend/src/api/game-state.ts — pure applyOrderOverlay(report, commands, statuses) projection plus the currentTurn rune on GameStateStore.
  • ui/frontend/src/lib/rendered-report.svelte.ts — derives the overlay-applied report and exposes it through RENDERED_REPORT_CONTEXT_KEY.
  • ui/frontend/src/lib/galaxy-client-context.svelte.tsGalaxyClientHolder so command-driven UI can resolve the per-game GalaxyClient via context.
  • ui/frontend/src/lib/inspectors/planet.svelte — Rename action
    • inline editor with validateEntityName-driven feedback.
  • ui/frontend/src/lib/sidebar/order-tab.svelte — per-row status, Submit button with disabled-state matrix, refresh on success, surfaces batch errors inline.
  • ui/frontend/src/lib/sidebar/inspector-tab.svelte and ui/frontend/src/lib/active-view/map.svelte — switched to renderedReport.
  • ui/frontend/src/routes/games/[id]/+layout.svelte — wires the rendered report and galaxy-client contexts; runs orderDraft.hydrateFromServer(...) after the boot Promise.all resolves when needsServerHydration.
  • ui/frontend/src/lib/i18n/locales/{en,ru}.ts — keys for rename action / editor / order statuses / submit copy.
  • Tests: entity-name.test.ts, submit.test.ts, order-load.test.ts, order-overlay.test.ts, order-tab.test.ts, extended order-draft.test.ts and inspector-planet.test.ts. New Playwright spec tests/e2e/rename-planet.spec.ts.
  • Documentation: docs/ARCHITECTURE.md §9, docs/FUNCTIONAL.md §6.2 (and docs/FUNCTIONAL_ru.md mirror), ui/docs/order-composer.md with the new "Submit pipeline", "Optimistic overlay", and "Server hydration on cache miss" sections.

Dependencies: Phases 12, 13.

Acceptance criteria:

  • the user can select a planet, click Rename, type a new name, see the command appear in the order tab, click Submit, and observe the planet's name change everywhere within one second (overlay applies immediately on the inspector / mobile sheet / map; server-side state catches up at turn cutoff);
  • attempting an empty or invalid name is blocked locally (Submit button disabled, inline error message under the input);
  • a server-side rejection is surfaced as rejected status on every in-flight row, with the gateway's error message inline.

Targeted tests:

  • Vitest unit tests for submitOrder, fetchOrder, applyOrderOverlay, validateEntityName, and the extended OrderDraftStore.
  • Vitest component tests for the inline rename editor and the Submit button states.
  • Playwright e2e: rename a seeded planet, reload, confirm the new name persists; rejected path keeps the old name.

Verified on local-ci run 11 (success, f80c623).

Phase 15. Inspector — Planet Production Controls

Status: done.

Goal: let the user switch a planet's production type to industry, materials, research a tech field, or build a ship class; each change appends a command to the order draft. Repeated changes for the same planet collapse to the latest choice.

Decisions taken with the project owner during implementation:

  1. Forecast is deferred and raised as a blocker. The plan's audit clause discovered that pkg/calc/ only carries the two ship-side functions (ShipProductionCost, PlanetProduceShipMass); every other forecast formula (industry, materials, per-tech research, production capacity) lives inside game/internal/model/game/planet.go and is not exported. ui/core/calc/ and ui/docs/calc-bridge.md did not exist at all. Phase 15 creates ui/docs/calc-bridge.md documenting the gap and waives the forecast deliverable until a dedicated future phase builds the real Go → WASM → TS bridge. The inspector continues to show only the existing freeIndustry (free production potential) number, which is computed engine-side and ships in the report payload.
  2. Sub-pickers expose only what the game data already supports. "Research" sub-row shows the four implicit tech fields (DRIVE / WEAPONS / SHIELDS / CARGO); custom LocalScience entries are deferred until the science designer phase introduces them. "Build Ship" sub-row shows LocalShipClass entries; the GameReport projection is extended with a minimal ShipClassSummary { name } so the e2e spec can seed one ship class and exercise the SHIP branch end-to-end. Empty LocalShipClass collapses to a localised "no ship classes designed yet" placeholder.
  3. Re-clicks always emit a command. The collapse-by-planetNumber rule keeps at most one setProductionType per planet in the draft. A click that lands on the segment matching report.production still emits a command; the engine accepts repeat submits idempotently. Avoids a fragile reverse-mapping from report.production display strings ("Drive", ship-class name, science name) back to the FBS enum.
  4. Inspector layout split. ui/frontend/src/lib/inspectors/planet/ production.svelte is the new component; the parent inspectors/planet.svelte mounts it for kind === "local" planets and drops the static read-only "current production" row on that branch (the row stays for non-local planets). The mobile sheet (planet-sheet.svelte) and the sidebar (sidebar/inspector-tab.svelte) both forward localShipClass from the rendered-report context.

Artifacts (delivered):

  • ui/frontend/src/sync/order-types.tsSetProductionTypeCommand variant + ProductionType literal union + PRODUCTION_TYPE_VALUES / isProductionType helpers.
  • ui/frontend/src/sync/order-draft.svelte.tsvalidateCommand branch (mirrors the engine's subject=Production rule); add enforces collapse-by-planetNumber for the new variant only.
  • ui/frontend/src/sync/submit.ts — encodes CommandPlanetProduce via the new productionTypeToFBS helper.
  • ui/frontend/src/sync/order-load.ts — decodes CommandPlanetProduce via productionTypeFromFBS and skips PlanetProduction.UNKNOWN rows.
  • ui/frontend/src/api/game-state.tsapplyOrderOverlay rewrites planet.production for setProductionType (helper productionDisplayFromCommand mirrors Cache.PlanetProductionDisplayName); new ShipClassSummary type and GameReport.localShipClass projection (decoded from report.localShipClass).
  • ui/frontend/src/lib/inspectors/planet/production.svelte — new segmented control with Research / Build-Ship sub-rows.
  • ui/frontend/src/lib/inspectors/planet.svelte — accepts localShipClass prop, mounts <Production /> for local planets, drops the static production row on that branch only.
  • ui/frontend/src/lib/inspectors/planet-sheet.svelte and ui/frontend/src/lib/sidebar/inspector-tab.svelte — forward localShipClass from the rendered report context.
  • ui/frontend/src/routes/games/[id]/+layout.svelte — derives localShipClass and passes it to the mobile sheet.
  • ui/frontend/src/lib/sidebar/order-tab.svelte — new label branch for setProductionType using the new locale key.
  • ui/frontend/src/lib/i18n/locales/{en,ru}.ts — production-control copy plus the new order-tab label.
  • ui/frontend/tests/e2e/fixtures/report-fbs.ts — extended with a localShipClass fixture vector.
  • ui/frontend/tests/e2e/fixtures/order-fbs.ts — discriminated fixture union supporting both planetRename and setProductionType payloads.
  • ui/docs/calc-bridge.md (new) — calc-bridge gap analysis and the Phase 15 waiver.
  • ui/docs/order-composer.md — updated discriminated-union reference + new "Collapse-by-target rule" section.
  • Tests: extended order-draft.test.ts, submit.test.ts, order-load.test.ts, order-overlay.test.ts, game-state.test.ts, inspector-planet.test.ts; new inspector-planet-production.test.ts Vitest component spec; new tests/e2e/planet-production.spec.ts Playwright spec.

Dependencies: Phase 14.

Acceptance criteria:

  • changing production type adds exactly one setProductionType command to the order draft, with the engine wire shape (CommandPlanetProduce + subject rule for SCIENCE / SHIP);
  • repeated changes for the same planet collapse to the latest choice (no duplicate setProductionType commands per planet); other variants (e.g. planetRename) keep their append-only behaviour;
  • forecast output number is intentionally not rendered in this phase (waived per decision 1; tracked in ui/docs/calc-bridge.md).

Targeted tests:

  • Vitest unit tests for the collapse-by-planetNumber logic in OrderDraftStore.add and the setProductionType branch of validateCommand;
  • Vitest unit tests for the FBS encoder / decoder round-trip and the productionDisplayFromCommand helper;
  • Vitest component tests for the segmented control's segment emission, sub-row reveal, empty-classes placeholder, and active- highlight derivation;
  • Playwright e2e: switch production three times across all four segments, confirm the order tab carries exactly one row at every step, gateway records the latest choice (SHIP + class name), reload preserves the row through user.games.order.get.

Verified on local-ci run 16 (success, 4273102).

Phase 16. Inspector — Cargo Routes

Status: done. Verified on local-ci run 17 (gitea actions, commit 7c8b5ae).

Goal: configure up to four cargo routes per planet (colonists, industry, materials, empty) through the inspector, with the destination picked directly on the map. Phase 16 also lands the generic map-pick foundation (Pass A) the inspector consumes; Phase 19/20 (ship-group dispatch) reuses the same renderer surface.

Artifacts (Pass A — renderer foundation):

  • ui/frontend/src/map/pick-mode.ts carries the PickModeOptions / PickModeHandle types and the pure computePickOverlay helper.
  • ui/frontend/src/map/render.ts extends RendererHandle with setPickMode / isPickModeActive / getPickState, onPointerMove / onHoverChange, and the getPrimitiveAlpha(id) debug accessor. The standard onClick consumers are gated on the pickModeActive flag so the destination click does not also trigger planet selection.
  • ui/frontend/src/map/hit-test.ts widens point matching to (pointRadiusPx + slopPx) / camera.scale so hover and click zones match the visible disc; default radius shared via DEFAULT_POINT_RADIUS_PX = 3.
  • ui/frontend/src/lib/map-pick.svelte.ts defines the Svelte MapPickService (promise-shaped pick(...) plus reactive active); lib/active-view/map.svelte constructs the service and binds a renderer-side resolver that resolves sourcePlanetNumber against the current report.
  • ui/frontend/src/lib/debug-surface.svelte.ts registers getMapPrimitives() and getMapPickState() providers; the DEV-only __galaxyDebug surface in routes/__debug/store/+page.svelte exposes them so e2e specs can assert the renderer's state without scraping pixels.

Artifacts (Pass B — feature):

  • ui/frontend/src/sync/order-types.ts extends with CargoLoadType, SetCargoRouteCommand, and RemoveCargoRouteCommand. CARGO_LOAD_TYPE_VALUES is the priority order (COL, CAP, MAT, EMP).
  • ui/frontend/src/sync/order-draft.svelte.ts collapses both variants by (sourcePlanetNumber, loadType); the newer entry supersedes any prior set or remove for the same slot.
  • ui/frontend/src/sync/submit.ts and ui/frontend/src/sync/order-load.ts round-trip the two new variants through CommandPlanetRouteSet and CommandPlanetRouteRemove. UNKNOWN load-type values drop with a console.warn.
  • ui/frontend/src/api/game-state.ts extends GameReport with routes: ReportRoute[] (decoded from report.route() in CARGO_LOAD_TYPE_VALUES order) and localPlayerDrive: number (looked up via findLocalPlayerDrive). applyOrderOverlay upserts / drops route entries for valid / submitting / applied cargo-route commands.
  • ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte is the four-slot subsection. Add / Edit call MapPickService.pick(...); Remove emits removeCargoRoute.
  • ui/frontend/src/map/cargo-routes.ts builds the LinePrim arrows (shaft + two arrowhead wings) per (source, loadType, destination) triple. Per-type style and priority (COL=8EMP=5); ids prefixed with 0x80000000 to avoid colliding with planet numbers.
  • ui/frontend/src/map/state-binding.ts appends buildCargoRouteLines(report) to the world primitives.
  • ui/frontend/src/lib/active-view/map.svelte adds a routes-content fingerprint to the same-snapshot guard and preserves camera centre + zoom across route-driven remounts inside the same game id.
  • Topic doc ui/docs/cargo-routes-ux.md quotes rules.txt (lines 808843) and maps semantics to UI; ui/docs/renderer.md documents the pick-mode contract; ui/docs/calc-bridge.md records the Phase 16 reach waiver (inline TS rather than a calc bridge for one constant-time multiplication).

Dependencies: Phase 15.

Acceptance criteria:

  • the user can add, edit, and remove cargo routes through the inspector;
  • the destination picker happens on the map: out-of-reach planets fade to α=0.3, the source gains an anchor ring, the cursor draws a live line to the source, and hover over a reachable planet outlines it. Clicks on non-reachable space are no-ops; a click on a reachable planet emits setCargoRoute; Escape cancels;
  • the four route types are mutually exclusive — only one route per type per source planet;
  • configured routes are rendered as arrows on the map between source and destination planets, distinguishable per cargo type (placeholder colour palette; final values land in Phase 35 polish);
  • the optimistic overlay surfaces draft routes immediately on the map; the camera survives the routes-fingerprint remount so the view does not jolt mid-edit.

Targeted tests:

  • Vitest: tests/map-hit-test.test.ts (regenerated for the visible-radius formula), tests/map-pick-mode.test.ts (computePickOverlay lifecycle), tests/map-cargo-routes.test.ts, tests/inspector-planet-cargo-routes.test.ts, tests/state-binding.test.ts extension, tests/order-draft.test.ts extension, tests/submit.test.ts and tests/order-load.test.ts extensions, tests/order-overlay.test.ts extension.
  • Playwright e2e tests/e2e/cargo-routes.spec.ts: open inspector, trigger Add, assert dim state via __galaxyDebug.getMapPickState(), click a reachable planet, assert setCargoRoute shipped + arrow visible via __galaxyDebug.getMapPrimitives(). Add a CAP route to confirm slots coexist; Remove COL → arrow gone; reload → restored from user.games.order.get.

Decisions baked into Phase 16 (vs. the original stage description):

  • The destination picker is map-driven, not list-based. The acceptance criterion "disables planets outside reach with a tooltip" is replaced by "fades planets outside reach to α=0.3 and forbids picking them"; the rendered map is the player's spatial reference, so a list duplicates information the planet already conveys.
  • Reach is computed inline in TypeScript, not via a pkg/calc/ Go bridge (ui/docs/calc-bridge.md Phase 16 waiver).
  • Wrap-mode is treated as a per-game property set at map load; the camera-preservation refactor only fires when the routes-fingerprint changes inside the same game id.

Phase 17. Ship Classes — CRUD Without Calc

Status: done (local-ci run 20).

Goal: list, view, create, and delete ship classes through a dedicated table view and a designer view; numeric calculations are stubbed pending Phase 18.

Per game/rules.txt, ship classes are designed once and cannot be modified after creation — values are baked into existing ships at build time. The future "upgrade" command (Phase 19/20, CommandShipGroupUpgrade) raises an existing ship group's tech levels but does not edit the class blueprint. Phase 17 therefore exposes only Create and Delete; an "edit" affordance is deliberately absent and the designer renders an existing class read-only.

Artifacts:

  • ui/frontend/src/lib/active-view/table-ship-classes.svelte table of ship classes with sort and filter, plus per-row Delete affordance (the existing routes/games/[id]/table/[entity]/+page.svelte already wires this active view through the [entity] parameter, so no new route file lands).
  • ui/frontend/src/lib/active-view/designer-ship-class.svelte rewritten from the Phase 10 stub: empty form for the Create flow (name plus the five fields Drive, Armament, Weapons, Shields, Cargo) and read-only view + Delete affordance for an existing class. Validation rules from rules.txt live in lib/util/ship-class-validation.ts (TS port of pkg/calc/validator.go.ValidateShipTypeValues): each of drive / weapons / shields / cargo is 0 or ≥ 1; armament is a non-negative integer; armament and weapons are both zero or both nonzero; not all five values may be zero. The existing routes/games/[id]/designer/ship-class/[[classId]]/+page.svelte is already wired and consumes the optional classId URL segment through page.params.
  • ui/frontend/src/sync/order-types.ts extends with CreateShipClassCommand and RemoveShipClassCommand variants (mapped to CommandShipClassCreate and CommandShipClassRemove on the wire by sync/submit.ts and sync/order-load.ts).
  • ui/frontend/src/api/game-state.ts widens ShipClassSummary to carry the full attribute set; applyOrderOverlay projects pending Save / Delete actions onto localShipClass so the table reflects the player's intent before auto-sync lands.

Dependencies: Phase 14.

Acceptance criteria:

  • the user can create, list, view, and delete ship classes;
  • field validation matches rules.txt constraints with disabled Submit + tooltip when invalid;
  • double-tapping a row in the ship-classes table opens its designer (read-only view of the existing class);
  • pending Save / Delete actions surface in the order tab and reflect on the table immediately through the rendered-report overlay, before the auto-sync round-trip completes.

Targeted tests:

  • Vitest component tests for designer field validation (tests/designer-ship-class.test.ts) and the table (tests/table-ship-classes.test.ts); Vitest unit tests for the validator (tests/ship-class-validation.test.ts);
  • Playwright e2e (tests/e2e/ship-classes.spec.ts): create a class, list it, delete it; rejected-submit kept; field-validation kept (Save disabled with localised tooltip).

Phase 18. Ship Classes — Calc Bridge

Status: done.

Goal: wire pkg/calc/ ship math into the designer for live mass, speed, range, and cargo capacity previews.

Artifacts:

  • ui/core/calc/ship.go thin Go bridge wrapping seven functions from pkg/calc/ship.goDriveEffective, EmptyMass, WeaponsBlockMass, FullMass, Speed, CargoCapacity, CarryingMass — each as a one-line passthrough; the seventh function (CarryingMass) was added during stage implementation to let the preview compose full-load mass from CargoCapacity without injecting math into TS;
  • ui/wasm/main.go registers the seven wrappers under globalThis.galaxyCore; ui/frontend/src/platform/core/index.ts extends Core with the matching typed methods (emptyMass and weaponsBlockMass return number | null, mirroring the Go (_, false) validator path);
  • ui/frontend/src/api/game-state.ts extends GameReport with localPlayerWeapons, localPlayerShields, localPlayerCargo alongside the existing localPlayerDrive. The decoder reads all four from the Player row in the report's player block. Phases 19-21 reuse these fields without re-extending the report;
  • ui/frontend/src/lib/core-context.svelte.ts exposes a CoreHolder through CORE_CONTEXT_KEY. The in-game layout (routes/games/[id]/+layout.svelte) populates the holder after loadCore() resolves, so the designer reads Core from context without re-booting WASM;
  • live-updating preview pane in the ship-class designer showing empty mass, full-load mass, max speed (at empty), range at full load, and cargo capacity per ship at the player's current tech levels; the pane only renders when the form passes validation and Core is ready;
  • audit step recorded in ui/docs/calc-bridge.md: live surface table lists every wired function against its pkg/calc/ source.

Dependencies: Phases 5 (Core skeleton), 17.

Acceptance criteria:

  • changing any field in the designer updates the preview within one frame on a mid-range laptop;
  • preview values are byte-for-byte identical to direct pkg/calc/ calls on shared fixtures;
  • the bridge contains zero math beyond marshalling adapters.

Targeted tests:

  • Go parity tests in ui/core/calc/ship_test.go against pkg/calc/ outputs on shared fixtures, plus a composition test that exercises the exact preview pipeline (empty → cargo capacity → carrying mass → full-load mass → speed at empty + at full);
  • Vitest coverage in ui/frontend/tests/designer-ship-class.test.ts asserts preview hidden until validation passes, hidden when no Core is available, renders five rows with computed values once the form is valid, and reactively refreshes on subsequent edits.

Phase 19. Inspector — Ship Group (Read-Only)

Status: done (local-ci run 24).

Goal: render ship groups on the map and display group details in the inspector when a group is selected; read-only, no actions yet.

Artifacts:

  • ui/frontend/src/map/ship-groups.ts renders ship groups on the map: own and visible foreign groups stationed on planets, groups in hyperspace at their current coordinates, and incoming groups with a distinct visual style and an ETA label
  • ui/frontend/src/map/state-binding.ts extends to feed groups into the renderer alongside planets
  • ui/frontend/src/lib/inspectors/ship-group.svelte read-only display of group fields: class, count, tech levels of components, location (planet or hyperspace coordinates), cargo type and amount, fleet membership
  • map click handler that selects a group and switches sidebar to Inspector (or raises bottom-sheet on mobile)
  • selection store extended to support ShipGroup selections

Dependencies: Phases 11, 13.

Acceptance criteria:

  • own and visible foreign ship groups render on the map for a seeded game in both torus and no-wrap modes;
  • incoming groups are visually distinct and show ETA;
  • clicking any rendered group shows its details in the inspector;
  • groups in hyperspace show coordinates and remaining distance in the inspector;
  • cargo type and amount display when applicable.

Targeted tests:

  • Vitest unit tests for the rendering of each group variant (on-planet, in-hyperspace, incoming);
  • Vitest component tests for the ship-group inspector with fixture data covering planet-based, hyperspace, and cargo-loaded variants;
  • Playwright e2e: click each variant from a seeded game, assert all expected fields render.

Phase 20. Inspector — Ship Group Actions

Status: done (local-ci run 28).

Goal: enable group operations from the inspector: split, send, load, unload, modernize, dismantle, transfer to race, add to fleet.

Artifacts:

  • action panel ui/frontend/src/lib/inspectors/ship-group/actions.svelte mounted by the read-only inspector for the local variant; eight inline forms (one per action) with disabled-button tooltips that mirror the engine's pre-conditions (controller/ship_group*.go)
  • ui/frontend/src/sync/order-types.ts extends with eight new command variants — breakShipGroup, sendShipGroup, loadShipGroup, unloadShipGroup, upgradeShipGroup, dismantleShipGroup, transferShipGroup, joinFleetShipGroup — plus ShipGroupCargo and ShipGroupUpgradeTech literal types
  • sync/submit.ts and sync/order-load.ts round-trip every new variant against the existing FBS classes in proto/galaxy/fbs/order/; the id field on each ship-group payload carries the target group UUID (the source group, or the freshly-minted newGroupId when an implicit split precedes the action)
  • Send action drops the inspector straight into map-pick mode on click and only mounts the form (ship count + confirm) after the player chooses a destination — there is no destination control inside the form. The picker is filtered by the group's reach (localPlayerDrive * 40, computed inline via the existing torusShortestDelta from cargo-routes.svelte); the player's tech levels are already on GameReport.localPlayer* from Phase 18, no extra plumbing needed
  • Modernize cost preview through core.blockUpgradeCost (Phase 20 bridge), summed over the four ship-class blocks for the targeted ship count; preview hides when Core is not yet booted or the form is invalid (see ui/docs/ship-group-actions.md for the formula breakdown)
  • two-step inline confirmation for Dismantle over a foreign planet with colonists onboard (engine reference controller/ship_group.go:177-179UnloadColonists is not called over a foreign planet, so the cargo is lost)
  • state-changing-command lock: a Send / Modernize / Dismantle / Transfer order in the draft for a given group disables every action button on that group's inspector and surfaces a banner pointing to the order list. Cancelling the queued command in the order tab releases the lock. Load / Unload / Split / JoinFleet do not lock — they stack legitimately on the engine side. Send used to be unlocked too, but a queued Send is the visible commitment to launch this orbit, so the inspector treats it the same as the three destructive variants
  • pkg/calc/ship.go.BlockUpgradeCost (migrated from game/internal/controller/ship_group_upgrade.go) — the bridge rule says ui/core/calc/ only wraps pkg/calc/ formulas, so the function moved upstream and the controller now imports it
  • GameReport.otherRaces: string[] populated by the report decoder from report.player[] (non-extinct, ≠ self) — used by the transfer-to-race picker; Phase 22's Races View reuses the same field
  • planet inspector's stationed-ship rows (lib/inspectors/planet/ship-groups.svelte) become clickable for own groups, pivoting the SelectionStore to the matching shipGroup.local ref so the actions panel is reachable from the standard click flow (the map deliberately hides on-planet groups, so this is the on-planet entry point)
  • topic doc ui/docs/ship-group-actions.md covers the action surface, disabled-state rules, implicit-split pattern, and the modernize cost preview formula

Dependencies: Phases 18, 19.

Acceptance criteria:

  • every action either adds the corresponding command to the order draft or is disabled with a tooltip explaining why;
  • splitting a group of N into K and N-K results in two valid commands (the implicit split + the action);
  • destructive actions surface explicit confirmation dialogs;
  • end-to-end execution: send a group, submit order, observe arrival next turn.

Targeted tests:

  • pkg/calc/ship_test.go.TestBlockUpgradeCost — formula coverage on the migrated function;
  • ui/core/calc/ship_test.go.TestBlockUpgradeCostParity — bridge parity against pkg/calc/;
  • Vitest:
    • tests/inspector-ship-group-actions.test.ts — disabled-state rules per action and the implicit-split pattern;
    • tests/inspector-ship-group-dismantle-confirm.test.ts — two-step confirm over foreign-COL groups;
    • tests/inspector-ship-group-modernize-cost.test.ts — preview formula matches BlockUpgradeCost × ship count and hides when Core is null;
    • tests/sync-order-types-ship-group.test.tsvalidateCommand for each new variant;
    • tests/sync-submit-ship-group.test.ts — encoder/decoder round-trip per new variant;
  • Playwright tests/e2e/ship-group-send.spec.ts — synthetic report with a 3-ship group on Earth and a reachable Mars, drives the planet inspector → ship-group inspector pivot, then Send 2 of 3 with map-pick destination, asserts both Break and Send land in the order draft via the order tab.

Decisions during stage:

  1. BlockUpgradeCost migration. The pre-existing copy in game/internal/controller/ship_group_upgrade.go moved to pkg/calc/ship.go; the controller's GroupUpgradeCost and UpgradeGroupPreference now call calc.BlockUpgradeCost. The unit test moved from controller/ship_group_upgrade_test.go to pkg/calc/ship_test.go.
  2. GameReport.otherRaces field added to ui/frontend/src/api/game-state.ts; the synthetic-report decoder populates it the same way (api/synthetic-report.ts). Phase 22's Races View can read this directly without a fresh plumbing pass — the Phase 22 stage text below is updated to reflect that.
  3. Stationed-ship rows are clickable. The Phase 19 stationed- ship subsection on the planet inspector becomes interactive for own groups (Phase 21+ table view stays a separate target). The map renderer continues to hide on-planet groups — this is the cheaper navigational fix.
  4. Inline forms, no modal. Every action opens an inline editor under the buttons row, matching the Phase 14 rename and Phase 16 cargo-route patterns. Send reuses MAP_PICK_CONTEXT_KEY (Phase 16's renderer service) for the destination picker. Foreign-COL Dismantle uses a two-step inline confirm (button label flips to "confirm — colonists die") rather than a separate modal component.
  5. Implicit split for Send/Load/Unload/Modernize/Dismantle/ Transfer. The number-of-ships input defaults to the group's full count; when the player picks a smaller M, the inspector prepends breakShipGroup(id, newId, M) and routes the action at newId. JoinFleet and Split do not get a counter (JoinFleet is whole-group atomically per the engine; Split is the break command).
  6. Send is pick-first, form-second. Click → enter map-pick mode immediately. The form (ship count + confirm) only appears after a destination is chosen; cancelling the picker leaves no form behind. Removing the destination control from the form keeps the surface to one editable field at any time.
  7. State-changing-command lock. Any sendShipGroup, upgradeShipGroup, dismantleShipGroup, or transferShipGroup in the draft for a given group id disables every action button on that group's inspector with a "command pending" tooltip and renders a banner pointing the player at the order list. Cancellation from the order tab releases the lock. All four commands flip the group out of StateInOrbit at turn cutoff (StateLaunched / StateUpgrade / removal / StateTransfer), so any second action would race the engine's pre-condition check anyway — the lock surfaces that commitment up-front.
  8. Pending-Send map overlay. A queued sendShipGroup for an own group still in orbit renders as a green dashed line from the orbit planet to the destination, drawn on the same overlay layer as cargo-route arrows. The line is wrap-aware (uses torusShortestDelta) and skipped when the engine has marked the command rejected or invalid. Removed when the group leaves orbit (Send applied) or the player cancels the command from the order tab. Implemented in ui/frontend/src/map/pending-send-routes.ts; the overlay fingerprint in lib/active-view/map.svelte is extended so the renderer's setExtraPrimitives re-runs on draft changes.
  9. Yellow dashed track for own in-space groups. The map already drew the in-space group point in yellow (0xfff176); Phase 20 adds the matching yellow dashed line from the origin planet to the destination so the player reads "this group is moving" even when zoomed out. Wrap-aware via the same torus delta. Implemented in ui/frontend/src/map/ship-groups.ts alongside the existing in-space point primitive.

Phase 21. Sciences — CRUD List + Designer

Status: done (local-ci run 30).

Goal: define and manage sciences (named mixes of tech proportions summing to 1.0) through a table view and a designer, plus surface them in the planet production picker.

Artifacts:

  • ui/frontend/src/lib/active-view/table-sciences.svelte — sciences list with sort / filter / Delete, mounted by the existing routes/games/[id]/table/[entity] catch-all when entity === "sciences".
  • ui/frontend/src/lib/active-view/designer-science.svelte — designer with four percent inputs (step="0.1", range [0, 100]), live sum readout, strict sum-equals-100 gate, and a read-only view mode for the existing routes/games/[id]/designer/science/[[scienceId]] route.
  • ui/frontend/src/sync/order-types.ts extends with CreateScience and RemoveScience command variants (the original plan mentioned UpdateScience; the wire only carries Create + Remove, so the decision below replaces Update with Remove).
  • ui/frontend/src/lib/util/science-validation.ts — the TS-side mirror of pkg/calc/validator.go.ValidateScienceValues plus the entity-name rules and the percent → fraction conversion.
  • ui/frontend/src/api/game-state.ts — adds ScienceSummary, localScience on GameReport, decoder, and overlay branches for createScience / removeScience.
  • ui/frontend/src/lib/inspectors/planet/production.svelte — the Research sub-row gains one button per defined science; click emits setProductionType("SCIENCE", "<name>").
  • topic doc ui/docs/science-designer-ux.md covering the percent input model, validation, and the planet-production-picker integration.

Dependencies: Phase 17.

Decisions during stage:

  1. UpdateScience was a planning error: the wire schema (pkg/schema/fbs/order.fbs) only carries CommandScienceCreate + CommandScienceRemove. Sciences are write-once on the wire — the designer's view mode therefore has no Save-edits affordance, and an "edit" is a Remove + Create sequence the player drives manually. Mirrors Phase 17's ship-class pattern.
  2. The production-picker integration places science buttons inside the existing Research sub-row, alongside the four tech buttons, instead of adding a fifth top-level segment. A science wins over a same-named tech display when the engine sends an ambiguous production string (a science named Drive shadows the Drive tech button).
  3. Designer inputs are percentages (step="0.1", [0, 100]) with a strict sum-equals-100 gate (SUM_EPSILON_PERCENT = 1e-3), not auto-rebalanced fractions. The user controls the sum; the designer converts to fractions only on Save before dispatching createScience.

Acceptance criteria:

  • the user can create and delete sciences (no in-place edit — see decision 1);
  • proportions are entered as one-decimal percentages and the four must sum to exactly 100 for Save to enable;
  • the planet production picker (Phase 15) lists the user's sciences in the Research sub-row and lets the user select one for research production;
  • name validation matches rules.txt constraints (length, allowed characters, special characters not at start/end, no triple repeats).

Targeted tests:

  • Vitest unit tests for percent-range validation, sum-equals-100 gate, and percent → fraction conversion (tests/science-validation.test.ts);
  • Vitest component tests for the table (tests/table-sciences.test.ts) and the designer (tests/designer-science.test.ts);
  • Playwright e2e: create a science, set a planet's production to it via the Research sub-row, delete it (tests/e2e/sciences.spec.ts).

Phase 22. Races View — War/Peace Toggle and Votes

Status: done.

Goal: list other races with their visible stats, expose the war/peace toggle, and the voting UI.

Artifacts:

  • ui/frontend/src/lib/active-view/table-races.svelte table mounted by the dispatcher in ui/frontend/src/lib/active-view/table.svelte (same pattern as Phase 21's sciences table). One row per non-extinct other race carrying name, tech levels (drive / weapons / shields / cargo as percent), total population, total production (engine industry), planet count, votes received, and the local player's stance toward that race. The richer per-race projection (GameReport.races: ReportOtherRace[]) is decoded in ui/frontend/src/api/game-state.ts by walking report.player[] once and surfacing the row alongside the existing otherRaces: string[] (which keeps backing the ship-group transfer picker from Phase 20)
  • per-row segmented WAR | PEACE control. The active stance is highlighted (aria-pressed=true + contrast colour); the inactive button queues setDiplomaticStance (engine CommandRaceRelation). The displayed stance is the local player's relation toward the named race (rules.txt "(R) Ваше отношение к указанной расе, но не наоборот") — not the other way round
  • voting control: a single <select> populated with races[].name, changing it queues setVoteRecipient (engine CommandRaceVote). Disabled when the local player is the only non-extinct race. A read-only myVotes total renders next to the picker
  • explanatory note in the page header: alliance grouping and the 2/3 victory check are tallied on the server at turn cutoff and are NOT projected on the client. The report carries each race's votes received (Player.votes) and the local player's outgoing vote (Report.vote_for), but foreign races' outgoing votes are intentionally private, so a client-side vote graph would be partial. The acceptance criterion "vote counts match server state byte-for-byte" forbids a local recomputation

Cross-stack notes:

  • No backend / wire changes. CommandRaceRelation, CommandRaceVote, Player.relation, Player.votes, Report.votes, and Report.vote_for already carry every datum this stage needs
  • TS draft store (ui/frontend/src/sync/order-draft.svelte.ts) gains two collapse rules: setDiplomaticStance collapses by acceptor (one stance intent per opponent); setVoteRecipient collapses singleton (a single outgoing vote slot per rules.txt:1066)
  • The optimistic overlay (applyOrderOverlay) flips races[i].relation and myVoteFor immediately so the controls reflect the queued intent without waiting for the auto-sync round-trip. votesReceived, myVotes, and the alliance state stay server-authoritative

Dependencies: Phase 14.

Acceptance criteria:

  • the user can toggle war / peace and change vote recipient;
  • the per-row stance and the "I vote for" picker reflect the queued intent immediately (optimistic overlay) and resolve to applied in the sidebar order tab after the auto-sync round-trip;
  • vote counts match server state byte-for-byte (no client tally).

Targeted tests:

  • Vitest component test (ui/frontend/tests/table-races.test.ts) covering: render rows from a canonical fixture, filter, sort flip, stance click + collapse-by-acceptor, vote pick + singleton collapse, empty state;
  • Playwright e2e (ui/frontend/tests/e2e/races.spec.ts): open the races table, toggle one row's stance, change the vote recipient, observe both commands as applied in the sidebar order tab and verify the decoded gateway payload.

Phase 23. Reports View — Current Turn Sections

Status: done (local-ci run 2).

Goal: present every section of the current turn's report as readable panels, mirroring the structure documented in rules.txt and docs/FUNCTIONAL.md §6.4.

Artifacts:

  • ui/frontend/src/routes/games/[id]/report/+page.svelte scrollable layout with one section per report category (galaxy summary, votes, player status, my sciences, foreign sciences, my ship classes, foreign ship classes, battles, bombings, approaching groups, my planets, ships in production, cargo routes, foreign planets, uninhabited planets, unknown planets, my fleets, my ship groups, foreign ship groups, unidentified groups)
  • per-section anchor navigation in a sticky sidebar for quick jumping
  • a back to map action visible at all times

Dependencies: Phase 11.

Acceptance criteria:

  • every report section renders for a seeded game with non-empty data;
  • empty sections render explicit empty-state copy;
  • scroll position resets when switching to another view and is restored on return.

Targeted tests:

  • Vitest component tests for one representative section per data shape (table, list, sub-table);
  • Playwright e2e: open the report, scroll to each section via anchor navigation, assert content present.

Decisions during stage:

  1. Component decomposition. The orchestrator lib/active-view/report.svelte is one file; each of the twenty sections is its own component under lib/active-view/report/section-<slug>.svelte. Six distinct data shapes (kv-list, races-style grid, planets-style grid, sub-table- per-race, raw UUID list, fleet/group grids) sit too unevenly in one monolith; per-section components also map directly onto the Vitest targeted-test seam. No shared <Section> abstraction was extracted — CLAUDE.md "wait for the third real caller" still holds with one shape per section. Shared formatters live in report/format.ts.
  2. races vs players. A parallel GameReport.players: ReportPlayer[] was added (full roster, self row included, extinct rows kept with extinct: true). The Phase 22 races[] (non-extinct, self excluded) stays untouched so no Phase 22 surface had to change. Extinct races are shown in Player Status with a RIP marker; the orchestrator highlights the local row.
  3. Scroll save / restore. Wired through SvelteKit's Snapshot API on routes/games/[id]/report/+page.svelte. Captures window.scrollY (the in-game shell layout expands its active-view-host to fit content, so the document body is the real scroll container) and restores via a requestAnimationFrame poll that waits for documentElement.scrollHeight to catch up before calling window.scrollTo. The earlier plan to track the host's scrollTop did not survive contact with the layout's no-explicit-height contract; the change is contained to the route file. No new context plumbing was introduced.
  4. Active-section highlight. IntersectionObserver rooted on the viewport (root: null) with rootMargin: "-30% 0px -60% 0px" tracks which section sits in the upper third of the visible area and updates the TOC. Cheaper than a scroll handler and degrades gracefully where IO is not available.
  5. Mobile TOC. A sticky <select> at the top of the report body replaces the desktop anchor sidebar on viewports below 768 px. No new overlay primitive is introduced; the existing layout-owned bottom-tab bar stays unobstructed. Picking an option scrolls the chosen section into view.
  6. Battles section. Battle UUIDs render as inactive monospace <span> rows until Phase 27 lights up /games/:id/battle/:battleId. The earlier plan to link them now was reverted: a dead link is a worse experience than a plain identifier, and the rewire when Phase 27 lands is one line.
  7. Foreign sciences / ship classes layout. One sub-table per race with a {race} sciences / {race} ship classes sub-header. The (race, name) decoder sort produces stable groups; cross-race sorting is intentionally avoided (it would be semantically meaningless across races).
  8. Bombings wiped state. Wiped rows get a .wiped CSS class plus a dedicated report-bombing-wiped-badge element so the boolean is visually explicit and easy to assert in e2e.
  9. Ships in production prodUsed derivation (Go side). The legacy text reports do not carry the engine's per-turn ProdUsed field — only Cost, Percent, Free. The legacy parser derives an approximation as ShipBuildCost(shipMass, material, resources) * percent using a new shared helper pkg/calc.ShipBuildCost. The engine's controller.ProduceShip was refactored to call the same helper (behavior-preserving — engine tests stay unchanged and pass). The approximation is documented in tools/local-dev/legacy-report/README.md; live engine reports come over FBS and never flow through this parser.
  10. Legacy parser scope. Per user direction, the parser was extended to populate LocalScience, OtherScience, OthersShipClass, Bombing, and ShipProduction from their legacy text sections. Battles stay in the parser's Skipped list: the legacy text carries per-battle rosters with no stable UUID, and synthesising IDs would invent data Phase 27 would have to drop. OtherGroup[], UnidentifiedGroup[], and cargo routes remain skipped (no legacy section).
  11. i18n namespace. All Phase 23 strings live under game.report.section.<slug>.*; the duplicate-looking entries (sciences / ship classes columns) are deliberately separate from game.table.* so the two surfaces evolve independently. ≈90 new keys, en + ru in lockstep.

Phase 24. Push Events — Turn-Ready

Status: done.

Goal: subscribe to the server push stream and refresh client state when a turn-ready event arrives.

Artifacts (delivered):

  • ui/frontend/src/api/events.svelte.ts — single SubscribeEvents consumer per session. Absorbs the previous revocation-watcher.ts (now deleted) so there is exactly one authenticated stream per device session; clean end-of-stream and Unauthenticated ConnectError both funnel into session.signOut("revoked"). Exposes a connectionStatus rune for the future header indicator.
  • ui/frontend/src/lib/toast.svelte.ts and toast-host.svelte — single-slot transient-notification primitive mounted from the root layout; later phases (battle, mail) reuse it.
  • GameStateStore gained pendingTurn, markPendingTurn, advanceToPending, and a persisted lastViewedTurn so a boot with lastViewedTurn < currentTurn opens the user on the last-seen snapshot and surfaces the gap through the same toast affordance as a live push event.
  • Backend producer: lobby.Service.OnRuntimeSnapshot emits game.turn.ready on every current_turn advance, addressed to every active membership, idempotency key turn-ready:<game_id>:<turn>, payload {game_id, turn}. Catalog routes it through the push channel only.
  • Mandatory event-signature verification through ui/core: core.verifyPayloadHash + core.verifyEvent on every frame. Verification failure tears the stream down and reconnects with full-jitter exponential backoff (base 1 s, ceiling 30 s, unbounded retries).
  • Topic doc: ui/docs/events.md.

Dependencies: Phases 23, 4 (Connect streaming in gateway).

Decisions baked back in (this stage):

  • Minimum traffic on game.turn.ready. The event flips gameState.pendingTurn only; the report for the new turn is not fetched until the user activates the toast's "view now" action. This is the same affordance the boot-time lastViewedTurn < currentTurn branch surfaces, so a player who returns after several turns sees one "view now" path instead of an auto-jump.
  • Revocation-watcher folded into events.svelte.ts. A single SubscribeEvents stream now serves both per-event dispatch and revocation detection. Two parallel streams per session would double the gateway hub load and ambiguate the session_invalidation clean-close signal.
  • Integration test scope. Backend producer is covered by lobby/runtime_hooks_test.go (testcontainers); UI consumer by tests/events.test.ts and the Playwright e2e in tests/e2e/turn-ready.spec.ts. A dedicated integration/turn_ready_flow_test.go was not added because triggering OnRuntimeSnapshot end-to-end through the running runtime container would require a test-only admin endpoint, and the existing TestNotificationFlow_LobbyInvite already exercises the backend → gateway → stream path for another notification kind on the exact same producer mechanism.

Acceptance criteria (met):

  • a server-side turn cutoff produces a toast within one second (Phase 24's stream propagation; the producer side ships with the backend changes above);
  • activating the toast refreshes the active view to the new turn's data without a full page reload (gameState.advanceToPending → fresh lobby.my.games.list + user.games.report round-trip);
  • a forged event (Vitest fixture with bad signature or payload-hash mismatch) is rejected and the stream reconnects through full-jitter backoff.

Targeted tests (delivered):

  • Vitest: tests/events.test.ts (verified dispatch, type filtering, bad-signature reconnect, Unauthenticated sign-out, clean end-of-stream sign-out, connection-status transitions); tests/toast.test.ts; extensions in tests/game-state.test.ts for pendingTurn / lastViewedTurn / advanceToPending.
  • Backend: internal/notification/catalog_test.go (kind + channels); internal/lobby/runtime_hooks_test.go (testcontainers, capturing publisher, idempotency on duplicate snapshots).
  • Playwright: tests/e2e/turn-ready.spec.ts (signed game.turn.ready frame surfaces the toast, manual dismiss clears it).

Phase 25. Sync Protocol — Turn Cutoff, Conflict, Auto-Pause

Status: done.

Goal: make the order draft survive transient connectivity issues and the real turn-cutoff machinery, with explicit user feedback on conflicts and on admin-pause states. The phase is intentionally cross-module: the UI side leans on a backend turn-cutoff guard and auto-pause that did not exist before; both land together so the contract is end-to-end.

Decisions baked in during implementation:

  • Turn-cutoff enforcement lives in backend (not in game-engine). The scheduler flips runtime_status to generation_in_progress before each engine tick and back to running after; the user-games handlers reject every command/order in non-running runtime states.
  • A failed engine tick auto-pauses the game (running → paused) through lobby.OnRuntimeSnapshot, and the lobby publishes a matching game.paused push event. Admin resume remains the only way out of paused.
  • The wire-level error codes are turn_already_closed (cutoff conflict) and game_paused (paused / starting / finished / removed). Gateway carries them through projectUserBackendError unchanged.
  • The UI draft store delegates to a new OrderQueue (single-slot pending, single retry on reconnect via onOnline callback). On game.turn.ready after a conflict / pause, the layout calls OrderDraftStore.resetForNewTurn which wipes the draft and re-hydrates from the server for the new turn (old commands are preserved server-side and can be read back via user.games.order.get?turn=N).

Backend artifacts:

  • backend/internal/notification/catalog.go: new KindGamePaused = "game.paused" and catalog/SupportedKinds entries; matching NotificationGamePaused constant in backend/internal/lobby/lobby.go; CHECK-constraint widened in backend/internal/postgres/migrations/00001_init.sql.
  • backend/internal/lobby/runtime_hooks.go: nextStatusFromSnapshot flips running → paused on engine_unreachable / generation_failed; new publishGamePaused mirrors publishTurnReady, idempotency key paused:<game_id>:<turn>, payload {game_id, turn, reason}.
  • backend/internal/runtime/scheduler.go: tick wraps the engine call with generation_in_progress / running flips and forwards failure snapshots to lobby through Service.publishFailureSnapshot.
  • backend/internal/runtime/service.go: CheckOrdersAccept plus the pure OrdersAcceptStatus helper used by both Orders and Commands user-games handlers.
  • backend/internal/server/httperr/httperr.go: new CodeTurnAlreadyClosed, CodeGamePaused; openapi.yaml ErrorBody.code enum extended.
  • backend/internal/server/handlers_user_games.go: requireOrdersOpen runs before forwarding, maps sentinels to HTTP 409 + the matching code.

UI artifacts:

  • ui/frontend/src/sync/order-queue.svelte.ts (new) — OrderQueue class with offline detection, classification of turn_already_closed / game_paused, dependency-injected online probe + event listeners. Pure-function helper classifyResult reused from tests.
  • ui/frontend/src/sync/order-types.tsCommandStatus gains conflict.
  • ui/frontend/src/sync/order-draft.svelte.ts — wires OrderQueue through runSync, adds conflict / paused / offline to SyncStatus, plus conflictBanner / pausedBanner runes, markPaused, resetForNewTurn, clearConflictForMutation, sticky-paused guard in hydrateFromServer. bindClient(client, { getCurrentTurn }) lets the conflict banner interpolate the turn number.
  • ui/frontend/src/lib/sidebar/order-tab.svelte — renders conflict / paused banners and the new conflict per-row badge; status bar carries the offline / conflict / paused copy.
  • ui/frontend/src/lib/i18n/locales/{en,ru}.ts — new keys for sync.{offline,conflict,paused}, conflict.banner (with {turn} interpolation) plus banner_no_turn fallback, paused.banner, status.conflict.
  • ui/frontend/src/routes/games/[id]/+layout.svelte — subscribes to game.paused; game.turn.ready handler now triggers resetForNewTurn when the prior syncStatus was conflict / paused. bindClient is invoked with getCurrentTurn: () => gameState.currentTurn.
  • ui/docs/sync-protocol.md (new) — send-loop semantics, retry budget, conflict and paused UX, recovery paths.
  • ui/docs/order-composer.md — stale Phase 25 paragraph replaced with a pointer to the new topic doc; state-machine diagram extended with the conflict transition.

Dependencies: Phases 14, 24; backend notification / lobby / runtime modules.

Acceptance criteria:

  • submitting an order while offline queues it and submits successfully on reconnect (one attempt on the next online event, no inline retry storm);
  • a turn cutoff between draft and submit produces a visible conflict banner with the turn number; the local draft is preserved until the next game.turn.ready, then the layout wipes it and re-hydrates from the server for turn = N+1;
  • a runtime failure during generation flips the game into paused, emits game.paused, and the order tab shows the pause banner; submits are blocked until the next game.turn.ready clears the state;
  • the order tab clearly distinguishes draft, valid, invalid, submitting, applied, rejected, and conflict states per command.

Targeted tests:

  • Backend: runtime_hooks_unit_test.go for nextStatusFromSnapshot, orders_accept_test.go for the per-record decision, plus existing testcontainer-backed runtime_hooks_test.go covering the published intent. Catalog / event tests extended with game.paused.
  • UI Vitest: tests/order-queue.test.ts (classification + offline plumbing), extended tests/order-draft.test.ts (conflict marks commands, mutation clears banner, pause blocks sync, offline holds + flushes on online, resetForNewTurn re-hydrates), extended tests/order-tab.test.ts (banner DOM + sync-status attribute), extended tests/events.test.ts (game.paused dispatch).
  • Playwright e2e: tests/e2e/order-sync.spec.ts — conflict banner on turn_already_closed reply and paused banner on the signed game.paused frame.

Phase 26. History Mode

Status: done. Verified on local-ci run 6 (success, 2d17760).

Goal: let the user navigate to past turns and view all data as it was, with no order composition allowed.

Decisions baked in during implementation:

  1. History state lives in GameStateStore, no separate module. The Phase 12 plan-line "introduce lib/history-mode.ts" is superseded: the only consumer needs a one-line derivation (historyMode = $derived(gameState.historyMode)), and the project's compactness rule rejects an abstraction with no second caller. The store ships two distinct turn runes — currentTurn (server's authoritative latest, set by setGame / advanceToPending) and viewedTurn (what the UI displays, set by viewTurn / returnToCurrent) — plus the derived historyMode rune that flips when viewedTurn < currentTurn.
  2. OrderDraftStore gates mutations at one chokepoint. bindClient gains an optional getHistoryMode: () => boolean alongside the existing getCurrentTurn; add / remove / move return early when it reports true. Every Phase 1422 inspector that calls orderDraft.add(...) becomes inert in history mode without per-component edits.
  3. Turn navigator UX. Header replaces the static turn N text with ← turn N →: arrows step ±1 (disabled at 0 and currentTurn), the middle button opens a dropdown of every turn Turn #0Turn #currentTurn with the current row carrying a badge. No free-text input. Desktop uses an absolute popover under the header; mobile reuses view-menu.svelte's fixed- drawer pattern (no new primitive). Selecting the current row routes through returnToCurrent() so the "leave history" path has one canonical entry.
  4. History is ephemeral across reloads. last-viewed-turn is written only when viewedTurn === currentTurn; historical excursions never advance the resume bookmark. Page reload exits history mode. The visibility-refresh listener is a no-op while historyMode is true so a tab-focus event cannot silently kick the user back onto the live turn. Push events (Phase 24) continue to deliver new-turn notifications, so the pending-turn toast still appears.
  5. Past-turn report cache. New game-history/{gameId}/turn/{N} namespace stores past-turn reports; viewTurn(N) reads cache first and falls back to the network on miss. Past turns are immutable so the cache has no TTL and no eviction. The current turn deliberately skips the cache (it is mutable until the next tick).
  6. Order overlay short-circuits in history mode. RenderedReportSource.report returns the raw server snapshot instead of running applyOrderOverlay: the draft is composed against the current turn, projecting it onto a past report would render fictional intent.
  7. game.shell.headline removed. The Phase 11 i18n key that formatted {race} @ {game}, turn {turn} is deleted; the header composes race @ game in plain text and delegates turn N to turn-navigator.svelte. The existing game-shell-headline testid moves to the .left wrapper so e2e specs that match toContainText("turn N") continue to find the substring inside the navigator's button.

Artifacts (delivered):

  • ui/frontend/src/lib/game-state.svelte.tsviewedTurn rune, derived historyMode rune, viewTurn(turn) / returnToCurrent() public methods, loadTurn(turn, { isCurrent }) refactor that gates last-viewed-turn writes, readReport cache layer over the game-history namespace, visibility-refresh short-circuit in history mode, initSynthetic keeps currentTurn === viewedTurn.
  • ui/frontend/src/sync/order-draft.svelte.tsbindClient accepts getHistoryMode, add / remove / move no-op when active.
  • ui/frontend/src/lib/rendered-report.svelte.ts — overlay short- circuit when gameState.historyMode === true.
  • ui/frontend/src/lib/header/turn-navigator.svelte (new) — header triplet ← turn N → + dropdown popover / drawer, reuses view-menu.svelte's outside-click / Escape pattern.
  • ui/frontend/src/lib/header/history-banner.svelte (new) — sticky read-only banner under the header with a Return to current turn action.
  • ui/frontend/src/lib/header/header.svelte — embeds <TurnNavigator /> next to the race-and-game identity span; drops the static turn portion.
  • ui/frontend/src/routes/games/[id]/+layout.sveltehistoryMode derived rune, getHistoryMode passed to orderDraft.bindClient, <HistoryBanner /> mounted between header and body.
  • ui/frontend/src/lib/i18n/locales/{en,ru}.ts — new game.shell.history.* and game.shell.turn.* keys; the now- unused game.shell.headline entry is removed.
  • ui/docs/storage.mdgame-history namespace row; also adds the game-prefs/{gameId}/last-viewed-turn row (Phase 11 doc gap).
  • ui/docs/game-state.md — current/viewed-turn rune table, the new History mode section.
  • ui/docs/navigation.md — describes the navigator, the read-only banner, and the historyMode derivation wiring.
  • ui/docs/order-composer.md — notes the mutation gate, the overlay short-circuit, and the cross-doc references.
  • Vitest: tests/game-state.test.ts extended with viewTurn / returnToCurrent / historyMode derivation / cache hit / visibility-refresh short-circuit / resume-from-stale-bookmark flips; tests/order-draft.test.ts extended with the history- mode gate cases; tests/turn-navigator.test.ts and tests/history-banner.test.ts (new) cover the components in isolation.
  • Playwright: tests/e2e/history-mode.spec.ts (new) — drives the full chrome flow against /games/<id>/table/planets. The map view is deliberately avoided because the Pixi renderer can monopolise the headless Chromium main thread long enough to let the toContainText poll find stale "turn ?" content; the table view exercises the same wiring without that rendering tail.

Dependencies: Phases 11, 12, 23.

Acceptance criteria:

  • selecting a past turn from the navigator switches every view to that turn's data;
  • order tab disappears from the sidebar; calculator tab remains available;
  • returning to the current turn restores live data and re-shows the order tab with the prior draft intact (state preservation);
  • battle / mail stub views still render correctly while the read-only banner is visible (Phases 27 / 28 will replace the stubs with real implementations; the wiring is sufficient today).

Targeted tests:

  • Vitest unit tests for current/viewed turn rune split, view-turn cache behaviour, visibility-refresh short-circuit, order-draft history-mode gate, turn-navigator interactions, history-banner rendering / action;
  • Playwright e2e: enter history mode via arrow, navigate via dropdown, return via banner action, confirm the order draft survives the round-trip.

Phase 27. Battle Viewer

Status: done (local-ci run 14).

Goal: ship a dedicated Battle Viewer rendering radial scenes from BattleReport data (planet centred, races on the outer ring, per ship-class clusters, animated shot lines), plus battle and bombing markers on the map. Battles and bombings stay strictly separate — bombings remain a static table in the Reports view, only battles get the animated viewer.

Artifacts:

  • engine: game/internal/router/handler/battle.go for GET /api/v1/battle/:turn/:uuid (handler pre-existed; Phase 27 added the tests + openapi schemas)
  • engine wire: pkg/model/report/battle.go ships a new BattleSummary{id, planet, shots}; Report.battle carries a slice of these summaries so the map can place markers without fetching every full report
  • backend: backend/internal/engineclient/client.go.FetchBattle and backend/internal/server/handlers_user_games.go.Battle expose GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}
  • UI viewer: ui/frontend/src/lib/battle-player/ (radial-layout.ts, timeline.ts, battle-scene.svelte, playback-controls.svelte, battle-viewer.svelte); SVG-based, one frame per protocol entry, full controls (play/pause + step back + step forward + rewind + 1x/2x/4x speed switch)
  • UI route + page wrapper: ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte feeds gameId / turn / battleId into ui/frontend/src/lib/active-view/battle.svelte, which loads the report via api/battle-fetch.ts (synthetic-fixture path + real engine fetch through the backend gateway)
  • UI report link: lib/active-view/report/section-battles.svelte now links every battle UUID into /games/{id}/battle/{uuid}?turn={turn}
  • UI map markers: ui/frontend/src/map/battle-markers.ts emits a yellow X cross per battle (two LinePrim through the planet's bounding-square diagonals; stroke width scales 1px..5px with protocol length) plus a stroke-only ring per bombing (yellow when damaged, red when wiped). Wired into state-binding.ts; the map click handler dispatches battle clicks to the viewer and bombing clicks to a scroll-into-view of the matching row in Reports.
  • topic doc ui/docs/battle-viewer-ux.md covers playback semantics, accessibility (the always-visible <ol> log), the radial layout, and the marker click behaviour
  • docs/FUNCTIONAL.md §6.5 (Battle viewer) + mirror in docs/FUNCTIONAL_ru.md

Dependencies: Phase 23.

Acceptance criteria:

  • battle and bombing markers render on the map for the seeded current-turn report and are clickable: battle → Battle Viewer for the corresponding UUID, bombing → scroll to its row in Reports;
  • the Battle Viewer plays back any BattleReport end-to-end with step back / step forward / rewind / 1x-2x-4x speeds; observers (inBattle === false) are not drawn; eliminated races drop out and survivors re-distribute on the next frame;
  • the same protocol is mirrored as an always-visible text log under the scene for accessibility;
  • bombings keep their Phase 23 static table layout in Reports; no Battle Viewer entry-point is wired from them.

Targeted tests:

  • Vitest unit: radial layout (1/2/3 races) and timeline frame- builder (initial state, shot decrement, race-elimination drop-out) in tests/battle-player.test.ts
  • Vitest unit: marker primitives + stroke-width formula (1→1, 50→2.98, 100→5, 200→5) in tests/battle-markers.test.ts
  • Go unit: engine HTTP handler validations (400 / 404 / 500) in game/internal/router/battle_test.go
  • Go contract: openapi freezes for the new endpoint and schemas in game/openapi_contract_test.go
  • Playwright e2e: click battle marker → viewer; play / step back; click battle UUID in Reports → viewer; click bombing marker → Reports bombings row scrolled into view.

Decisions during stage:

  1. Bombings stay a static table. section-bombings.svelte already covers the "who bombed, with what power, wiped or not" requirement; nothing in Phase 27 touches it. Bombings explicitly do not open the Battle Viewer.
  2. Wire change. Report.Battle switched from []uuid.UUID to []BattleSummary{id, planet, shots} so the map renderer can place markers without N extra fetches and so the cross-marker stroke can scale with protocol length.
  3. Battle marker = yellow X cross drawn as 2 LinePrim through the corners of the planet's circumscribed square; stroke width clamp(1 + (shots - 1) * 4 / 99, 1, 5) px.
  4. Bombing marker = stroke-only ring slightly larger than the planet circle. Yellow when damaged, red when wiped. Click = scroll to the matching row in Reports (not the viewer).
  5. Viewer URL /games/[id]/battle/[battleId]?turn=N. Turn is a query param so the same route works in history mode.
  6. SVG, not PixiJS for the radial scene — isolated component, no need for WebGL; PixiJS stays as the map renderer.
  7. Playback controls full set: play / pause + step back + step forward + rewind + 1x / 2x / 4x switch. 1x = 400 ms per frame.
  8. Observer groups (inBattle: false) are filtered out of both the scene and the text log.
  9. Cluster aggregation by (race, className) so a race with multiple groups of the same class collapses to one labelled circle. Stable target for shot-line endpoints.
  10. Page loader switches on synthetic- gameId prefix — synthetic mode uses api/synthetic-battle.ts fixtures; live games hit GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}. BattleViewer component itself is a logically isolated prop sink.
  11. Always-visible <ol> text protocol under the scene satisfies the accessibility requirement without a separate "skip animation" toggle.

TODO carried into Phase 27 deferred items (see Phase 27 of this PLAN's deferred-followups list, near the bottom):

  • push event game.battle.new + toast deep-link;
  • richer ship-class visuals derived from class characteristics;
  • animated transitions when survivors re-distribute after an elimination (currently hard-jumps).

Phase 28. Diplomatic Mail View

Status: done (CI gate passed on run 136 — go-unit / ui-test / integration all green at commit 6d0272b).

Decisions baked in during implementation:

  1. Transport: ConnectRPC user.games.mail.*. Eight new authenticated commands (inbox / sent / message.get / send / broadcast / admin / read / delete) plumbed end-to-end through the existing gateway → backend REST surface. Schemas in pkg/schema/fbs/diplomail.fbs; constants in pkg/model/diplomail/diplomail.go; gateway translation in gateway/internal/backendclient/mail_commands.go.
  2. Recipient by race name. The send / admin endpoints accept an alternative recipient_race_name field; backend resolves it via Memberships.ListMembers(gameID, "active"). The UI feeds the picker straight off report.races[].name — no client-side memberships RPC.
  3. sender_race_name snapshot. New nullable column on diplomail_messages, populated for sender_kind='player' senders that have an active membership at send time. Drives the per-race threading on the client.
  4. /sent returns full message detail. Backend's bulk sent listing now returns the same UserMailMessageDetail shape as /inbox, one row per (message, recipient). The UI collapses broadcasts by message_id into a single stand-alone item.
  5. Threading + stand-alones. MailStore.entries groups personal messages by the other party's race name. System, admin, and outgoing broadcasts render as stand-alone items in the same list pane.
  6. No read receipts. read_at and deleted_at drive the badge counter and soft-delete affordance but are never shown to the user.
  7. Header badge. Inline pill on the view-menu "diplomatic mail" row, fed by mailStore.unreadCount. No always-visible chrome added.
  8. Push event reuse. A new eventStream.on("diplomail.message.received", …) handler in routes/games/[id]/+layout.svelte parses the verified payload, refreshes the inbox, and raises a toast.show with a "view" deep-link.
  9. Translation toggle. Per-message Show original / Show translation toggle inside both thread-pane.svelte and system-item-pane.svelte; the body defaults to the cached translation when present.

Artifacts (delivered):

  • backend: internal/postgres/migrations/00001_init.sql, internal/diplomail/{types.go,store.go,service.go,admin_send.go,diplomail_e2e_test.go,README.md}, internal/server/{handlers_user_mail.go,handlers_admin_diplomail.go}, openapi.yaml;
  • wire: pkg/schema/fbs/diplomail.fbs + generated Go and TS bindings; pkg/model/diplomail/diplomail.go;
  • gateway: gateway/internal/backendclient/{mail_commands.go,routes.go,mail_commands_test.go}, gateway/cmd/gateway/main.go;
  • ui: ui/frontend/src/api/diplomail.ts, ui/frontend/src/lib/mail-store.svelte.ts, ui/frontend/src/lib/active-view/mail.svelte (+ subdir mail/{thread-list,thread-pane,system-item-pane,compose,system-titles}.svelte|.ts), ui/frontend/src/lib/header/view-menu.svelte, ui/frontend/src/routes/games/[id]/+layout.svelte, ui/frontend/src/lib/i18n/locales/{en,ru}.ts;
  • docs: ui/docs/diplomail-ui.md, docs/FUNCTIONAL.md §11.4 + mirror in docs/FUNCTIONAL_ru.md.

Original phase brief follows.

Goal: implement a mail inbox and compose flow as a dedicated view that replaces the map.

Artifacts:

  • ui/frontend/src/routes/games/[id]/mail/+page.svelte two-pane on desktop (list + reading), one-pane stack on mobile
  • compose form for new messages targeting any other race in the game
  • inbox sorted by arrival time, with read/unread state persisted via Cache
  • push-event integration: new mail surfaces a toast and increments an unread badge in the header

Dependencies: Phases 22, 24.

Acceptance criteria:

  • the user can read incoming mail, compose new mail, and reply to mail end-to-end;
  • unread state persists across reloads;
  • server-side delivery confirmations appear on the message thread.

Targeted tests:

  • Vitest component tests for the compose form including field validation;
  • Playwright e2e: send a message between two seeded players, confirm arrival.

Phase 29. Map Toggles

Status: pending.

Goal: deliver the gear-icon control for hiding categories of map content and switching between torus and no-wrap view modes. All toggleable categories are already rendered by earlier phases; this phase only exposes the controls.

Artifacts:

  • ui/frontend/src/lib/active-view/map-toggles.svelte gear icon in the map view's corner; popover (desktop) / bottom sheet (mobile)
  • two sections inside the popover:
    • object visibility: hyperspace groups, incoming groups, cargo routes, reach / visibility zones, battle and bombing markers
    • view options: wrap scrolling (torus / no-wrap)
  • planets are always rendered and not toggleable
  • ui/frontend/src/lib/map/reach-zones.ts implementation of reach / visibility zone overlays, off by default (the only category not yet rendered by earlier phases)
  • toggle state persists per game in Cache

Dependencies: Phases 9 (no-wrap engine), 11 (planets), 16 (cargo routes), 19 (groups, incoming), 27 (battle markers).

Acceptance criteria:

  • toggling each object visibility category hides or shows the corresponding objects on the map within one frame;
  • switching wrap scrolling switches the renderer between torus and no-wrap mode without losing camera position when possible;
  • toggle state persists across reloads per game;
  • the gear popover is reachable on mobile through a comfortable tap target (≥ 44 px).

Targeted tests:

  • Vitest component tests for toggle state persistence;
  • Vitest unit tests for reach-zone rendering on torus and no-wrap fixtures;
  • Playwright e2e in desktop and mobile viewports: toggle each category and the wrap scrolling, assert visual change.

Phase 30. Calculator Tab

Status: pending.

Goal: ship an independent calculator in the sidebar, callable from any view, exposing the full set of pkg/calc/ functions wired through Core.

Artifacts:

  • ui/frontend/src/lib/sidebar/calculator-tab.svelte UI with mode selector (ship calculator, path calculator, modernization cost, bombing power) and per-mode forms
  • bridge entries in ui/core/calc/ for any function not already wrapped by Phase 18
  • topic doc ui/docs/calculator-ux.md documenting modes, layouts, and the rule that calculator inputs persist across navigation

Dependencies: Phase 18.

Acceptance criteria:

  • every calculator mode produces results identical to direct pkg/calc/ calls;
  • inputs persist across view switches per global state-preservation rule;
  • calculator works in history mode against the snapshot's tech levels.

Targeted tests:

  • Vitest snapshot tests per mode on canonical inputs;
  • Playwright e2e: switch modes, confirm input persistence.

Phase 31. Wails Desktop Wrapper

Status: pending. Re-evaluate Wails v2 vs v3 at phase start.

Goal: build a native desktop app for macOS, Windows, and Linux that runs the same frontend bundle and replaces the WASM core with embedded Go code.

Artifacts:

  • topic doc ui/docs/wails-version.md recording the v2-vs-v3 decision made at phase start with rationale
  • ui/desktop/main.go Wails entry point
  • ui/desktop/app.go IPC bindings exposing ui/core API to the WebView through a structured adapter
  • ui/desktop/keychain/ per-OS secure-storage helpers (macOS Keychain via Security framework, Windows DPAPI, Linux Secret Service / file fallback at ~/.config/galaxy/keypair with mode 0600)
  • ui/desktop/sqlite/ modernc.org/sqlite cache wired through Wails IPC
  • ui/frontend/src/platform/core/wails.ts WailsCore adapter
  • ui/frontend/src/platform/store/wails.ts WailsKeyStore and WailsCache adapters
  • ui/desktop/build/icon.icns macOS app icon
  • ui/desktop/build/icon.ico Windows app icon
  • ui/desktop/build/icon.png Linux app icon
  • ui/Makefile targets desktop-mac, desktop-win, desktop-linux
  • topic doc ui/docs/desktop-secure-storage.md documenting the Linux/Windows file fallback for missing keychains

Dependencies: Phase 6 (KeyStore and Cache interfaces); Phases 7 through 30 in their web form (the desktop wrapper exercises the same TypeScript code).

Acceptance criteria:

  • the macOS, Windows, and Linux binaries each launch, complete login, and preserve the keypair across restarts on a fresh user profile;
  • a single source codebase produces all three OS bundles;
  • the same Core and Storage TypeScript interfaces are satisfied as on web, with no platform-specific code outside platform/;
  • Linux file fallback activates when Secret Service is absent and writes with 0600 permissions.

Targeted tests:

  • Go unit tests for each keychain helper, including file fallback;
  • desktop e2e smoke test driven by Wails headless mode running the Phase 7 login Playwright scenario via CDP;
  • regression test: keychain absence on a Linux container without libsecret falls back to file storage.

Phase 32. Capacitor Mobile Wrapper

Status: pending.

Goal: build native iOS and Android apps that run the same frontend bundle and call into a gomobile-compiled ui/core.

Artifacts:

  • ui/mobile-bridge/bridge.go gomobile-friendly façade over ui/core
  • ui/Makefile target gomobile producing Galaxy.framework and galaxy.aar
  • ui/mobile/capacitor.config.ts Capacitor project configuration
  • ui/mobile/plugins/galaxy-core/ custom Capacitor plugin (Swift + Kotlin) wrapping the gomobile artifacts
  • ui/frontend/src/platform/core/capacitor.ts CapacitorCore adapter
  • ui/frontend/src/platform/store/capacitor.ts CapacitorKeyStore and CapacitorCache using @capacitor-community/secure-storage-plugin and @capacitor-community/sqlite
  • ui/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/ iOS app icon set
  • ui/mobile/android/app/src/main/res/mipmap-*/ Android app icon set
  • iOS launch screen and Android splash screen
  • ui/Makefile targets ios and android
  • topic doc ui/docs/mobile-bridge.md describing the plugin API, marshalling strategy, and the manual smoke procedure for this phase

Dependencies: Phase 6; Phases 7 through 30 in their web form.

Acceptance criteria:

  • both the iOS Simulator and an Android Emulator launch the app, complete login, and preserve the keypair across restarts (validated by manual smoke);
  • the same Core and Storage TypeScript interfaces are satisfied as on web and desktop;
  • gomobile build produces deterministic outputs reproducible in CI on a macOS runner.

Targeted tests:

  • Go unit tests for the mobile-bridge façade;
  • Capacitor plugin unit tests on iOS (XCTest) and Android (Espresso);
  • manual smoke procedure: login flow on iOS Simulator and Android Emulator, recorded in ui/docs/mobile-bridge.md. Full Appium automation lands in Phase 36 as part of the acceptance pass.

Phase 33. PWA — Service Worker, Manifest, Web Icons

Status: pending.

Goal: make the web build installable and offline-tolerant on every browser. Native packaging icons live with their respective wrapper phases (31 for desktop, 32 for mobile) — this phase is web-only.

Artifacts:

  • ui/frontend/src/service-worker.ts cache-first asset strategy with stale invalidation on app update
  • ui/frontend/static/manifest.webmanifest PWA manifest
  • ui/frontend/static/icons/ web icon set sized per manifest.webmanifest requirements
  • topic doc ui/docs/pwa-strategy.md covering update flow and offline scope

Dependencies: Phase 25 (offline order queue).

Acceptance criteria:

  • the web app installs as a PWA on Chrome, Edge, and iOS Safari;
  • the service worker survives an app update without serving stale code on the next reload.

Targeted tests:

  • Lighthouse PWA audit at score ≥ 90;
  • Playwright test: install the app, take it offline, verify the cached login route still loads;
  • regression test: bumping the app version invalidates the prior service worker.

Phase 34. Multi-Turn Projection — Single-Turn Forecast and Range Circles

Status: pending. Long-term scope deferred but this phase ships real features.

Goal: ship two concrete projection features (planet next-turn forecast and ship-designer reach circles) plus the transient map-overlay back-stack mechanism that the reach-circles feature is the first user of.

Artifacts:

  • ui/frontend/src/lib/projection/ minimal projection engine that computes one-turn-ahead state for a single planet using pkg/calc/
  • planet inspector forecast section showing next-turn population, industry, materials stockpile, and production progress
  • ui/frontend/src/lib/navigation/transient-overlay.ts push/pop back-stack mechanism for map overlays driven by other views, with a back-button affordance on the map that returns to the originating view with state preserved
  • ship-designer Preview range on map action that pushes a transient overlay onto the map showing concentric reach circles for 1, 2, 3, 4 turns from a chosen origin, computed from the in-progress ship design and the player's current Drive tech via ui/core/calc/
  • topic doc ui/docs/multi-turn-projection.md describing the long-term vision (multi-turn planning mode, scenario branches) and the phased path to it

Dependencies: Phases 17, 18.

Acceptance criteria:

  • the planet inspector shows a forecast section with next-turn values matching pkg/calc/ outputs;
  • the ship-designer Preview range on map button transitions to the map with reach circles drawn from the chosen origin; back returns to the designer with all in-progress state intact;
  • the transient overlay is cleared if the user navigates to any other view via the header dropdown.

Targeted tests:

  • Vitest unit tests for the projection engine on canonical fixtures;
  • Vitest unit tests for the transient-overlay push/pop logic and state preservation;
  • Playwright e2e: open a planet inspector, observe one-turn forecast; open a ship designer, click Preview range on map, see reach circles, click back, return with state intact.

Phase 35. Polish — Accessibility, Localisation, Error UX

Status: pending.

Goal: prepare the client for technical beta with end-user-quality polish.

Artifacts:

  • ui/frontend/src/lib/i18n/ translation bundles for English and Russian, covering every visible string
  • ui/frontend/src/lib/error/ central error surface with stable codes and retry / escalation guidance
  • accessibility audit results recorded under ui/docs/a11y.md
  • keyboard-only navigation paths for lobby, game view, and login
  • focus rings, ARIA labels, screen-reader-only text where needed
  • mobile bottom-sheet swipe-down dismissal and tap-outside dismissal, on top of the close button shipped in Phase 13
  • selected-planet visual on the map (ring or halo), wired off the Phase 13 SelectionStore

Dependencies: Phase 33.

Acceptance criteria:

  • WCAG 2.2 AA compliance on lobby, login, and the in-game shell per axe-core scan;
  • the entire UI is reachable by keyboard only with visible focus rings;
  • every server-side error is mapped to a translated, actionable user message in both languages;
  • locale switch persists across reloads on every platform.

Targeted tests:

  • axe-core integration tests on every top-level view;
  • Vitest tests for the i18n bundle structure and missing-translation detection;
  • Playwright keyboard-only navigation tests.

Phase 36. Acceptance Pass

Status: pending.

Goal: reconcile implementation, documentation, and regression coverage before declaring the client ready for technical beta.

Artifacts:

  • updated ui/README.md, topic docs, and any drift in docs/ARCHITECTURE.md or docs/FUNCTIONAL.md (mirrored to docs/FUNCTIONAL_ru.md)
  • final cross-platform regression run on a release-candidate build
  • ui/docs/release-checklist.md for repeatable releases
  • visual regression baselines committed under ui/frontend/tests/__snapshots__/; if maintenance proves heavy, follow-up issue to switch to self-hosted Argos
  • Appium harness for iOS Simulator and Android Emulator covering the login flow, push-event flow, and at least one full turn loop; .gitea/workflows/ui-release.yaml extended with macOS-runner Appium job (mandatory pre-release gate)

Dependencies: Phases 1 through 35.

Acceptance criteria:

  • implementation matches every documented contract and live topic doc;
  • the cross-cutting regression scenarios listed below pass on web, desktop, and mobile;
  • Appium smoke passes on both iOS and Android in CI.

Targeted tests:

  • run focused package tests for ui/core and every TypeScript module;
  • rerun cross-platform Playwright suites against release-candidate builds;
  • run Tier 2 visual regression baselines;
  • run Appium smoke suites on iOS and Android.

Cross-Cutting Regression Scenarios

  • A fresh device generates a keypair, completes email-code login, and successfully signs a follow-up authenticated request on every target platform.
  • A returning device resumes its session without re-login, preserves queued orders, and continues receiving push events without gaps.
  • Server-side session revocation tears down the active push stream and forces a re-login on every target platform within one second.
  • Tampering with payload_bytes, payload_hash, request_id, message_type, or any signature byte is rejected by the verifier in ui/core with a stable error code.
  • Requests outside the freshness window are rejected before they reach network, and the client surfaces a clock-skew warning when its local clock disagrees with the server time event by more than the freshness window.
  • The map renderer holds 60 fps with a 1000-primitive fixture on mid-range hardware on web (Chrome, Edge, Safari, Firefox), desktop (Wails on macOS, Windows, Linux), and mobile (latest iPhone, mid- range Android).
  • The single-tool sidebar preserves state across tab switches; the active view preserves state across view switches; designers preserve their in-progress state when navigating to the map and back through a transient overlay.
  • Order draft is preserved across page reloads, view switches, network drops, and history-mode entry / exit.
  • Orders queued offline are flushed in order on reconnect; a turn- cutoff conflict surfaces as a clearly failed-order banner without retrying forever.
  • History mode applies to every view; the order tab disappears in history mode and the prior draft is restored on return to the current turn.
  • The ship-class designer's calculations match pkg/calc/ byte-for- byte; any drift between client mirror and server fails CI.
  • Linux desktop builds without Secret Service still complete login by falling back to the 0600 file under ~/.config/galaxy/.
  • The web service worker invalidates correctly on app update and never serves stale code on the first load after a deploy.
  • Push-event signature verification is mandatory; any verification failure tears down the stream and reconnects with backoff.
  • Locale switch persists across reloads and applies to every visible string on every platform.

TODO — deferred follow-ups from Phases 1-5

These items are explicit decisions to defer, not unknown work. Each should be picked up either as a follow-up patch or folded into the phase listed in the parenthesis when that phase lands.

  • Build core.wasm in CI, drop the committed artefacts — install TinyGo on the Gitea Actions runner (brew install tinygo is not available on Linux runners, so use the official tarball or curl … | tar -xz step), add make -C ui wasm ahead of the Vitest step in .gitea/workflows/ui-test.yaml, then remove ui/frontend/static/core.wasm and ui/frontend/static/wasm_exec.js from the repo and re-tighten ui/.gitignore. Phase 5 committed the binaries only as a stop-gap so contributors did not have to install TinyGo. (Phase 5 cleanup, blocks before Phase 33 PWA.)
  • Restore js.CopyBytesToGo when TinyGo fixes the instanceof Uint8Array check — the per-element loop in ui/wasm/main.go::copyBytesFromJS is a workaround for TinyGo 0.41 panicking on Uint8Arrays whose prototype chain crosses Node's Buffer. Track upstream (https://github.com/tinygo-org/tinygo/issues) and revert the helper once a release is pinned. (Phase 5 follow-up.)
  • Migrate TS codegen to Connect-ES v2 BSR plugin once publishedui/buf.gen.yaml runs protoc-gen-es v2 locally because buf.build/connectrpc/es is still on v1.6.1 and emits v1-incompatible imports. When the v2 plugin lands on the BSR, we can either keep the local plugin (no network dep) or move back to the remote, depending on whether buf.build rate limits are hit in CI. (Phase 5 follow-up; revisit when next regenerating.)
  • Rename gateway/internal/grpcapi/gateway/internal/connectapi/ — the package now hosts a Connect-Go listener that natively serves Connect, gRPC, and gRPC-Web; the grpcapi name is historical. Touches imports in gateway/cmd/gateway/main.go and a couple of cross-package refs. Pure rename, no behaviour change. (Phase 4 cleanup; do alongside the next gateway change.)
  • Rename GATEWAY_AUTHENTICATED_GRPC_* env vars to drop the GRPC infix — they label the authenticated-edge tier, not the wire protocol. Affects gateway/internal/config/, the integration testenv defaults in integration/testenv/gateway.go, the README, and the runbook. Coordinated with the package rename above. (Phase 4 cleanup; not before the env vars are referenced by external operators.)
  • Add a Docker-stack integration test for Connect end-to-end — Phase 4 closed with service-level Connect tests only. Once a phase already brings up the full stack (Phase 7 onward, since auth flow needs backend), drop a integration/connect_call_test.go that exercises a unary Connect call and a server-streaming Connect call through testenv.Bootstrap. (Phase 7+, fold into the phase that needs it.)
  • Battle viewer — push event game.battle.new — when a battle involving the current player lands, emit a backend notification intent (idempotency battle-new:<game_id>:<turn>:<battle_id>, payload {game_id, turn, battle_id}) so the in-game shell surfaces a toast with a deep link into the Battle Viewer. (Phase 27 deferred; needs an engine emit-side change.)
  • Battle viewer — richer ship-class visuals — current MVP draws one small circle plus <class>:<numLeft> label per (race, className) pair. Future work derives shape / scale from mass, armament, shields, and the number of ships in the group. (Phase 27 deferred.)
  • Battle viewer — animated re-distribution on elimination — current implementation hard-jumps to the new spacing on the next frame; replace with an easing so the survivors visibly slide along the outer ring. (Phase 27 deferred.)