Eight ship-group operations land on the inspector behind a single inline-form panel: split, send, load, unload, modernize, dismantle, transfer, join fleet. Each action either appends a typed command to the local order draft or surfaces a tooltip explaining the disabled state. Partial-ship operations emit an implicit breakShipGroup command before the targeted action so the engine sees a clean (Break, Action) pair on the wire. `pkg/calc.BlockUpgradeCost` migrates from `game/internal/controller/ship_group_upgrade.go` so the calc bridge can wrap a pure pkg/calc formula; the controller now imports it. The bridge surfaces the function as `core.blockUpgradeCost`, which the inspector calls once per ship block to render the modernize cost preview. `GameReport.otherRaces` is decoded from the report's player block (non-extinct, ≠ self) and feeds the transfer-to-race picker. The planet inspector's stationed-ship rows become clickable for own groups so the actions panel is reachable from the standard click flow (the renderer continues to hide on-planet groups). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
137 KiB
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/coreas a compute-only library (canonical bytes, sign/verify, FlatBuffers codec, keypair, thin bridge topkg/calc/) compiled to WASM, gomobile, and Wails-embedded native; - TypeScript-side
Coreinterface with three adapters (WasmCore,WailsCore,CapacitorCore) selected at build time; GalaxyClienton top ofCoreperforms 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
KeyStoreandCacheTypeScript 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.modof the parent module; TinyGo for WASM must supportcrypto/ed25519andcrypto/sha256. If TinyGo support is insufficient, fall back to standard GoGOOS=js GOARCH=wasmwith 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-webfor 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
pnpmworkspace atui/; 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/clientGo module is deprecated in full. New code does not import from it; this includesclient/world/, which is no longer the reference algorithm for the TypeScript renderer. Existing types inpkg/model/client/are not migrated; UI types are written from scratch inui/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 inui/core/calc/wraps selectedpkg/calc/functions for theCoreAPI. New shared math is added topkg/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
discardactions 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 serverReport, 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 intools/local-dev/legacy-report/, which converts legacy text reports undertools/local-dev/reports/into the shape ofpkg/model/report.Report(whatever subset the UI currently decodes). Every phase that extends the server→UI report contract — adding decoding for a newReportfield inui/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'sREADME.mdthat the field cannot be derived from the legacy text format and is left empty in synthetic JSON. The point is to keeptools/local-dev/legacy-report/a faithful (and type-checked, viapkg/model/reportimport) 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 (768–1024 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→/lobbyafter successful confirm-email-code./lobby→/games/:id/mapwhen a game card is selected.- Any view →
/loginimmediately 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.mdmirroring the architectural overview from this planui/Makefilewith placeholder targets for every build type (web,wasm,gomobile,desktop-{mac,win,linux},ios,android,all)ui/pnpm-workspace.yamldeclaring the single-package pnpm workspaceui/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.svelteminimal landing page rendering the app version string in the page footer; the version is read at build time by Vitedefinefromui/frontend/package.jsonui/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 2ui/.gitignorecoveringnode_modules,dist,*.wasm, build outputs for Wails and Capacitor, Playwright artefactsui/docs/empty directory ready for per-phase topic docs
Dependencies: none.
Acceptance criteria:
pnpm install && pnpm devfromui/frontendstarts a dev server that serves the landing page at a free local port;makelists every planned build target as a placeholder;ui/README.mdlists the five target platforms, the layered architecture, and points readers to per-phase topic docs underui/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.jsondev-dependencies (added on top of the Phase 1 minimum ofvitest,jsdom,@testing-library/svelte):@testing-library/jest-dom,playwright,@playwright/testui/frontend/vitest.config.tsextended withsetupFiles: ["./tests/setup.ts"]to wire@testing-library/jest-dommatchers into Vitest (the JSDOM environment itself is wired in Phase 1)ui/frontend/tests/setup.tsregisteringjest-dommatchersui/frontend/tests/e2e/landing.spec.tsplaceholder Playwright test asserting the version footer rendersui/frontend/playwright.config.tswith four projects:chromium-desktop,webkit-desktop,chromium-mobile-iphone-13,chromium-mobile-pixel-5; tracing and screenshots enabled on failure;webServer: pnpm run devon port 5173.gitea/workflows/ui-test.yamlrunning Tier 1 on every push and PR on a Linux runner: monorepo Go service tests forbackend/,gateway/,game/, and everypkg/<name>/module (each pkg module is enumerated explicitly because they sit as independent go.work modules under a sharedpkg/directory, and./pkg/...does not recurse across module boundaries). All Go tests run with-count=1so the cache never masks a failing run; backend tests additionally run with-p 1because most backend packages spawn their own Postgres testcontainer and parallel bootstraps starve each other on the runner. The integration suite stays gated behindmake -C integration integrationand lives outside Tier 1; the deprecatedclient/Fyne client (see §74) is also excluded — its tests, code, and documentation are frozen and CI must not run them. Thenpnpm install --frozen-lockfilefromui/,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.yamlrunning 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.mdtopic doc naming the two tiers, the tools per tier, and the rule that visual regression baselines live inui/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-desktopandwebkit-desktopprojects 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-desktopandwebkit-desktopprojects; - 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.modmodulegalaxy/coredeclared in the project Go workspace (go.workuseandreplacedirectives).gitea/workflows/ui-test.yamland.gitea/workflows/ui-release.yamlextended to add./ui/core/...to the Tier 1 / Tier 2go testcommand list introduced in Phase 2ui/core/canon/canonical bytes forgalaxy-request-v1,galaxy-response-v1, andgalaxy-event-v1, matchingdocs/ARCHITECTURE.md§15 byte-for-byte. Server-only signers (Ed25519ResponseSigner, PKCS#8 PEM loaders) intentionally stay ingateway/authn—ui/coreis verify-only on the server sideui/core/keypair/Ed25519 generate, marshal, unmarshal helpers over opaque[]byteblobs;Generateaccepts an injectedio.Readerso the WASM build can wire incrypto.getRandomValuesui/core/types/full v1 transport-envelope structs withSigningFields()projection helpers; result-code and protocol-version constants (ProtocolVersionV1,ResultCodeOK).TraceIDis 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 eventui/core/README.mddocumenting the public API and the network-free / storage-free / no-x509 / no-PEM / no-osinvariantgateway/authn/parity_with_ui_core_test.go(cross-module test) proving canonical-bytes parity and bidirectional sign/verify acceptance betweengateway/authnandgalaxy/core. The test addsrequire galaxy/coretogateway/go.mod(test-only in practice — gateway production binary does not linkui/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/coreis accepted by the gateway's own verifier in a unit test (TestParityRequestSignedByUICoreAcceptedByGateway); - a response signed by
gateway/authn'sEd25519ResponseSigneris accepted byui/core's verifier (TestParityResponseSignedByGatewayAcceptedByUICore); the same applies to gateway-signed events; - tampered
payload_hash, mismatchedrequest_id, mismatchedtimestamp_ms, and invalid signature length are rejected with stable error codes fromui/core/canon. Server-side freshness enforcement (the symmetric ±5 minutes around server time) stays ingateway/internal/grpcapi/freshness_replay.goand is not duplicated inui/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, mismatchedrequest_id, mismatchedtimestamp_ms, invalid signature lengths (too short, too long, empty), bit-flipped signature, wrong public key, malformed base64 public key; gateway/authncross-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.yamlextended withbuf.build/connectrpc/go, generatinggateway/proto/galaxy/gateway/v1/gatewayv1connect/edge_gateway.connect.gogateway/internal/grpcapi/server.gorewritten aroundhttp.Serverh2c.NewHandler+gatewayv1connect.NewEdgeGatewayHandler
- new
gateway/internal/grpcapi/connect_handler.goadapting the existinggatewayv1.EdgeGatewayServerdecorator stack to the Connect handler interface, including agrpc.ServerStreamingServershim around*connect.ServerStream[GatewayEvent]and a gRPCstatus.Error→*connect.Errortranslation helper - new
gateway/internal/grpcapi/connect_observability.goConnect 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 ofpeer.FromContext - updated
gateway/README.md(Transport Matrix + "Authenticated Edge Surface"),gateway/docs/runtime.md,gateway/docs/flows.md,gateway/docs/runbook.md, anddocs/ARCHITECTURE.md§15 - migrated tests:
gateway/internal/grpcapi/server_test.go,test_fixtures_test.go, and every*_integration_test.goin that package now drive agatewayv1connect.EdgeGatewayClientover HTTP/2 cleartext loopback - migrated harness:
integration/testenv/grpc_client.go→connect_client.go.SignedGatewayClientkeeps the same public shape (Execute,SubscribeEvents,Close) but speaks Connect internally;Is*helpers now useconnect.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.goandcommand_routing_integration_test.goscenarios driving the Connect client over loopback h2c; - server-streaming
SubscribeEventsworks over Connect with the signedgateway.server_timebootstrap event delivered first — verified byTestSubscribeEventsValidEnvelopeSendsBootstrapEventAndWaitsForCancellation; - 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.NewServer→s.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
WasmCore Adapter, GalaxyClient SkeletonStatus: 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:
- TinyGo as primary toolchain.
core.wasmlands at 903 KB — well under the 1 MB acceptance bar. TheGOOS=js GOARCH=wasmfallback path stays documented inui/docs/wasm-toolchain.md. Core.signRequestreturns canonical bytes only. No private key inside WASM; Phase 6 plugs WebCrypto's non-exportable keys at the orchestration layer.GalaxyClienttakes a pluggableSignerso Phase 5 tests pass a fixture-key signer and Phase 6 swaps in WebCrypto without touching the orchestration.- TS codegen runs locally, not against buf.build BSR. A new
ui/buf.gen.yamlinvokesfrontend/node_modules/.bin/protoc-gen-es(added as a devDependency). This sidesteps BSR rate limiting and removes the network dependency from the codegen step. - Field naming is camelCase end-to-end. Both the TS
Coreinterface and the Go bridge inui/wasm/main.gouse camelCase field names; there is no snake-case translation layer.
Artifacts (delivered):
ui/wasm/main.goTinyGo entry point onglobalThis.galaxyCorewith four functions:signRequest,verifyResponse,verifyEvent,verifyPayloadHash.ui/Makefilewasmandts-protostargets.ui/buf.gen.yamlwith 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— typedCoreinterface plus aloadCore()resolver (Phase 5 ships only the WASM adapter).ui/frontend/src/platform/core/wasm.ts—WasmCoreadapter for browsers; the JSDOM test path lives next to it inui/frontend/tests/setup-wasm.ts.ui/frontend/src/api/connect.ts— typed Connect-Web transport +EdgeGatewayClientfactory.ui/frontend/src/api/galaxy-client.ts—GalaxyClientskeleton with injectedSignerandSha256dependencies.ui/frontend/src/proto/galaxy/gateway/v1/edge_gateway_pb.ts(generated) andui/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 sanitysignRequestcheck 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 fromui/core/canon/testdata/), andtests/galaxy-client.test.ts(orchestration through a mockCoreandcreateRouterTransportfrom@connectrpc/connect). - Topic doc
ui/docs/wasm-toolchain.md. ui/README.mdrepository-layout block.
Dependencies: Phases 2, 3, 4.
Acceptance criteria (met):
make wasmproducescore.wasmdeterministically under 1 MB (903 KB measured);WasmCore.signRequestproduces 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);WasmCoreexposes the sameCoreTypeScript types futureWailsCoreandCapacitorCoreadapters will satisfy.
Targeted tests (delivered):
- Vitest unit tests for
WasmCorecalling each public method with the WASM module loaded in JSDOM viatests/setup-wasm.ts; - Vitest unit tests for
GalaxyClientusing a mockCoreand the in-memorycreateRouterTransport; - Vitest tests asserting
WasmCore.signRequestoutput 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:
- 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.
- 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/ed25519code path. The full matrix and rationale live inui/docs/storage.md.
Artifacts:
ui/frontend/src/platform/store/index.ts— publicKeyStore,Cache,DeviceKeypairinterfaces and theloadStore()resolver, with no web-specific imports in any public signatureui/frontend/src/platform/store/idb.ts— sharedgalaxy-uiIndexedDB connection (typed viaidb'sDBSchema) used by both the keystore and the cacheui/frontend/src/platform/store/idb-cache.ts— IndexedDB-backedCachekeyed by compound[namespace, key]ui/frontend/src/platform/store/webcrypto-keystore.ts— WebCrypto non-exportable Ed25519 key generation, structured-cloned through IDBui/frontend/src/platform/store/web.ts— theloadWebStorefactory wired intoloadStoreui/frontend/src/api/session.tsthin layer withloadDeviceSession,setDeviceSessionId,clearDeviceSessionui/frontend/src/routes/__debug/store/+page.svelte(++page.tswithprerender = false; ssr = false;) — dev-only debug surface the Phase 6 Playwright spec drives throughwindow.__galaxyDebug- topic doc
ui/docs/storage.mddescribing 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;
KeyStoreandCacheinterfaces have full TypeScript types and zero web-specific imports in their public signatures.
Targeted tests:
- Vitest unit tests for
IDBCachewithfake-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 thatclearDeviceSessionforces 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:
- Playwright e2e against a mocked gateway.
page.route(...)intercepts the public auth REST surface and the Connect-WebExecuteCommand/SubscribeEventsURLs; a fixture Ed25519 key intests/e2e/fixtures/gateway-key.tssigns the forged responses soGalaxyClient.verifyResponseaccepts them under the matching public key the dev server picks up viaVITE_GATEWAY_RESPONSE_PUBLIC_KEY. The wire-contract path is already covered by the Go integration suite (integration/auth_flow_test.go). - 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. - 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_invalidationevent) and a ConnectUnauthenticatederror. Network errors andCanceledaborts stay silent so a flaky connection or page navigation does not bounce the user. The per-event dispatch path lands in Phase 24. - 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.tswithprerender = 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 authenticateduser.account.getthroughGalaxyClientand surfaces the decoded display name. The placeholder usedJSON.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 decodeAccountResponsevia 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.tsputs the whole tree into SPA mode (ssr = false; prerender = false;).ui/frontend/src/api/auth.ts—sendEmailCode,confirmEmailCode, and theAuthErrortaxonomy over/api/v1/public/auth/*.ui/frontend/src/lib/env.ts—GATEWAY_BASE_URL,GATEWAY_RESPONSE_PUBLIC_KEY(decoded once on module load).ui/frontend/src/lib/session-store.svelte.ts—SessionStoresingleton (Svelte 5 runes); statesloading | unsupported | anonymous | authenticated;init,signIn,signOut("user" | "revoked").ui/frontend/src/lib/revocation-watcher.ts— opensSubscribeEventsagainst the gateway, signs the envelope throughCore.signRequest, treats clean stream end /Unauthenticatedas revocation.ui/frontend/.env.example—VITE_GATEWAY_BASE_URL,VITE_GATEWAY_RESPONSE_PUBLIC_KEY.- Topic doc
ui/docs/auth-flow.md; cross-references fromui/docs/storage.mdandui/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 intests/e2e/fixtures/{gateway-key,canon,sign-response}.ts. - Pre-existing
tests/e2e/landing.spec.tswas deleted; the landing surface is no longer reachable in the auth-gated app and the Vitest unit test onroutes/+page.svelteretains 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 throughWasmCore→GalaxyClient→ ConnectRPC and the response signature is verified underVITE_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 bytests/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,AuthErrormapping). - 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:
- Cross-stack catalog extension. Phase 8 expands the lobby
command catalog beyond
lobby.my.games.listandlobby.game.open-enrollment(the only routes shipped before this phase). Seven new authenticated message types now flow throughgateway/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 inpkg/schema/fbs/lobby.fbs, its Go request/response struct inpkg/model/lobby/lobby.go, and its transcoder pair inpkg/transcoder/lobby.go. - 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.
- Submit-application UX. Per FUNCTIONAL.md §4.2, joining a
public game means submitting an application that lands in the
pendingstate until the owner approves. The button label isSubmit 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. - TS-side FlatBuffers integration. The placeholder lobby in
Phase 7 used
JSON.parse(TextDecoder)to read theuser.account.getpayload; that was a mismatch with the gateway's FlatBuffers transcoder and only worked under mocked tests. Phase 8 adds aflatbuffersruntime dep toui/frontend/package.json, afbs-tsMake target inui/Makefilethat drivesflatc --ts, and checks the generated bindings intoui/frontend/src/proto/galaxy/fbs/{lobby,user}/. Phase 7'suser.account.getdecode is rewritten to use those bindings as part of this phase, fixing the wire-format gap. - Create-game form scope. The form keeps
game_name,description,turn_schedule(5-field cron), andenrollment_ends_atalways 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 toprivateregardless 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 gamescards routing to/games/:id/map(placeholder until Phase 10, §4.5),pending invitationscards with Accept / Decline (§4.3),my applicationscards with localised pending / approved / rejected status (§4.5), andpublic gamescards with an inline race-name form behind aSubmit applicationbutton (§4.2). Convention follows the Phase 7 login page — singlemax-width: 32remcap, no@mediaqueries.ui/frontend/src/routes/lobby/create/+page.svelte(++page.tswithssr = 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 aroundGalaxyClient.executeCommandfor all eight lobby commands plus aLobbyErrorclass that surfaces canonical lobby error codes (invalid_request,subject_not_found,forbidden,conflict,internal_error).ui/frontend/src/api/galaxy-client.ts—executeCommandnow returns{ resultCode, payloadBytes };lobby.tsuses the result-code branch to throwLobbyError.pkg/model/lobby/lobby.go— seven new message-type constants and matching request/response structs.pkg/schema/fbs/lobby.fbs—PublicGamesListRequest,PublicGamesListResponse,ApplicationSummary,MyApplicationsListRequest,MyApplicationsListResponse,InviteSummary,MyInvitesListRequest,MyInvitesListResponse,GameCreateRequest,GameCreateResponse,ApplicationSubmitRequest,ApplicationSubmitResponse,InviteRedeemRequest,InviteRedeemResponse,InviteDeclineRequest,InviteDeclineResponsetables. ReusedGameSummaryforGameCreateResponse.gameandMyGamesListResponse.pkg/transcoder/lobby.go— encode/decode pairs for all new types plus shared helpersencodeApplicationSummary,decodeApplicationSummary,encodeInviteSummary,decodeInviteSummary,unixMilliFromOptional. ReusesencodeGameSummary/decodeGameSummaryfrom 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 intoLobbyRoutes.ui/frontend/src/proto/galaxy/fbs/{lobby,user}/...— generated TS FlatBuffers bindings (regenerated frompkg/schema/fbs/*.fbsvia thefbs-tsMake target, checked into the repo like the Connect bindings).ui/Makefile— newfbs-tstarget.ui/frontend/package.json—flatbuffersruntime dep.ui/frontend/src/lib/i18n/locales/{en,ru}.ts— fulllobby.*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) withtests/e2e/fixtures/lobby-fbs.tsbuilding forged FlatBuffers payloads through the same generated bindings the production code uses. The Phase 7 spec was migrated to the same fixture souser.account.getis now FlatBuffers end-to-end. - Phase 7 e2e specs were updated to
click → fillthe readonly inputs (the readonly attribute is the documented Safari autofill-suppression workaround;fillchecks 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 anhttptest.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-13andchromium-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.tsdata 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 mappingui/frontend/src/map/math.tsgeometry primitives:torusShortestDelta,distSqPointToSegment,clamp, andscreenToWorld/worldToScreenround-trip transformsui/frontend/src/map/render.tsPixiJS v8 scene graph driven bypixi-viewport@^6for pan/zoom/pinch with WebGPU/WebGL backend selection viaApplication.init({ preference }); torus wrap is rendered through nine container copies at(±W, 0) × (±H, 0)ui/frontend/src/map/hit-test.tsbrute-force hit-test pass over the world primitives with[-priority, distSq, kindOrder, id]ordering and torus-shortest distance in'torus'modeui/frontend/src/map/no-wrap.tscamera clamp helpers (clampCameraNoWrap,minScaleNoWrap,pivotZoom) for bounded plane mode;pixi-viewport'sclamp/clampZoomplugins are used at the renderer level with a centring hook on'moved'so the viewport-larger-than-world case stays centredui/frontend/src/map/fixtures.tsdeterministic 1000-primitive sample world used by the playground and by manual perf checksui/frontend/src/routes/__debug/map/+page.sveltedevelopment page rendering the fixture world with a mode switch between torus and no-wrap, plus awindow.__galaxyMapdebug surface for tests- topic doc
ui/docs/renderer.mdspecifying the data model, hit-test math, torus copy rule, no-wrap camera semantics, and the deprecation status ofgalaxy/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.mdas 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-desktopforces WebGPU,webkit-desktopforces 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:
- 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.sveltewrappers that mount the matchinglib/active-view/<name>.sveltestub. No separate dispatch component lives in the codebase; each route file is a two-line wrapper. - 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. - 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. - Tablet sidebar — click toggle, not swipe. The 768–1024 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.
- Mobile tool overlay —
mobileToolstate, gated by URL. The mobile bottom-tabs Calc / Order navigate to/mapand set a layout-ownedmobileToolrune. The layout's derivedeffectiveToolonly 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|orderURL param that seeds the initial tab on first mount, used by later phases that want to land directly on a particular tool. - 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. - Race-name and turn-counter placeholders. The header race
name is the static
race ?string from i18n, mirroring the spec's staticturn ?placeholder. Phase 11 wires both fromuser.games.reportdata throughlib/header/turn-counter.svelte. - Auth gate inherited. The root
+layout.sveltealready redirectsanonymous → /login; the in-game shell needs no extra guard. Phase 10 verified this by booting the e2e shell spec via__galaxyDebug.setDeviceSessionIdand observing the post-session.initauthenticatedstatus. - 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.ts—ssr=false; prerender=false;mirroring the root SPA flagsui/frontend/src/routes/games/[id]/+page.ts— redirects/games/:id→/games/:id/mapui/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 stubui/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 byi18n.setLocale; Logout callssession.signOut("user"))ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab, inspector-tab, order-tab, bottom-tabs}.svelte— three-tab sidebar withinspectordefault and?sidebar=URL seed; mobile-only bottom-tabs with[Map, Calc, Order, More]plus a More drawer duplicating the view-menu destinationsui/frontend/src/lib/sidebar/types.ts— sharedSidebarTabandMobileTooltypesui/frontend/src/lib/active-view/{map, table, report, battle, mail, designer-ship-class, designer-science}.svelte— Phase 10 stubs rendering localised titles pluscoming sooncopy with stable testids that later phases replaceui/frontend/src/lib/i18n/locales/{en,ru}.ts— fullgame.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/mapfrom 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 sooncopy, 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:
current_turnonGameSummary. The user-facinglobby.my.games.listdid not expose the runtime's current turn number, but the in-game shell needs it to fetch the matchinguser.games.report. Phase 11 extendsGameSummarywith a newcurrent_turn:int32field (FB schema, Go transcoder + model, backendgameSummaryWire, gatewaydecodeGameSummary*,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-newuser.games.statemessage (full wire-flow for a one-field response) and hard-codingturn=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.- Per-game state store with context. A
GameStateStorelives inlib/game-state.svelte.ts; the in-game shell layout instantiates one per game and exposes it through Svelte context underGAME_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. - Lobby lookup for current turn. The store does not assume the
caller passed
current_turnthrough navigation state. OnsetGame, it callslobby.my.games.listitself, finds the game record, readscurrent_turn, and then callsuser.games.report. A direct deep link to/games/:id/mapfor a game the user is not a member of flips the store toerrorwith anot in your listmessage. - Refresh on tab focus. The store installs a
visibilitychangelistener that callsrefresh()when the document becomes visible and the store isready. 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. - Wrap-mode preference.
Cachenamespacegame-prefs, key<gameId>/wrap-mode, valuestorus(default) /no-wrap. Phase 11 reads throughwrapMode;setWrapModewrites back. Phase 29 wires the toggle UI on top of these primitives. - State binding.
map/state-binding.ts::reportToWorldemits 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. - 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
RendererHandledoes not yet expose asetWorldAPI 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 areplaceWorldmethod. - e2e bootstrap reuses
__galaxyDebug. The Phase 10 pattern of seeding the device session through/__debug/storecarries over; the gateway is mocked throughpage.routeforlobby.my.games.list,user.games.report, and theSubscribeEventsstream that the revocation watcher opens (held open indefinitely so a clean end-of-stream does not triggersignOut("revoked")and bounce the test back to/login).
Artifacts (delivered):
ui/frontend/src/api/game-state.ts— typed wrapper foruser.games.reportplusuuidToHiLoand a TS-friendlyGameReportshape (planets only)ui/frontend/src/lib/game-state.svelte.ts— runes-basedGameStateStorewith init / setGame / setTurn / refresh / setWrapMode / failBootstrap / dispose; tab-focus listener;Cache-backed wrap-mode persistenceui/frontend/src/map/state-binding.ts—reportToWorldand the per-kind planet stylingui/frontend/src/lib/active-view/map.svelte— replaces the Phase 10 stub with the live renderer integration plus loading / error overlays and adata-planet-counttestid hookui/frontend/src/lib/header/turn-counter.svelte— readsstore.report.turnthrough context, falls back to the static?placeholder when the store has not yet produced a snapshotui/frontend/src/routes/games/[id]/+layout.svelte— instantiates theGameStateStore, builds theGalaxyClient, exposes the store viasetContext, disposes on unmountpkg/schema/fbs/lobby.fbs—current_turn:int32fieldpkg/schema/fbs/lobby/GameSummary.go(regenerated)pkg/transcoder/lobby.go— encode/decodecurrent_turnpkg/transcoder/lobby_test.go— non-zerocurrent_turnin the round-trip fixturepkg/model/lobby/lobby.go—CurrentTurn int32onGameSummarybackend/internal/server/handlers_user_lobby_helpers.go—gameSummaryWire.CurrentTurn+gameSummaryToWirereads it fromRuntimeSnapshot.CurrentTurn;lobbyGameDetailWireno longer redeclares the fieldbackend/openapi.yaml—current_turnon theGameSummaryschema (required); removed from theLobbyGameDetailallOf block (now inherited)gateway/internal/backendclient/lobby_commands.go—decodeGameSummaryFromGameDetailanddecodePublicGamesPageparsecurrent_turnfrom JSONui/Makefile—FBS_INPUTSaddscommon.fbs(so thecommon/uuid.tsdirectory is generated) andreport.fbsui/frontend/src/proto/galaxy/fbs/{common,report}/...— regenerated TS bindingsui/frontend/src/api/lobby.ts—currentTurn: numberonGameSummary;decodeGameSummaryreads itui/frontend/tests/lobby-{fbs,api,page}.test.tsandtests/e2e/fixtures/lobby-fbs.ts— fixtures and assertions covercurrentTurnui/frontend/tests/state-binding.test.ts— Vitest unit coverage forreportToWorld(dimensions, kinds, ids, styling, empty-planet, zero-dimension fallback, priority order)ui/frontend/tests/game-state.test.ts— Vitest coverage forGameStateStore(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 overlayui/frontend/tests/e2e/fixtures/report-fbs.ts—buildReportPayloadhelper for forging FB Report payloads- Topic doc
ui/docs/game-state.md ui/docs/lobby.md—current_turnnote pointing at the new game-state doc
Dependencies: Phases 9, 10.
Acceptance criteria (met):
- entering
/games/:id/mapfor 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.tscovers the report→world translation across every planet kind plus malformed-dimension guards;tests/game-state.test.tscovers the store lifecycle end-to-end with a stubbedlistMyGamesand a fakeGalaxyClient; - Playwright e2e:
tests/e2e/game-shell-map.spec.tsexercises 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
Cacheingame-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:
- 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.tsfiles; the draft state has to be reactive soorder-tab.sveltere-renders on add/remove/move. The artifact ships asui/frontend/src/sync/order-draft.svelte.ts, mirroring the Phase 11lib/game-state.svelte.tspattern. - Single
placeholdervariant in the discriminated union. The project compactness rule rejects defining surface for the next phase. Phase 14 ownsplanetRenameend-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. - Reorder API is
move(fromIndex, toIndex). One canonical operation; up/down at the call site is a one-line index arithmetic. NomoveUp/moveDownaliases. - Write-on-every-mutation persistence.
add/remove/moveeach callCache.putwith the full draft snapshot. Phase 25 may profile the submit pipeline and batch writes if needed; until then deterministic writes are easier to test. - Per-game scoping via Svelte context. One
OrderDraftStoreinstance per game is created inroutes/games/[id]/+layout.sveltealongsideGameStateStore, exposed throughORDER_DRAFT_CONTEXT_KEY, disposed on layout destroy. historyModeas a prop, not a module. Layout passeshistoryMode={false}(a constant in Phase 12) toSidebarandBottomTabs; both forward to their tab-bar children which omit the order entry when the flag is true. Phase 26 introduces the reallib/history-mode.tsmodule and replaces the constant in one place.- Empty-state copy is
order is empty/приказ пуст. Thecoming soonplaceholder text is replaced; per-row delete button readsdelete/удалить. - e2e seeding via
__galaxyDebug.seedOrderDraft. The existing debug surface inroutes/__debug/store/+page.svelteis extended withseedOrderDraft(gameId, commands)andclearOrderDraft(gameId)helpers that write directly to theorder-draftscache namespace. The store loads the seeded draft on the next layout mount the same way it would after a real reload. - Race / disposal hygiene mirrors
GameStateStore. Mutations are gated onstatus === "ready"so calls beforeinitresolves are no-ops, andpersistchecks adestroyedflag so in-flight writes afterdisposeresolve into nothing.
Artifacts (delivered):
ui/frontend/src/sync/order-types.ts—OrderCommanddiscriminated union (singleplaceholdervariant) andCommandStatuslifecycle type.ui/frontend/src/sync/order-draft.svelte.ts—OrderDraftStorerunes class withinit/add/remove/move/dispose, plusORDER_DRAFT_CONTEXT_KEY. Persists the full draft on every mutation under namespaceorder-drafts, key{gameId}/draft.ui/frontend/src/lib/sidebar/order-tab.svelte— replaces the Phase 10 stub. Empty state fromgame.sidebar.empty.order; ordered list with stabledata-testid="order-command-{i}"rows and a per-row delete button.ui/frontend/src/lib/sidebar/sidebar.svelte,tab-bar.svelte,bottom-tabs.svelte—historyModeprop on the sidebar forwards tohideOrderon tab-bar / bottom-tabs; active-taborderis reset toinspectorif the flag flips on, and the?sidebar=orderURL seed falls back toinspectorwhile the flag is true.ui/frontend/src/routes/games/[id]/+layout.svelte— instantiatesOrderDraftStore, sets context, runsinit({ cache, gameId })next togameState.initthrough onePromise.all, disposes on destroy, passeshistoryMode={false}down.ui/frontend/src/routes/__debug/store/+page.svelte— extendedDebugSurfacewithseedOrderDraft/clearOrderDraft.ui/frontend/src/lib/i18n/locales/{en,ru}.ts— newgame.sidebar.order.command_deletekey plus updatedgame.sidebar.empty.ordercopy.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-referencesstorage.mdandnavigation.md.ui/docs/storage.md— namespace registry row fororder-drafts.ui/docs/navigation.md— describes the historyMode prop wiring through Sidebar / BottomTabs.ui/README.md— new entry under topic docs fororder-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-draftcovering 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.svelteempty 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 resolvedReportPlanetto the planet inspector component.ui/frontend/src/lib/inspectors/planet.svelteread-only display of every planet field carried by the FBS report and documented in therules.txtplanet 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.sveltemobile-only bottom-sheet that wraps the same planet component for the < 768 px breakpoint. Visibility is gated oneffectiveTool === "map"so the sheet does not stack with the calc / order overlays.ui/frontend/src/lib/active-view/map.svelteregisters a click handler against the newRendererHandle.onClick(built onpixi-viewport'sclickedevent), translates the hit into a planet, and callsSelectionStore.selectPlanet(number).ui/frontend/src/lib/selection.svelte.tsrunes store with the selected-object union ({ kind: "planet"; id: number } | null), exposed viasetContextfrom 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.tsprojection extended to surface every planet field needed by the inspector (industryStockpile,materialsStockpile,industry,population,colonists,production,freeIndustry, plus the existingowner).ui/frontend/src/routes/games/[id]/+layout.svelteliftsactiveTabinto a layout-level rune bound into the sidebar, owns theSelectionStore, mounts the bottom-sheet, and runs the reveal$effectthat 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 syntheticReportPlanetfixtures; - 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 onchromium-desktopand asserts the sidebar inspector content, and onchromium-mobile-iphone-13asserts 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:
- 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 keepsuser.games.orderfor the wire path and adds a pure projectionapplyOrderOverlay(report, commands, statuses)inapi/game-state.ts. Inspector, mobile sheet, and map renderer read a derivedrenderedReport(context keyRENDERED_REPORT_CONTEXT_KEY) that swaps planet names in for every applied or in-flight rename. RawgameState.reportstays available for debugging / history mode. - 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 routeGET /api/v1/user/games/{game_id}/orders?turn=N(pass-through to the engine's existingGET /api/v1/order). The frontend calls it fromOrderDraftStore.hydrateFromServeronly when the local cache row is absent — an explicitly empty cache row honours the user's empty draft. Theturnquery is required (the frontend always knows the current turn from the lobby record). - Per-command results from real engine response. The engine
now answers
PUT /api/v1/orderwith202 Acceptedand a populatedUserGamesOrderbody (per-commandcmdApplied,cmdErrorCode, plus an engine-assignedupdatedAt). The gateway parses that JSON into the extended FBSUserGamesOrderResponseenvelope and the frontend reads the per-command outcome throughsubmitOrder. A defensive batch-level fallback covers an emptycommandsarray. - 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
validand the overlay re-applies. - Validator parity through a TS port.
ValidateTypeNamefrompkg/util/string.gois mirrored inui/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 everyaddand exposes per-rowvalid/invalidto the order tab. updatedAtplumbing without enforcement. Phase 14 sends0on 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— extendedUserGamesOrderResponse(game_id,updated_at,commands); newUserGamesOrderGet/UserGamesOrderGetResponsetables.pkg/model/order/order.go—MessageTypeUserGamesOrderGetandUserGamesOrderGettyped payload.pkg/transcoder/order.go—JSONToUserGamesOrder,UserGamesOrderResponseToPayload,UserGamesOrderGetToPayload,PayloadToUserGamesOrderGet,PayloadToUserGamesOrderResponse,UserGamesOrderGetResponseToPayload,PayloadToUserGamesOrderGetResponse. Replaces the oldEmptyUserGamesOrderResponsePayloadhelper.backend/internal/server/handlers_user_games.go— newGetOrdershandler.engineclient.GetOrderforwards to the engine'sGET /api/v1/orderwith the player rebound.backend/openapi.yamldocuments the new GET operation;contract_test.goextended with aqueryParamStubsmap for required query parameters.gateway/internal/backendclient/games_commands.go— updatedexecuteUserGamesOrder(parses real engine JSON viaJSONToUserGamesOrder); newexecuteUserGamesOrderGetandprojectUserGamesOrderGetResponse.gateway/internal/backendclient/routes.goregisters the new message type.ui/Makefile—order.fbsjoinsFBS_INPUTS; regenerated TS bindings underui/frontend/src/proto/galaxy/fbs/order/.ui/frontend/src/sync/order-types.ts—PlanetRenameCommandvariant added to the discriminated union.ui/frontend/src/sync/submit.ts—submitOrderposts the FBS request and parses per-command verdicts.ui/frontend/src/sync/order-load.ts—fetchOrderissuesuser.games.order.get.ui/frontend/src/sync/order-draft.svelte.ts— extended with per-commandstatuses,validate/markSubmitting/applyResults/markRejected/revertSubmittingToValid/hydrateFromServer, and theneedsServerHydrationflag.ui/frontend/src/lib/util/entity-name.ts— TS port ofValidateTypeName.ui/frontend/src/api/game-state.ts— pureapplyOrderOverlay(report, commands, statuses)projection plus thecurrentTurnrune onGameStateStore.ui/frontend/src/lib/rendered-report.svelte.ts— derives the overlay-applied report and exposes it throughRENDERED_REPORT_CONTEXT_KEY.ui/frontend/src/lib/galaxy-client-context.svelte.ts—GalaxyClientHolderso command-driven UI can resolve the per-gameGalaxyClientvia context.ui/frontend/src/lib/inspectors/planet.svelte— Rename action- inline editor with
validateEntityName-driven feedback.
- inline editor with
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.svelteandui/frontend/src/lib/active-view/map.svelte— switched torenderedReport.ui/frontend/src/routes/games/[id]/+layout.svelte— wires the rendered report and galaxy-client contexts; runsorderDraft.hydrateFromServer(...)after the bootPromise.allresolves whenneedsServerHydration.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, extendedorder-draft.test.tsandinspector-planet.test.ts. New Playwright spectests/e2e/rename-planet.spec.ts. - Documentation:
docs/ARCHITECTURE.md§9,docs/FUNCTIONAL.md§6.2 (anddocs/FUNCTIONAL_ru.mdmirror),ui/docs/order-composer.mdwith 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, clickSubmit, 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
rejectedstatus on every in-flight row, with the gateway's error message inline.
Targeted tests:
- Vitest unit tests for
submitOrder,fetchOrder,applyOrderOverlay,validateEntityName, and the extendedOrderDraftStore. - 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:
- 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 insidegame/internal/model/game/planet.goand is not exported.ui/core/calc/andui/docs/calc-bridge.mddid not exist at all. Phase 15 createsui/docs/calc-bridge.mddocumenting 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 existingfreeIndustry(free production potential) number, which is computed engine-side and ships in the report payload. - Sub-pickers expose only what the game data already supports.
"Research" sub-row shows the four implicit tech fields
(DRIVE / WEAPONS / SHIELDS / CARGO); custom
LocalScienceentries are deferred until the science designer phase introduces them. "Build Ship" sub-row showsLocalShipClassentries; theGameReportprojection is extended with a minimalShipClassSummary { name }so the e2e spec can seed one ship class and exercise the SHIP branch end-to-end. EmptyLocalShipClasscollapses to a localised "no ship classes designed yet" placeholder. - Re-clicks always emit a command. The collapse-by-
planetNumberrule keeps at most onesetProductionTypeper planet in the draft. A click that lands on the segment matchingreport.productionstill emits a command; the engine accepts repeat submits idempotently. Avoids a fragile reverse-mapping fromreport.productiondisplay strings ("Drive", ship-class name, science name) back to the FBS enum. - Inspector layout split.
ui/frontend/src/lib/inspectors/planet/ production.svelteis the new component; the parentinspectors/planet.sveltemounts it forkind === "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 forwardlocalShipClassfrom the rendered-report context.
Artifacts (delivered):
ui/frontend/src/sync/order-types.ts—SetProductionTypeCommandvariant +ProductionTypeliteral union +PRODUCTION_TYPE_VALUES/isProductionTypehelpers.ui/frontend/src/sync/order-draft.svelte.ts—validateCommandbranch (mirrors the engine'ssubject=Productionrule);addenforces collapse-by-planetNumberfor the new variant only.ui/frontend/src/sync/submit.ts— encodesCommandPlanetProducevia the newproductionTypeToFBShelper.ui/frontend/src/sync/order-load.ts— decodesCommandPlanetProduceviaproductionTypeFromFBSand skipsPlanetProduction.UNKNOWNrows.ui/frontend/src/api/game-state.ts—applyOrderOverlayrewritesplanet.productionforsetProductionType(helperproductionDisplayFromCommandmirrorsCache.PlanetProductionDisplayName); newShipClassSummarytype andGameReport.localShipClassprojection (decoded fromreport.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— acceptslocalShipClassprop, mounts<Production />for local planets, drops the static production row on that branch only.ui/frontend/src/lib/inspectors/planet-sheet.svelteandui/frontend/src/lib/sidebar/inspector-tab.svelte— forwardlocalShipClassfrom the rendered report context.ui/frontend/src/routes/games/[id]/+layout.svelte— deriveslocalShipClassand passes it to the mobile sheet.ui/frontend/src/lib/sidebar/order-tab.svelte— new label branch forsetProductionTypeusing 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 alocalShipClassfixture vector.ui/frontend/tests/e2e/fixtures/order-fbs.ts— discriminated fixture union supporting bothplanetRenameandsetProductionTypepayloads.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; newinspector-planet-production.test.tsVitest component spec; newtests/e2e/planet-production.spec.tsPlaywright spec.
Dependencies: Phase 14.
Acceptance criteria:
- changing production type adds exactly one
setProductionTypecommand to the order draft, with the engine wire shape (CommandPlanetProduce+subjectrule forSCIENCE/SHIP); - repeated changes for the same planet collapse to the latest choice
(no duplicate
setProductionTypecommands 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-
planetNumberlogic inOrderDraftStore.addand thesetProductionTypebranch ofvalidateCommand; - Vitest unit tests for the FBS encoder / decoder round-trip and the
productionDisplayFromCommandhelper; - 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 throughuser.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.tscarries thePickModeOptions/PickModeHandletypes and the purecomputePickOverlayhelper.ui/frontend/src/map/render.tsextendsRendererHandlewithsetPickMode/isPickModeActive/getPickState,onPointerMove/onHoverChange, and thegetPrimitiveAlpha(id)debug accessor. The standardonClickconsumers are gated on thepickModeActiveflag so the destination click does not also trigger planet selection.ui/frontend/src/map/hit-test.tswidens point matching to(pointRadiusPx + slopPx) / camera.scaleso hover and click zones match the visible disc; default radius shared viaDEFAULT_POINT_RADIUS_PX = 3.ui/frontend/src/lib/map-pick.svelte.tsdefines the SvelteMapPickService(promise-shapedpick(...)plus reactiveactive);lib/active-view/map.svelteconstructs the service and binds a renderer-side resolver that resolvessourcePlanetNumberagainst the current report.ui/frontend/src/lib/debug-surface.svelte.tsregistersgetMapPrimitives()andgetMapPickState()providers; the DEV-only__galaxyDebugsurface inroutes/__debug/store/+page.svelteexposes them so e2e specs can assert the renderer's state without scraping pixels.
Artifacts (Pass B — feature):
ui/frontend/src/sync/order-types.tsextends withCargoLoadType,SetCargoRouteCommand, andRemoveCargoRouteCommand.CARGO_LOAD_TYPE_VALUESis the priority order (COL,CAP,MAT,EMP).ui/frontend/src/sync/order-draft.svelte.tscollapses both variants by(sourcePlanetNumber, loadType); the newer entry supersedes any priorsetorremovefor the same slot.ui/frontend/src/sync/submit.tsandui/frontend/src/sync/order-load.tsround-trip the two new variants throughCommandPlanetRouteSetandCommandPlanetRouteRemove. UNKNOWN load-type values drop with aconsole.warn.ui/frontend/src/api/game-state.tsextendsGameReportwithroutes: ReportRoute[](decoded fromreport.route()inCARGO_LOAD_TYPE_VALUESorder) andlocalPlayerDrive: number(looked up viafindLocalPlayerDrive).applyOrderOverlayupserts / drops route entries for valid / submitting / applied cargo-route commands.ui/frontend/src/lib/inspectors/planet/cargo-routes.svelteis the four-slot subsection.Add/EditcallMapPickService.pick(...);RemoveemitsremoveCargoRoute.ui/frontend/src/map/cargo-routes.tsbuilds theLinePrimarrows (shaft + two arrowhead wings) per(source, loadType, destination)triple. Per-type style and priority (COL=8…EMP=5); ids prefixed with0x80000000to avoid colliding with planet numbers.ui/frontend/src/map/state-binding.tsappendsbuildCargoRouteLines(report)to the world primitives.ui/frontend/src/lib/active-view/map.svelteadds 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.mdquotesrules.txt(lines 808–843) and maps semantics to UI;ui/docs/renderer.mddocuments the pick-mode contract;ui/docs/calc-bridge.mdrecords 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 emitssetCargoRoute; 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(computePickOverlaylifecycle),tests/map-cargo-routes.test.ts,tests/inspector-planet-cargo-routes.test.ts,tests/state-binding.test.tsextension,tests/order-draft.test.tsextension,tests/submit.test.tsandtests/order-load.test.tsextensions,tests/order-overlay.test.tsextension. - Playwright e2e
tests/e2e/cargo-routes.spec.ts: open inspector, triggerAdd, assert dim state via__galaxyDebug.getMapPickState(), click a reachable planet, assertsetCargoRouteshipped + arrow visible via__galaxyDebug.getMapPrimitives(). Add a CAP route to confirm slots coexist; Remove COL → arrow gone; reload → restored fromuser.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.3and 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.mdPhase 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.sveltetable of ship classes with sort and filter, plus per-row Delete affordance (the existingroutes/games/[id]/table/[entity]/+page.sveltealready wires this active view through the[entity]parameter, so no new route file lands).ui/frontend/src/lib/active-view/designer-ship-class.svelterewritten 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 fromrules.txtlive inlib/util/ship-class-validation.ts(TS port ofpkg/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 existingroutes/games/[id]/designer/ship-class/[[classId]]/+page.svelteis already wired and consumes the optionalclassIdURL segment throughpage.params.ui/frontend/src/sync/order-types.tsextends withCreateShipClassCommandandRemoveShipClassCommandvariants (mapped toCommandShipClassCreateandCommandShipClassRemoveon the wire bysync/submit.tsandsync/order-load.ts).ui/frontend/src/api/game-state.tswidensShipClassSummaryto carry the full attribute set;applyOrderOverlayprojects pending Save / Delete actions ontolocalShipClassso 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.txtconstraints 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.gothin Go bridge wrapping seven functions frompkg/calc/ship.go—DriveEffective,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 composefull-load massfromCargoCapacitywithout injecting math into TS;ui/wasm/main.goregisters the seven wrappers underglobalThis.galaxyCore;ui/frontend/src/platform/core/index.tsextendsCorewith the matching typed methods (emptyMassandweaponsBlockMassreturnnumber | null, mirroring the Go(_, false)validator path);ui/frontend/src/api/game-state.tsextendsGameReportwithlocalPlayerWeapons,localPlayerShields,localPlayerCargoalongside the existinglocalPlayerDrive. The decoder reads all four from thePlayerrow in the report's player block. Phases 19-21 reuse these fields without re-extending the report;ui/frontend/src/lib/core-context.svelte.tsexposes aCoreHolderthroughCORE_CONTEXT_KEY. The in-game layout (routes/games/[id]/+layout.svelte) populates the holder afterloadCore()resolves, so the designer readsCorefrom 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
Coreis ready; - audit step recorded in
ui/docs/calc-bridge.md: live surface table lists every wired function against itspkg/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.goagainstpkg/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.tsasserts preview hidden until validation passes, hidden when noCoreis 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.tsrenders 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 labelui/frontend/src/map/state-binding.tsextends to feed groups into the renderer alongside planetsui/frontend/src/lib/inspectors/ship-group.svelteread-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
ShipGroupselections
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.
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.sveltemounted 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.tsextends with eight new command variants —breakShipGroup,sendShipGroup,loadShipGroup,unloadShipGroup,upgradeShipGroup,dismantleShipGroup,transferShipGroup,joinFleetShipGroup— plusShipGroupCargoandShipGroupUpgradeTechliteral typessync/submit.tsandsync/order-load.tsround-trip every new variant against the existing FBS classes inproto/galaxy/fbs/order/; theidfield on each ship-group payload carries the target group UUID (the source group, or the freshly-mintednewGroupIdwhen an implicit split precedes the action)Sendaction picks destination through a planet picker filtered by the group's reach (localPlayerDrive * 40, computed inline via the existingtorusShortestDeltafromcargo-routes.svelte); the player's tech levels are already onGameReport.localPlayer*from Phase 18, no extra plumbing neededModernizecost preview throughcore.blockUpgradeCost(Phase 20 bridge), summed over the four ship-class blocks for the targeted ship count; preview hides whenCoreis not yet booted or the form is invalid (seeui/docs/ship-group-actions.mdfor the formula breakdown)- two-step inline confirmation for
Dismantleover a foreign planet with colonists onboard (engine referencecontroller/ship_group.go:177-179—UnloadColonistsis not called over a foreign planet, so the cargo is lost) pkg/calc/ship.go.BlockUpgradeCost(migrated fromgame/internal/controller/ship_group_upgrade.go) — the bridge rule saysui/core/calc/only wrapspkg/calc/formulas, so the function moved upstream and the controller now imports itGameReport.otherRaces: string[]populated by the report decoder fromreport.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 theSelectionStoreto the matchingshipGroup.localref 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.mdcovers 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 againstpkg/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 matchesBlockUpgradeCost× ship count and hides whenCoreis null;tests/sync-order-types-ship-group.test.ts—validateCommandfor 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:
BlockUpgradeCostmigration. The pre-existing copy ingame/internal/controller/ship_group_upgrade.gomoved topkg/calc/ship.go; the controller'sGroupUpgradeCostandUpgradeGroupPreferencenow callcalc.BlockUpgradeCost. The unit test moved fromcontroller/ship_group_upgrade_test.gotopkg/calc/ship_test.go.GameReport.otherRacesfield added toui/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.- 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.
- 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. - 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 atnewId. JoinFleet and Split do not get a counter (JoinFleet is whole-group atomically per the engine; Split is the break command).
Phase 21. Sciences — CRUD List + Designer
Status: pending.
Goal: define and manage sciences (named mixes of tech proportions summing to 1.0) through a table view and a designer.
Artifacts:
ui/frontend/src/routes/games/[id]/table/sciences/+page.sveltelist of sciences with name and four tech proportionsui/frontend/src/routes/games/[id]/designer/science/[id]/+page.sveltedesigner with four numeric inputs that auto-normalise to 1.0 and a name fieldui/frontend/src/sync/order-types.tsextends withCreateScienceandUpdateSciencecommand variants- topic doc
ui/docs/science-designer-ux.mdcovering auto-normalisation, validation, and the relationship to the planet production picker (Phase 15)
Dependencies: Phase 17.
Acceptance criteria:
- the user can create, edit, and delete sciences;
- proportions auto-normalise on edit so the sum is always 1.0;
- the planet production picker (Phase 15) lists the user's sciences and lets the user select one for research production;
- name validation matches
rules.txtconstraints (length, allowed characters, special characters not at start/end, no triple repeats).
Targeted tests:
- Vitest unit tests for proportion normalisation;
- Vitest unit tests for science name validation;
- Playwright e2e: create a science, set a planet to research it, submit, confirm.
Phase 22. Races View — War/Peace Toggle and Votes
Status: pending.
Goal: list other races with their visible stats, expose war/peace toggle and the voting UI.
Artifacts:
ui/frontend/src/routes/games/[id]/table/races/+page.sveltetable with one row per race, including name, tech levels, total population, total production, planet count, war-or-peace from this race's perspective, votes received. The race list itself is read fromGameReport.otherRaces(introduced in Phase 20 for the ship-group transfer-to-race picker); the table view widens the per-race shape (tech / population / production / planet count / votes / relation) by walkingreport.player[]directly when those fields are needed- per-row toggle for declaring war or peace (adds
SetDiplomaticStancecommand) - voting control: a single slot for
give my votes to <race>(addsSetVoteRecipientcommand) - alliance summary panel showing the current vote graph and any alliance reaching ≥ 2/3 of total votes
Dependencies: Phase 14.
Acceptance criteria:
- the user can toggle war / peace and change vote recipient;
- the alliance summary updates after a server roundtrip;
- vote counts match server state byte-for-byte.
Targeted tests:
- Vitest component tests for the alliance summary on canonical fixtures (chain of votes, fork, win condition);
- Playwright e2e: change diplomatic stance and vote, submit, confirm.
Phase 23. Reports View — Current Turn Sections
Status: pending.
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.sveltescrollable 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 mapaction 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.
Phase 24. Push Events — Turn-Ready
Status: pending.
Goal: subscribe to the server push stream and refresh client state when a turn-ready event arrives.
Artifacts:
ui/frontend/src/api/events.tspush-stream subscription wired throughGalaxyClient.subscribeEventsand Connect server-streaming- on
game.turn.readyevent: invalidate(game_id, current_turn)cache entries and trigger a fresh report fetch - a top-of-screen toast:
Turn N+1 is ready. View now.with a button that re-renders the active view against the new turn - mandatory event signature verification through
ui/core— any verification failure tears down the stream and reconnects with exponential backoff
Dependencies: Phases 23, 4 (Connect streaming in gateway).
Acceptance criteria:
- a server-side turn cutoff produces a toast within one second;
- accepting the toast refreshes the active view to the new turn's data without a full page reload;
- a forged event (test fixture with bad signature) is rejected and the stream reconnects.
Targeted tests:
- Vitest unit tests for
events.tshandling subscribe, event dispatch, error backoff; - Playwright e2e: trigger a server turn, observe toast and refresh.
Phase 25. Sync Protocol — Order Queue, Retry, Conflict
Status: pending.
Goal: make the order draft survive network failures and turn cutoffs gracefully, with explicit user feedback on conflicts.
Artifacts:
ui/frontend/src/sync/order-queue.tssend loop: on disconnect, hold the most recent submit; on reconnect, retry once; on persistent failure, surface error to the order tab- conflict detection: if the server returns
turn_already_closedfor a submit, mark the entire draft asconflictand surface aTurn N closed before your order was accepted. Edit and resubmit.banner in the order tab - topic doc
ui/docs/sync-protocol.mdcovering queue semantics, retry budgets, and conflict UX
Dependencies: Phases 14, 24.
Acceptance criteria:
- submitting an order while offline queues it and submits successfully on reconnect;
- a turn cutoff between draft and submit produces a visible conflict banner with no data loss;
- the order tab clearly distinguishes
draft,submitting,accepted,rejected,conflictstates per command.
Targeted tests:
- Vitest unit tests for
order-queuecovering all state transitions; - Playwright e2e: simulate network drop using Playwright's offline mode, submit an order, restore network, confirm submission;
- regression test: force a turn cutoff during submit, assert conflict banner appears.
Phase 26. History Mode
Status: pending.
Goal: let the user navigate to past turns and view all data as it was, with no order composition allowed.
Artifacts:
ui/frontend/src/lib/header/turn-navigator.svelteclickable turn counter expansion: popover (desktop) / bottom-sheet (mobile) listing recent turns and a search field for jumping to a turn numberui/frontend/src/lib/history-mode.tsglobal toggle wired into every view's data source: when active, allstate-binding, table, report, inspector, and map sources read from the historical snapshot for the selected turnui/frontend/src/lib/header/history-banner.sveltepersistent banner readingViewing turn N · read-onlywith aReturn to current turnaction- order tab hidden in history mode (already prepared in Phase 12)
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);
- all UI views (map, tables, report, battle, mail) work in history mode.
Targeted tests:
- Vitest unit tests for
history-modetoggle and per-view source selection; - Playwright e2e: enter history mode, navigate three views, return, confirm the order draft survived.
Phase 27. Battle Viewer
Status: pending.
Goal: render battles as a dedicated view with playback controls (play, pause, step forward, step backward, rewind), driven by the server-side combat log; render battle and bombing markers on the map.
Artifacts:
ui/frontend/src/map/battle-markers.tsrenders markers on the map for current-turn battles and bombings within visibility, clickable to open the battle viewerui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelteview with the combatant list, the round-by-round log, and a player control barui/frontend/src/lib/battle-player/round timeline, current-round highlight, per-shot animation- entry points to the viewer: marker on map, row in the report's battles section, push-event toast when a battle this turn involved the player
- topic doc
ui/docs/battle-viewer-ux.mdcovering playback semantics, accessibility (the combat log must be readable as text for users who skip animations)
Dependencies: Phase 23.
Acceptance criteria:
- battle and bombing markers render on the map for the seeded current-turn report and are clickable to open the viewer;
- the viewer plays back any battle in the seeded report including multi-round and one-sided battles;
- step controls allow precise inspection;
- the same data is accessible as a static text log for accessibility.
Targeted tests:
- Vitest unit tests for round-state transitions;
- Vitest unit tests for marker rendering on torus and no-wrap fixtures;
- Playwright e2e: click a battle marker on the map, play through, step backward, return to the report.
Phase 28. Diplomatic Mail View
Status: pending.
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.sveltetwo-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.sveltegear 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.tsimplementation 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.svelteUI 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.mddocumenting 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.mdrecording the v2-vs-v3 decision made at phase start with rationale ui/desktop/main.goWails entry pointui/desktop/app.goIPC bindings exposingui/coreAPI to the WebView through a structured adapterui/desktop/keychain/per-OS secure-storage helpers (macOS Keychain viaSecurityframework, Windows DPAPI, Linux Secret Service / file fallback at~/.config/galaxy/keypairwith mode0600)ui/desktop/sqlite/modernc.org/sqlitecache wired through Wails IPCui/frontend/src/platform/core/wails.tsWailsCoreadapterui/frontend/src/platform/store/wails.tsWailsKeyStoreandWailsCacheadaptersui/desktop/build/icon.icnsmacOS app iconui/desktop/build/icon.icoWindows app iconui/desktop/build/icon.pngLinux app iconui/Makefiletargetsdesktop-mac,desktop-win,desktop-linux- topic doc
ui/docs/desktop-secure-storage.mddocumenting 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
CoreandStorageTypeScript interfaces are satisfied as on web, with no platform-specific code outsideplatform/; - Linux file fallback activates when Secret Service is absent and
writes with
0600permissions.
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.gogomobile-friendly façade overui/coreui/MakefiletargetgomobileproducingGalaxy.frameworkandgalaxy.aarui/mobile/capacitor.config.tsCapacitor project configurationui/mobile/plugins/galaxy-core/custom Capacitor plugin (Swift + Kotlin) wrapping the gomobile artifactsui/frontend/src/platform/core/capacitor.tsCapacitorCoreadapterui/frontend/src/platform/store/capacitor.tsCapacitorKeyStoreandCapacitorCacheusing@capacitor-community/secure-storage-pluginand@capacitor-community/sqliteui/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/iOS app icon setui/mobile/android/app/src/main/res/mipmap-*/Android app icon set- iOS launch screen and Android splash screen
ui/Makefiletargetsiosandandroid- topic doc
ui/docs/mobile-bridge.mddescribing 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
CoreandStorageTypeScript 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-bridgefaç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.tscache-first asset strategy with stale invalidation on app updateui/frontend/static/manifest.webmanifestPWA manifestui/frontend/static/icons/web icon set sized permanifest.webmanifestrequirements- topic doc
ui/docs/pwa-strategy.mdcovering 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 usingpkg/calc/- planet inspector forecast section showing next-turn population, industry, materials stockpile, and production progress
ui/frontend/src/lib/navigation/transient-overlay.tspush/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 mapaction 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 viaui/core/calc/ - topic doc
ui/docs/multi-turn-projection.mddescribing 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 mapbutton 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 stringui/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 indocs/ARCHITECTURE.mdordocs/FUNCTIONAL.md(mirrored todocs/FUNCTIONAL_ru.md) - final cross-platform regression run on a release-candidate build
ui/docs/release-checklist.mdfor 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.yamlextended 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/coreand 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 inui/corewith 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
0600file 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.wasmin CI, drop the committed artefacts — install TinyGo on the Gitea Actions runner (brew install tinygois not available on Linux runners, so use the official tarball orcurl … | tar -xzstep), addmake -C ui wasmahead of the Vitest step in.gitea/workflows/ui-test.yaml, then removeui/frontend/static/core.wasmandui/frontend/static/wasm_exec.jsfrom the repo and re-tightenui/.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.CopyBytesToGowhen TinyGo fixes theinstanceof Uint8Arraycheck — the per-element loop inui/wasm/main.go::copyBytesFromJSis a workaround for TinyGo 0.41 panicking on Uint8Arrays whose prototype chain crosses Node'sBuffer. 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 published —
ui/buf.gen.yamlrunsprotoc-gen-esv2 locally becausebuf.build/connectrpc/esis 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; thegrpcapiname is historical. Touches imports ingateway/cmd/gateway/main.goand 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 theGRPCinfix — they label the authenticated-edge tier, not the wire protocol. Affectsgateway/internal/config/, the integration testenv defaults inintegration/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.gothat exercises a unary Connect call and a server-streaming Connect call throughtestenv.Bootstrap. (Phase 7+, fold into the phase that needs it.)