Files
galaxy-game/ui/PLAN.md
T
2026-05-09 08:37:56 +02:00

2542 lines
112 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UI Client Implementation Plan
This plan stages the implementation of the cross-platform UI client for
Galaxy. The client builds from a single TypeScript + Svelte codebase to
five targets: web, web-mobile, standalone PC (mac/win/linux), iOS, and
Android. A shared Go module (`ui/core`) carries envelope cryptography,
FlatBuffers codec, keypair management, and a thin bridge over `pkg/calc/`
for UI-side game math; it is compiled to WASM (web), gomobile native
libraries (iOS/Android), and embedded directly in Wails (desktop). All
network I/O lives on the TypeScript side via ConnectRPC, so the Go
module is a pure compute boundary on every platform.
The existing Fyne client in `client/` is deprecated and is not modified
or imported by the new code. The strategy and rationale behind these
choices live in the plan file at
`/Users/id/.claude/plans/buzzing-questing-fountain.md`; the architectural
overview is mirrored into `ui/README.md` as part of Phase 1.
Each phase ends with a runnable artifact. The visual progression is:
empty page → navigation skeleton → stubbed views → live data → real
actions. Phases are sized so that any one of them can be shipped, run,
and reviewed before the next starts; if a direction proves wrong, the
plan can be adjusted with at most one phase of rework.
---
## Summary
This plan breaks implementation into 36 small reviewable phases. Each
phase has a single primary goal, clear deliverables, explicit
dependencies, acceptance criteria, and focused tests. Tests live
alongside the code added in the phase; a phase is not closed until its
tests are green on the targets it claims to support.
The intended v1 architecture is:
- TypeScript + Svelte 5 frontend, shared across all five build targets;
- PixiJS v8 with dual WebGPU/WebGL backend for the world map renderer;
- Go module `ui/core` as a compute-only library (canonical bytes,
sign/verify, FlatBuffers codec, keypair, thin bridge to `pkg/calc/`)
compiled to WASM, gomobile, and Wails-embedded native;
- TypeScript-side `Core` interface with three adapters (`WasmCore`,
`WailsCore`, `CapacitorCore`) selected at build time;
- `GalaxyClient` on top of `Core` performs all network I/O via
ConnectRPC (`@connectrpc/connect-web`) on every platform;
- per-platform storage: WebCrypto + IndexedDB on web, OS keychain +
SQLite on desktop, iOS Keychain / Android Keystore + SQLite on mobile,
all behind a single `KeyStore` and `Cache` TypeScript interface;
- mobile-first navigation: one active view occupies the main area at a
time; sidebar holds a single tool (calculator, inspector, or order)
with persistent state on switch.
## Assumptions and Defaults
- Target Go version follows `go.mod` of the parent module; TinyGo for
WASM must support `crypto/ed25519` and `crypto/sha256`. If TinyGo
support is insufficient, fall back to standard Go `GOOS=js
GOARCH=wasm` with a larger bundle (~2 MB).
- The gateway exposes server-streaming gRPC. Browsers cannot speak raw
gRPC; ConnectRPC support is added to the gateway so a single set of
Go handlers serves native gRPC and browser clients simultaneously.
- TypeScript-side network code uses `@connectrpc/connect-web` for unary
calls and server-streaming push events on every platform.
- Ed25519 private keys never leave the device. Loss of secure storage is
acceptable on every platform and triggers a re-login flow.
- Build pipeline is a single `pnpm` workspace at `ui/`; Make targets
wrap TinyGo, gomobile, Wails CLI, Capacitor CLI, and Vite.
- All file/directory names, code, comments, identifiers, and docs in
`ui/` are written in English. Russian appears only in i18n bundles
delivered in Phase 35.
- Pre-production migration rule from the project root applies: schema
changes are inlined into the existing init schema rather than
producing new migrations; clean rebuilds on every checkout.
- The existing `galaxy/client` Go module is deprecated in full. New
code does not import from it; this includes `client/world/`, which
is no longer the reference algorithm for the TypeScript renderer.
Existing types in `pkg/model/client/` are not migrated; UI types
are written from scratch in `ui/core/types/` as needed.
- The TypeScript map renderer is specified in `ui/docs/renderer.md`,
derived from the renderer's own requirements rather than from any
earlier Go code. Tile-based spatial indexing is intentionally
omitted in the first iteration; PixiJS native culling and
bounds-based hit testing carry the renderer until profiling
proves otherwise.
- Game math that must stay synchronised between server and client lives
in `pkg/calc/`. The UI client never duplicates calc functions; instead
a bridge layer in `ui/core/calc/` wraps selected `pkg/calc/` functions
for the `Core` API. New shared math is added to `pkg/calc/` first; gaps
are surfaced at the start of each phase that needs them.
- State preservation is a global rule: switching active view or sidebar
tab does not reset state. State resets only on explicit user
`discard` actions or logout.
- History mode is a global read-only toggle that applies to every active
view. The Order sidebar tab is hidden in history mode.
- Wails v2 is the desktop baseline. At the start of Phase 31, the
current state of Wails v3 is re-evaluated; if v3 has reached a stable
release, the migration is folded into that phase.
- CI uses Gitea Actions (workflow files under `.gitea/workflows/`,
format-compatible with GitHub Actions). Linux runners cover Tier 1
tests; a macOS runner is provisioned only when Tier 2 iOS smoke is
needed.
## 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
```text
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):
```text
┌──────────────────────────────────────────────────────────┐
│ Header: race · turn N · countdown · view dropdown · ⚙ │
├────────────────────────────────────────────┬─────────────┤
│ │ tabs │
│ active view │ ┌─────────┐ │
│ (map / table / │ │ Calc │ │
│ battle / mail / │ │ Inspect │ │
│ designer / report) │ │ Order │ │
│ │ └─────────┘ │
│ │ tool │
│ │ content │
│ │ │
└────────────────────────────────────────────┴─────────────┘
```
Tablet (7681024 px): same as desktop but sidebar collapses to a
swipe-from-right drawer; a tab bar of three icons sits in the header
right corner.
Mobile (< 768 px):
```text
┌──────────────────────┐
│ ☰ race · turn N · ⚙ │
├──────────────────────┤
│ │
│ active view │
│ │
│ │
│ │
├──────────────────────┤
│ ▣ 🧮 📝 ☰ │
│ Map Calc Order More│
└──────────────────────┘
```
On mobile, Inspector is not a bottom tab — tapping an object on the map
raises a bottom-sheet showing inspector content. The sheet swipes down
to dismiss. `More` opens a hamburger menu that lists Mail, Battle log,
Tables (planets, ship classes, ship groups, fleets, sciences, races),
History, Settings, Logout.
### Sidebar tools (single-tool with state preservation)
- **Calculator** — independent ship/path calculator, callable from any
view. Holds in-progress inputs across navigation.
- **Inspector** — context-sensitive details for the currently selected
map object. Empty state when nothing is selected: `select an object on
the map`.
- **Order** — the draft order being composed. Vertical list of commands,
top-to-bottom. Each command shows its local-validation result while
composing and its server result after submit. Order persists across
page reloads and across view switches.
### Map active view
PixiJS canvas with pan/zoom over the torus. A gear icon in the corner
opens a popover (desktop) or bottom sheet (mobile) with category
toggles:
| Toggleable | Default |
| - | - |
| Hyperspace groups | on |
| Incoming groups (not necessarily enemy) | on |
| Cargo routes | on |
| Reach / visibility zones | off |
| Battle and bombing markers | on |
Planets are always shown and cannot be hidden.
### Header turn counter and history mode
The turn counter is clickable. Click expands to a turn navigator
(popover desktop, bottom sheet mobile) listing recent turns with a
search field for jumping to a specific turn number. Selecting a past
turn enters history mode: every active view switches its data source to
that turn's snapshot, the Order sidebar tab disappears, and a
persistent banner reads `Viewing turn N · read-only` with a `Return to
turn current` action.
### Cross-cutting shell
- Push-event toasts surface from the top of the screen for: turn ready,
lobby state changes, invitations, session revoked, incoming attack.
- A connection-state indicator in the header shows online / reconnecting
/ offline based on push-stream state and last successful unary call.
- The account menu (top-right on desktop, last hamburger entry on
mobile) holds Settings, Sessions, Theme, Language, Logout.
### Authenticated route transitions
- `/login``/lobby` after successful confirm-email-code.
- `/lobby``/games/:id/map` when a game card is selected.
- Any view → `/login` immediately on session revocation push event.
- Designer views can push a transient overlay onto `/map`; the back
affordance returns to the originating designer.
Per-screen behaviour (validations, exact field names, error mappings)
is derived from `docs/FUNCTIONAL.md` sections cited in the relevant
phases below. UI-specific decisions (animations, layout, microcopy)
live in per-phase topic docs under `ui/docs/`.
---
## ~~Phase 1. Workspace Skeleton~~
Status: done.
Goal: bring up the `ui/` workspace with a runnable empty
SvelteKit + Vite frontend and architectural anchors.
Artifacts:
- `ui/README.md` mirroring the architectural overview from this plan
- `ui/Makefile` with placeholder targets for every build type (`web`,
`wasm`, `gomobile`, `desktop-{mac,win,linux}`, `ios`, `android`,
`all`)
- `ui/pnpm-workspace.yaml` declaring the single-package pnpm workspace
- `ui/frontend/` Svelte 5 + SvelteKit + Vite + TypeScript project
(the SvelteKit scaffold provides `+layout.svelte`, `+page.svelte`,
`static/`, and the file-system router used by later phases)
- `ui/frontend/src/routes/+page.svelte` minimal landing page
rendering the app version string in the page footer; the version
is read at build time by Vite `define` from
`ui/frontend/package.json`
- `ui/frontend/{vitest.config.ts, tests/}` minimum Vitest harness
needed to run the smoke test below (`vitest`, `jsdom`,
`@testing-library/svelte`); the rest of the test toolchain
(Playwright, `@testing-library/jest-dom`, CI workflows) lands in
Phase 2
- `ui/.gitignore` covering `node_modules`, `dist`, `*.wasm`, build
outputs for Wails and Capacitor, Playwright artefacts
- `ui/docs/` empty directory ready for per-phase topic docs
Dependencies: none.
Acceptance criteria:
- `pnpm install && pnpm dev` from `ui/frontend` starts a dev server
that serves the landing page at a free local port;
- `make` lists every planned build target as a placeholder;
- `ui/README.md` lists the five target platforms, the layered
architecture, and points readers to per-phase topic docs under
`ui/docs/`.
Targeted tests:
- a single Vitest smoke test that mounts the landing component and
asserts the rendered version string is non-empty.
## ~~Phase 2. Testing Infrastructure~~
Status: done.
Goal: install and configure the test toolchain that every later phase
depends on, including Tier 1 (per-PR) and Tier 2 (release) targets.
Artifacts:
- `ui/frontend/package.json` dev-dependencies (added on top of the
Phase 1 minimum of `vitest`, `jsdom`, `@testing-library/svelte`):
`@testing-library/jest-dom`, `playwright`, `@playwright/test`
- `ui/frontend/vitest.config.ts` extended with `setupFiles:
["./tests/setup.ts"]` to wire `@testing-library/jest-dom` matchers
into Vitest (the JSDOM environment itself is wired in Phase 1)
- `ui/frontend/tests/setup.ts` registering `jest-dom` matchers
- `ui/frontend/tests/e2e/landing.spec.ts` placeholder Playwright test
asserting the version footer renders
- `ui/frontend/playwright.config.ts` with four projects:
`chromium-desktop`, `webkit-desktop`, `chromium-mobile-iphone-13`,
`chromium-mobile-pixel-5`; tracing and screenshots enabled on
failure; `webServer: pnpm run dev` on port 5173
- `.gitea/workflows/ui-test.yaml` running Tier 1 on every push and PR
on a Linux runner: monorepo Go service tests for `backend/`,
`gateway/`, `game/`, and every `pkg/<name>/` module (each pkg
module is enumerated explicitly because they sit as independent
go.work modules under a shared `pkg/` directory, and `./pkg/...`
does not recurse across module boundaries). All Go tests run with
`-count=1` so the cache never masks a failing run; backend tests
additionally run with `-p 1` because most backend packages spawn
their own Postgres testcontainer and parallel bootstraps starve
each other on the runner. The integration suite stays gated behind
`make -C integration integration` and lives outside Tier 1; the
deprecated `client/` Fyne client (see §74) is also excluded — its
tests, code, and documentation are frozen and CI must not run
them. Then `pnpm install --frozen-lockfile` from `ui/`,
`pnpm exec playwright install --with-deps`, `pnpm test`,
`pnpm exec playwright test`; Playwright reports and traces
uploaded as artefacts on failure
- `.gitea/workflows/ui-release.yaml` running Tier 2 on tag push (`v*`):
same Tier 1 step set today; visual-regression and macOS-runner
iOS-smoke jobs live as commented sections marked with the phase
number that re-enables them (Phase 33 and Phase 32 respectively)
- `ui/docs/testing.md` topic doc naming the two tiers, the tools
per tier, and the rule that visual regression baselines live in
`ui/frontend/tests/__snapshots__/` until shifted to Argos
Dependencies: Phase 1.
Acceptance criteria:
- a placeholder Vitest test passes locally and in CI;
- a placeholder Playwright test passes in `chromium-desktop` and
`webkit-desktop` projects locally;
- the Gitea Actions Tier 1 workflow runs end-to-end against a clean
clone of the repo on a Linux runner. Until the Gitea runner is
provisioned, the workflow is exercised locally with
`act -W .gitea/workflows/ui-test.yaml`.
Targeted tests:
- placeholder Vitest test from Phase 1 runs in CI and passes;
- placeholder Playwright test runs in CI on Linux runner and passes
in both `chromium-desktop` and `webkit-desktop` projects;
- intentional failure produces a Playwright trace artefact in CI.
## ~~Phase 3. Go Core: Canonical Bytes and Keypair~~
Status: done.
Goal: implement the canonical-bytes serializer and Ed25519 keypair
management in pure Go, with bit-for-bit parity to the gateway-side
implementation. No network, no UI.
Artifacts:
- `ui/core/go.mod` module `galaxy/core` declared in the project Go
workspace (`go.work` `use` and `replace` directives)
- `.gitea/workflows/ui-test.yaml` and `.gitea/workflows/ui-release.yaml`
extended to add `./ui/core/...` to the Tier 1 / Tier 2 `go test`
command list introduced in Phase 2
- `ui/core/canon/` canonical bytes for `galaxy-request-v1`,
`galaxy-response-v1`, and `galaxy-event-v1`, matching
`docs/ARCHITECTURE.md` §15 byte-for-byte. Server-only signers
(`Ed25519ResponseSigner`, PKCS#8 PEM loaders) intentionally stay
in `gateway/authn` — `ui/core` is verify-only on the server side
- `ui/core/keypair/` Ed25519 generate, marshal, unmarshal helpers
over opaque `[]byte` blobs; `Generate` accepts an injected
`io.Reader` so the WASM build can wire in `crypto.getRandomValues`
- `ui/core/types/` full v1 transport-envelope structs with
`SigningFields()` projection helpers; result-code and
protocol-version constants (`ProtocolVersionV1`, `ResultCodeOK`).
`TraceID` is part of the request envelope but deliberately
excluded from the request signing input (matches §15)
- `ui/core/canon/testdata/` golden JSON test vectors for the three
Phase-3 message types plus one response and one event
- `ui/core/README.md` documenting the public API and the
network-free / storage-free / no-x509 / no-PEM / no-`os` invariant
- `gateway/authn/parity_with_ui_core_test.go` (cross-module test)
proving canonical-bytes parity and bidirectional sign/verify
acceptance between `gateway/authn` and `galaxy/core`. The test
adds `require galaxy/core` to `gateway/go.mod` (test-only in
practice — gateway production binary does not link `ui/core`)
Dependencies: Phase 1.
Acceptance criteria:
- canonical-bytes output matches gateway-side output byte-for-byte
for the three Phase-3 message types (`user.account.get`,
`lobby.my.games.list`, `user.games.command`);
- a request signed by `ui/core` is accepted by the gateway's own
verifier in a unit test (`TestParityRequestSignedByUICoreAcceptedByGateway`);
- a response signed by `gateway/authn`'s `Ed25519ResponseSigner` is
accepted by `ui/core`'s verifier
(`TestParityResponseSignedByGatewayAcceptedByUICore`); the same
applies to gateway-signed events;
- tampered `payload_hash`, mismatched `request_id`, mismatched
`timestamp_ms`, and invalid signature length are rejected with
stable error codes from `ui/core/canon`. Server-side freshness
enforcement (the symmetric ±5 minutes around server time) stays
in `gateway/internal/grpcapi/freshness_replay.go` and is not
duplicated in `ui/core`.
Targeted tests:
- canonical-bytes equality tests on golden JSON fixtures
(`testdata/`) for every envelope kind;
- round-trip sign-then-verify across all three envelope kinds;
- negative tests: tampered `payload_hash`, mismatched `request_id`,
mismatched `timestamp_ms`, invalid signature lengths (too short,
too long, empty), bit-flipped signature, wrong public key,
malformed base64 public key;
- `gateway/authn` cross-module parity tests as listed under
Artifacts.
## ~~Phase 4. ConnectRPC Support in Gateway~~
Status: done. Cross-service phase — work happened in `gateway/` and
`integration/`, not `ui/`.
Goal: enable browsers to call the gateway's authenticated edge surface
through ConnectRPC, without keeping a separate gRPC server bootstrap
alive purely for test clients.
Decision (taken with the project owner before implementation): the
existing native-gRPC `grpc.NewServer` bootstrap was replaced with a
single `connectrpc.com/connect` HTTP/h2c listener, since Connect-Go
natively serves the Connect, gRPC, and gRPC-Web protocols on the same
port. No production gRPC clients existed to preserve. The package
`gateway/internal/grpcapi` keeps its name for diff-size reasons and
documents the historical labelling in its package doc.
Artifacts (delivered):
- `gateway/buf.gen.yaml` extended with `buf.build/connectrpc/go`,
generating `gateway/proto/galaxy/gateway/v1/gatewayv1connect/edge_gateway.connect.go`
- `gateway/internal/grpcapi/server.go` rewritten around `http.Server`
+ `h2c.NewHandler` + `gatewayv1connect.NewEdgeGatewayHandler`
- new `gateway/internal/grpcapi/connect_handler.go` adapting the
existing `gatewayv1.EdgeGatewayServer` decorator stack to the
Connect handler interface, including a `grpc.ServerStreamingServer`
shim around `*connect.ServerStream[GatewayEvent]` and a gRPC
`status.Error` → `*connect.Error` translation helper
- new `gateway/internal/grpcapi/connect_observability.go` Connect
interceptor recording the same metric and structured-log shape the
gRPC interceptors emitted; the rate-limit decorator now reads peer
IP from a context value populated by the interceptor instead of
`peer.FromContext`
- updated `gateway/README.md` (Transport Matrix + "Authenticated Edge
Surface"), `gateway/docs/runtime.md`, `gateway/docs/flows.md`,
`gateway/docs/runbook.md`, and `docs/ARCHITECTURE.md` §15
- migrated tests: `gateway/internal/grpcapi/server_test.go`,
`test_fixtures_test.go`, and every `*_integration_test.go` in that
package now drive a `gatewayv1connect.EdgeGatewayClient` over
HTTP/2 cleartext loopback
- migrated harness: `integration/testenv/grpc_client.go` →
`connect_client.go`. `SignedGatewayClient` keeps the same public
shape (`Execute`, `SubscribeEvents`, `Close`) but speaks Connect
internally; `Is*` helpers now use `connect.CodeOf`
Dependencies: Phase 3 (canonical bytes are needed for the
fixture-level signing the migrated tests use).
Acceptance criteria (met):
- unary Connect calls from outside the gateway process succeed
end-to-end against the authenticated surface — verified by the
migrated `grpcapi/server_test.go` and `command_routing_integration_test.go`
scenarios driving the Connect client over loopback h2c;
- server-streaming `SubscribeEvents` works over Connect with the
signed `gateway.server_time` bootstrap event delivered first —
verified by `TestSubscribeEventsValidEnvelopeSendsBootstrapEventAndWaitsForCancellation`;
- the unified listener still natively accepts gRPC and gRPC-Web
framing for any future native client (Connect-Go's documented
multi-protocol support);
- the Connect handler shares the same upstream business code as the
unified listener — there is exactly one decorator stack
(`grpcapi.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~~
Status: done.
Goal: package `ui/core` as a WASM module, expose it to TypeScript
through a typed adapter, and prove the WASM-side crypto pipeline at
unit level. End-to-end Connect round-trip is validated in Phase 7
(authenticated calls only become possible after login).
Decisions taken with the project owner before implementation:
1. **TinyGo as primary toolchain.** `core.wasm` lands at 903 KB —
well under the 1 MB acceptance bar. The `GOOS=js GOARCH=wasm`
fallback path stays documented in `ui/docs/wasm-toolchain.md`.
2. **`Core.signRequest` returns canonical bytes only.** No private
key inside WASM; Phase 6 plugs WebCrypto's non-exportable keys at
the orchestration layer. `GalaxyClient` takes a pluggable `Signer`
so Phase 5 tests pass a fixture-key signer and Phase 6 swaps in
WebCrypto without touching the orchestration.
3. **TS codegen runs locally, not against buf.build BSR.** A new
`ui/buf.gen.yaml` invokes
`frontend/node_modules/.bin/protoc-gen-es` (added as a
devDependency). This sidesteps BSR rate limiting and removes the
network dependency from the codegen step.
4. **Field naming is camelCase end-to-end.** Both the TS `Core`
interface and the Go bridge in `ui/wasm/main.go` use camelCase
field names; there is no snake-case translation layer.
Artifacts (delivered):
- `ui/wasm/main.go` TinyGo entry point on `globalThis.galaxyCore`
with four functions: `signRequest`, `verifyResponse`,
`verifyEvent`, `verifyPayloadHash`.
- `ui/Makefile` `wasm` and `ts-protos` targets.
- `ui/buf.gen.yaml` with the local Protobuf-ES plugin (single plugin —
protobuf-es v2 emits both message types and Connect service
descriptors in one file).
- `ui/frontend/src/platform/core/index.ts` — typed `Core` interface
plus a `loadCore()` resolver (Phase 5 ships only the WASM adapter).
- `ui/frontend/src/platform/core/wasm.ts` — `WasmCore` adapter for
browsers; the JSDOM test path lives next to it in
`ui/frontend/tests/setup-wasm.ts`.
- `ui/frontend/src/api/connect.ts` — typed Connect-Web transport +
`EdgeGatewayClient` factory.
- `ui/frontend/src/api/galaxy-client.ts` — `GalaxyClient` skeleton
with injected `Signer` and `Sha256` dependencies.
- `ui/frontend/src/proto/galaxy/gateway/v1/edge_gateway_pb.ts`
(generated) and `ui/frontend/src/proto/buf/validate/validate_pb.ts`
(generated as a transitive import via `--include-imports`).
- `ui/frontend/static/core.wasm` (903 KB) + `wasm_exec.js` (TinyGo
shim).
- Three Vitest files exercising the bridge end-to-end:
`tests/wasm-core.test.ts` (each Core method, including a sanity
`signRequest` check that the canonical bytes start with the v1
domain marker), `tests/wasm-core-canon-parity.test.ts` (byte-for-
byte parity against three request fixtures plus the response and
event signature fixtures from `ui/core/canon/testdata/`), and
`tests/galaxy-client.test.ts` (orchestration through a mock `Core`
and `createRouterTransport` from `@connectrpc/connect`).
- Topic doc `ui/docs/wasm-toolchain.md`.
- `ui/README.md` repository-layout block.
Dependencies: Phases 2, 3, 4.
Acceptance criteria (met):
- `make wasm` produces `core.wasm` deterministically under 1 MB (903
KB measured);
- `WasmCore.signRequest` produces canonical bytes byte-for-byte
identical to the gateway-side fixtures for three message types
(`request_user_account_get`, `request_user_games_command`,
`request_lobby_my_games_list`);
- `WasmCore` exposes the same `Core` TypeScript types future
`WailsCore` and `CapacitorCore` adapters will satisfy.
Targeted tests (delivered):
- Vitest unit tests for `WasmCore` calling each public method with
the WASM module loaded in JSDOM via `tests/setup-wasm.ts`;
- Vitest unit tests for `GalaxyClient` using a mock `Core` and the
in-memory `createRouterTransport`;
- Vitest tests asserting `WasmCore.signRequest` output matches the
committed gateway fixtures byte-for-byte for the three request
message types listed above.
Decision deviation note: the initial plan listed `protoc-gen-es` and
`protoc-gen-connect-es` as separate plugins. Protobuf-ES v2 generates
service descriptors in the `_pb.ts` file directly, so a single
`@bufbuild/protoc-gen-es` plugin is sufficient — `@connectrpc/connect`
v2 consumes those descriptors via `createClient`. The `connect-es`
plugin is a v1-only path and is intentionally not used here.
## ~~Phase 6. Storage Layer (Web)~~
Status: done.
Goal: persist the device session keypair securely in browsers, and
provide a generic local cache for game state. Defines the
TypeScript-side `KeyStore` and `Cache` interfaces that desktop and
mobile adapters will satisfy in later phases.
Decisions taken with the project owner before implementation:
1. **Phase 6 stops at the storage boundary.** The PLAN previously
listed a Playwright check that the gateway accepts a signed
request. Public-key registration happens through the email-code
confirm endpoint, which Phase 7 wires; building a temporary
test-only registration path was rejected as throw-away
scaffolding. The live-gateway round-trip is therefore covered by
Phase 7's existing acceptance bullet "the first authenticated
Connect call after login … succeeds end-to-end" instead, which
cannot pass unless the Phase 6 keystore persists and signs
correctly.
2. **Modern-browser baseline, no JS Ed25519 fallback.** WebCrypto
Ed25519 lands in Chrome ≥137, Firefox ≥130, Safari ≥17.4. Phase 7
surfaces a clear "browser not supported" message for older
engines instead of carrying a parallel `@noble/ed25519` code
path. The full matrix and rationale live in
`ui/docs/storage.md`.
Artifacts:
- `ui/frontend/src/platform/store/index.ts` — public `KeyStore`,
`Cache`, `DeviceKeypair` interfaces and the `loadStore()`
resolver, with no web-specific imports in any public signature
- `ui/frontend/src/platform/store/idb.ts` — shared `galaxy-ui`
IndexedDB connection (typed via `idb`'s `DBSchema`) used by both
the keystore and the cache
- `ui/frontend/src/platform/store/idb-cache.ts` — IndexedDB-backed
`Cache` keyed by compound `[namespace, key]`
- `ui/frontend/src/platform/store/webcrypto-keystore.ts` — WebCrypto
non-exportable Ed25519 key generation, structured-cloned through
IDB
- `ui/frontend/src/platform/store/web.ts` — the `loadWebStore`
factory wired into `loadStore`
- `ui/frontend/src/api/session.ts` thin layer with
`loadDeviceSession`, `setDeviceSessionId`, `clearDeviceSession`
- `ui/frontend/src/routes/__debug/store/+page.svelte` (+ `+page.ts`
with `prerender = false; ssr = false;`) — dev-only debug surface
the Phase 6 Playwright spec drives through `window.__galaxyDebug`
- topic doc `ui/docs/storage.md` describing the browser baseline,
IDB schema, keystore lifecycle, and cache contract
Dependencies: Phase 5.
Acceptance criteria:
- a freshly generated keypair survives page reloads (the loaded
handle still produces signatures verifiable under the persisted
public key); the live-gateway round-trip is covered by Phase 7;
- clearing site data removes the keypair, and the next request
triggers a re-login flow;
- `KeyStore` and `Cache` interfaces have full TypeScript types and
zero web-specific imports in their public signatures.
Targeted tests:
- Vitest unit tests for `IDBCache` with `fake-indexeddb`
(round-trip, namespace isolation, delete, clear-with-namespace,
full clear);
- Vitest unit tests for `WebCryptoKeyStore` (generate, load,
signature determinism under Node WebCrypto, signature
verifiability after a simulated reload, third-party verify of the
public key, clear, fresh-keypair-after-clear);
- Playwright e2e (`storage-keypair-persistence.spec.ts`, all four
projects): generate keypair, sign canonical bytes, capture the
signature, reload, assert the previous signature still verifies
under the public key (works on every engine in the baseline
including non-deterministic WebKit), and that
`clearDeviceSession` forces a fresh keypair on next load.
## ~~Phase 7. Auth Flow UI~~
Status: done.
Goal: implement the full email-code login flow with device session
registration and post-login redirect to a placeholder lobby.
Decisions taken with the project owner before implementation:
1. **Playwright e2e against a mocked gateway.** `page.route(...)`
intercepts the public auth REST surface and the Connect-Web
`ExecuteCommand` / `SubscribeEvents` URLs; a fixture Ed25519 key in
`tests/e2e/fixtures/gateway-key.ts` signs the forged responses so
`GalaxyClient.verifyResponse` accepts them under the matching
public key the dev server picks up via
`VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. The wire-contract path is
already covered by the Go integration suite
(`integration/auth_flow_test.go`).
2. **Build-time gateway response public key delivery.** The browser
reads `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` (standard base64 of the
raw 32-byte key) on module load. A future phase may switch to a
`/api/v1/public/well-known/...` endpoint when prod distribution is
wired up; Phase 7 stops at the env-var.
3. **Minimal SubscribeEvents-based revocation watcher.** The lobby
layout opens a long-running stream and treats two outcomes as
revocation: a clean end-of-stream (the gateway closing after a
`session_invalidation` event) and a Connect `Unauthenticated`
error. Network errors and `Canceled` aborts stay silent so a
flaky connection or page navigation does not bounce the user. The
per-event dispatch path lands in Phase 24.
4. **Browser-not-supported blocker.** The root layout runs a one-time
`crypto.subtle.generateKey({name:"Ed25519"}, ...)` probe on boot
and renders a blocker page when the probe rejects. This closes
Phase 6's "no JS Ed25519 fallback" follow-up.
Artifacts (delivered):
- `ui/frontend/src/routes/login/+page.svelte` (+ `+page.ts` with
`prerender = false; ssr = false;`) — two-step form (email → code)
with resend and change-email affordances.
- `ui/frontend/src/routes/lobby/+page.svelte` (+ `+page.ts`) —
placeholder lobby that issues the first authenticated
`user.account.get` through `GalaxyClient` and surfaces the decoded
display name. The placeholder used `JSON.parse(TextDecoder)` to
read the response payload; that worked with the mocked Playwright
setup but did not match the gateway's FlatBuffers wire format. Phase
8 introduces the TS-side FlatBuffers integration and rewrites this
page to decode `AccountResponse` via the generated bindings, so the
greeting now also works against a real local stack.
- `ui/frontend/src/routes/+layout.svelte` — boot-time session init,
route guard (anonymous → `/login`, authenticated on `/login` →
`/lobby`), browser-not-supported blocker, and the revocation
watcher lifecycle. `+layout.ts` puts the whole tree into SPA mode
(`ssr = false; prerender = false;`).
- `ui/frontend/src/api/auth.ts` — `sendEmailCode`,
`confirmEmailCode`, and the `AuthError` taxonomy 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` — `SessionStore`
singleton (Svelte 5 runes); states `loading | unsupported |
anonymous | authenticated`; `init`, `signIn`, `signOut("user" |
"revoked")`.
- `ui/frontend/src/lib/revocation-watcher.ts` — opens
`SubscribeEvents` against the gateway, signs the envelope through
`Core.signRequest`, treats clean stream end / `Unauthenticated` as
revocation.
- `ui/frontend/.env.example` — `VITE_GATEWAY_BASE_URL`,
`VITE_GATEWAY_RESPONSE_PUBLIC_KEY`.
- Topic doc `ui/docs/auth-flow.md`; cross-references from
`ui/docs/storage.md` and `ui/README.md`.
- Vitest: `tests/auth-api.test.ts`, `tests/session-store.test.ts`,
`tests/login-page.test.ts`.
- Playwright: `tests/e2e/auth-flow.spec.ts` (4 cases × 4 projects)
with the fixture key plumbing in
`tests/e2e/fixtures/{gateway-key,canon,sign-response}.ts`.
- Pre-existing `tests/e2e/landing.spec.ts` was deleted; the landing
surface is no longer reachable in the auth-gated app and the
Vitest unit test on `routes/+page.svelte` retains the version
footer assertion.
Dependencies: Phase 6.
Acceptance criteria (met):
- A fresh browser completes login end-to-end via the mocked gateway
in all four Playwright projects; the first authenticated Connect
call (`user.account.get`) succeeds end-to-end through `WasmCore` →
`GalaxyClient` → ConnectRPC and the response signature is verified
under `VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. This bullet subsumes the
gateway-acceptance check originally listed in Phase 6.
- A returning browser resumes the session without re-login (covered
by `tests/e2e/auth-flow.spec.ts::"returning user lands on the
lobby without re-login"`).
- Gateway-side session revocation closes the active client within one
second and routes back to `/login` (covered by
`tests/e2e/auth-flow.spec.ts::"server-side revocation closes the
active client within one second"`).
Targeted tests (delivered):
- Vitest component tests for the login form with mocked `auth.ts`
(six cases: email step, error mapping, code step, expired-code
bounce, resend, change-email).
- Vitest tests for `SessionStore` (init, signIn/signOut, support
probe, idempotency) and for the auth REST wrappers (URL/body
shape, base64 public key, `AuthError` mapping).
- Playwright e2e suite (`auth-flow.spec.ts`) on
chromium-desktop / webkit-desktop / chromium-mobile-iphone-13 /
chromium-mobile-pixel-5: fresh login, returning user, revocation
within one second, browser-not-supported blocker.
## ~~Phase 8. Lobby UI~~
Status: done.
Goal: replace the placeholder lobby with a working list of games
allowing the user to view membership, see and act on invitations and
applications, submit applications to public games, and create new
private games. The phase also introduces the TS-side FlatBuffers
codec the rest of the client relies on for authenticated payloads.
Decisions taken with the project owner before implementation:
1. **Cross-stack catalog extension.** Phase 8 expands the lobby
command catalog beyond `lobby.my.games.list` and
`lobby.game.open-enrollment` (the only routes shipped before this
phase). Seven new authenticated message types now flow through
`gateway/internal/backendclient/lobby_commands.go`:
`lobby.public.games.list`, `lobby.my.applications.list`,
`lobby.my.invites.list`, `lobby.game.create`,
`lobby.application.submit`, `lobby.invite.redeem`,
`lobby.invite.decline`. Each carries its FlatBuffers schema in
`pkg/schema/fbs/lobby.fbs`, its Go request/response struct in
`pkg/model/lobby/lobby.go`, and its transcoder pair in
`pkg/transcoder/lobby.go`.
2. **My applications projection.** FUNCTIONAL.md §4.5 lists three
"my" projections (games, applications, invites). The original
plan text omitted applications; the lobby now renders a fourth
"my applications" section so the user sees the pending status of
any application they submit.
3. **Submit-application UX.** Per FUNCTIONAL.md §4.2, joining a
public game means submitting an application that lands in the
`pending` state until the owner approves. The button label is
`Submit application`, the inline race-name form lives on the
public-game card itself (no overlay/modal infrastructure yet —
that lands later when the in-game shell does), and a successful
submit refreshes the applications section so the user sees the
pending entry immediately.
4. **TS-side FlatBuffers integration.** The placeholder lobby in
Phase 7 used `JSON.parse(TextDecoder)` to read the
`user.account.get` payload; that was a mismatch with the gateway's
FlatBuffers transcoder and only worked under mocked tests. Phase 8
adds a `flatbuffers` runtime dep to `ui/frontend/package.json`, a
`fbs-ts` Make target in `ui/Makefile` that drives `flatc --ts`,
and checks the generated bindings into
`ui/frontend/src/proto/galaxy/fbs/{lobby,user}/`. Phase 7's
`user.account.get` decode is rewritten to use those bindings as
part of this phase, fixing the wire-format gap.
5. **Create-game form scope.** The form keeps `game_name`,
`description`, `turn_schedule` (5-field cron), and
`enrollment_ends_at` always visible; the rest (`min_players`,
`max_players`, `start_gap_hours`, `start_gap_players`,
`target_engine_version`) sit behind a `<details>` "Advanced"
toggle with TS-side defaults (2 / 8 / 24 / 2 / `v1`). The gateway
forces visibility to `private` regardless of input — public games
come exclusively through the admin surface per FUNCTIONAL.md §3.3.
Artifacts (delivered):
- `ui/frontend/src/routes/lobby/+page.svelte` — full lobby landing
page. Header preserves the Phase 7 device-session-id and greeting
affordances; below it five sections render in a single mobile-first
column: a top-level "create new game" action (§3.3), `my games`
cards routing to `/games/:id/map` (placeholder until Phase 10,
§4.5), `pending invitations` cards with Accept / Decline (§4.3),
`my applications` cards with localised pending / approved /
rejected status (§4.5), and `public games` cards with an inline
race-name form behind a `Submit application` button (§4.2).
Convention follows the Phase 7 login page — single `max-width:
32rem` cap, no `@media` queries.
- `ui/frontend/src/routes/lobby/create/+page.svelte` (+ `+page.ts`
with `ssr = false; prerender = false;`) — create-game form with
always-visible name / description / turn-schedule / enrollment-end,
Advanced fields under `<details>` for the rest, and TS-side
defaults for the advanced inputs.
- `ui/frontend/src/api/lobby.ts` — typed wrappers around
`GalaxyClient.executeCommand` for all eight lobby commands plus a
`LobbyError` class that surfaces canonical lobby error codes
(`invalid_request`, `subject_not_found`, `forbidden`, `conflict`,
`internal_error`).
- `ui/frontend/src/api/galaxy-client.ts` — `executeCommand` now
returns `{ resultCode, payloadBytes }`; `lobby.ts` uses the
result-code branch to throw `LobbyError`.
- `pkg/model/lobby/lobby.go` — seven new message-type constants and
matching request/response structs.
- `pkg/schema/fbs/lobby.fbs` — `PublicGamesListRequest`,
`PublicGamesListResponse`, `ApplicationSummary`,
`MyApplicationsListRequest`, `MyApplicationsListResponse`,
`InviteSummary`, `MyInvitesListRequest`, `MyInvitesListResponse`,
`GameCreateRequest`, `GameCreateResponse`,
`ApplicationSubmitRequest`, `ApplicationSubmitResponse`,
`InviteRedeemRequest`, `InviteRedeemResponse`,
`InviteDeclineRequest`, `InviteDeclineResponse` tables. Reused
`GameSummary` for `GameCreateResponse.game` and `MyGamesListResponse`.
- `pkg/transcoder/lobby.go` — encode/decode pairs for all new types
plus shared helpers `encodeApplicationSummary`,
`decodeApplicationSummary`, `encodeInviteSummary`,
`decodeInviteSummary`, `unixMilliFromOptional`. Reuses
`encodeGameSummary` / `decodeGameSummary` from before.
- `gateway/internal/backendclient/lobby_commands.go` — switch cases
for the seven new message types and the per-command REST helpers
(`executeLobbyPublicGames`, `executeLobbyMyApplications`,
`executeLobbyMyInvites`, `executeLobbyGameCreate`,
`executeLobbyApplicationSubmit`, `executeLobbyInviteRedeem`,
`executeLobbyInviteDecline`); the JSON wire types from backend's
user-lobby handlers are mirrored locally for non-strict decoding.
- `gateway/internal/backendclient/routes.go` — the new message types
are wired into `LobbyRoutes`.
- `ui/frontend/src/proto/galaxy/fbs/{lobby,user}/...` — generated TS
FlatBuffers bindings (regenerated from `pkg/schema/fbs/*.fbs` via
the `fbs-ts` Make target, checked into the repo like the Connect
bindings).
- `ui/Makefile` — new `fbs-ts` target.
- `ui/frontend/package.json` — `flatbuffers` runtime dep.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — full `lobby.*`
catalogue covering sections, empty states, application form,
create form, status badges, and lobby error code translations.
- Topic doc `ui/docs/lobby.md`.
- Vitest: `tests/lobby-fbs.test.ts` (binding round-trips),
`tests/lobby-api.test.ts` (wrapper unit tests over a stub client),
`tests/lobby-page.test.ts`, `tests/lobby-create.test.ts`.
- Playwright: `tests/e2e/lobby-flow.spec.ts` (3 cases × 4 projects)
with `tests/e2e/fixtures/lobby-fbs.ts` building forged FlatBuffers
payloads through the same generated bindings the production code
uses. The Phase 7 spec was migrated to the same fixture so
`user.account.get` is now FlatBuffers end-to-end.
- Phase 7 e2e specs were updated to `click → fill` the readonly
inputs (the readonly attribute is the documented Safari
autofill-suppression workaround; `fill` checks editability before
Playwright's own focus call, so a deliberate click is required).
- `pkg/transcoder/lobby_test.go` — round-trip and corruption-recover
cases for every new pair.
- `gateway/internal/backendclient/lobby_commands_test.go` — per-RPC
success / 4xx / 5xx / 503 cases against an `httptest.Server`.
Dependencies: Phase 7.
Acceptance criteria (met):
- the user can list, create, submit-application, and accept an
invitation end-to-end against a local stack — the gateway routes
every required envelope, and the FlatBuffers wire path is the same
in production and in mocked tests;
- mobile viewport renders without horizontal scroll on
`chromium-mobile-iphone-13` and `chromium-mobile-pixel-5`;
- empty states are explicit (`no games yet`, `no invitations`,
`no applications`, `no public games`).
Targeted tests (delivered):
- Vitest binding round-trips for every lobby request/response;
- Vitest API wrapper coverage for every wrapper plus the LobbyError
surfacing path;
- Vitest component tests for the lobby page (every section, empty
states, race-name validation, Accept / Decline) and the create-game
form (validation, submission, cancel);
- Playwright e2e (3 flows × 4 projects): full create-game flow to
My Games, submit-application to My Applications pending, accept
invitation removes card and adds the game to My Games. Phase 7
auth flow now also runs over the FlatBuffers wire.
## ~~Phase 9. Map Renderer with Fixture Data~~
Status: done.
Goal: stand up the PixiJS map renderer with pan/zoom, primitive
drawing, torus wrap behaviour and bounded-plane (no-wrap) mode against
a fixture dataset, before wiring it to live game state. Both modes
are first-class — no-wrap is a real user-selectable view option, not
a deferred nicety.
Artifacts:
- `ui/frontend/src/map/world.ts` data model (`Primitive` =
`Point | Circle | Line`, `Style`, single-theme bindings) over plain
float64 world coordinates; the renderer is a vector renderer and
Pixi's transform pipeline owns the world→screen mapping
- `ui/frontend/src/map/math.ts` geometry primitives:
`torusShortestDelta`, `distSqPointToSegment`, `clamp`, and
`screenToWorld`/`worldToScreen` round-trip transforms
- `ui/frontend/src/map/render.ts` PixiJS v8 scene graph driven by
`pixi-viewport@^6` for pan/zoom/pinch with WebGPU/WebGL backend
selection via `Application.init({ preference })`; torus wrap is
rendered through nine container copies at `(±W, 0) × (±H, 0)`
- `ui/frontend/src/map/hit-test.ts` brute-force hit-test pass over
the world primitives with `[-priority, distSq, kindOrder, id]`
ordering and torus-shortest distance in `'torus'` mode
- `ui/frontend/src/map/no-wrap.ts` camera clamp helpers
(`clampCameraNoWrap`, `minScaleNoWrap`, `pivotZoom`) for bounded
plane mode; `pixi-viewport`'s `clamp`/`clampZoom` plugins are
used at the renderer level with a centring hook on `'moved'` so
the viewport-larger-than-world case stays centred
- `ui/frontend/src/map/fixtures.ts` deterministic 1000-primitive
sample world used by the playground and by manual perf checks
- `ui/frontend/src/routes/__debug/map/+page.svelte` development page
rendering the fixture world with a mode switch between torus and
no-wrap, plus a `window.__galaxyMap` debug surface for tests
- topic doc `ui/docs/renderer.md` specifying the data model,
hit-test math, torus copy rule, no-wrap camera semantics, and
the deprecation status of `galaxy/client`
Dependencies: Phase 1.
Acceptance criteria:
- a 1000-primitive fixture world pans and zooms on a mid-range
laptop with WebGPU, falling back to WebGL when WebGPU is
unavailable, in both torus and no-wrap modes; the 60 fps target
is documented in `ui/docs/renderer.md` as a manual gate, not a
CI assertion (CI runners vary too much in CPU/GPU);
- hit testing returns the expected primitive on a hand-built
fixture set covering wrap copies, line slop, ring vs filled
circles, ordering, and zoom-dependent slop;
- torus wrap renders all relevant corner copies correctly across
the viewport edges;
- no-wrap mode clamps the camera at world boundaries; pivot zoom
keeps the world point under the cursor stable; viewport never
becomes larger than the world.
Targeted tests:
- Vitest unit tests for geometry primitives, torus-shortest
distance, no-wrap clamps, pivot-zoom invariants
(`tests/map-math.test.ts`, `tests/map-no-wrap.test.ts`);
- Vitest hit-test cases for every rule in the algorithm spec
(`tests/map-hit-test.test.ts`, ~22 cases);
- Playwright visual smoke test of the playground page across all
four configured projects (`chromium-desktop` forces WebGPU,
`webkit-desktop` forces WebGL, mobile projects auto-pick),
exercising mode switch torus → no-wrap and back, wheel zoom,
no-wrap clamp after a drag past the edge, and live hit-test
plumbing (`tests/e2e/playground-map.spec.ts`).
## ~~Phase 10. In-Game Shell with View-Replacement Skeleton~~
Status: done.
Goal: assemble the in-game layout shell (header, sidebar, main area)
with empty placeholder content for every view, so navigation works
end-to-end before any data is wired.
Decisions taken with the project owner during implementation:
1. **Routing — file-system based, no extra dispatcher.** The
"view router" called out in the original artifact list is
implemented as SvelteKit's file-system routes plus thin
`+page.svelte` wrappers that mount the matching
`lib/active-view/<name>.svelte` stub. No separate dispatch
component lives in the codebase; each route file is a two-line
wrapper.
2. **Optional designer ID segments.** Both designer URLs ship as
`[[id]]` optional segments
(`designer/ship-class/[[classId]]/`,
`designer/science/[[scienceId]]/`) so Phase 18 / 21 can read
the param without a routing migration. Phase 10 stubs ignore
the param.
3. **Battle URL — optional id.** `battle/[[battleId]]/` accepts
both the list URL (`/battle`) and a specific battle URL
(`/battle/<id>`). Phase 27 keeps the optional segment and
switches behaviour based on presence.
4. **Tablet sidebar — click toggle, not swipe.** The 7681024 px
tablet sidebar slides in from a header-button click rather
than the IA section's swipe-from-right gesture. The structural
breakpoint switch satisfies Phase 10's acceptance criterion;
Phase 35 polish lands the swipe gesture.
5. **Mobile tool overlay — `mobileTool` state, gated by URL.**
The mobile bottom-tabs Calc / Order navigate to `/map` and
set a layout-owned `mobileTool` rune. The layout's derived
`effectiveTool` only honours the rune when the URL is `/map`,
so navigating to any other view via the More drawer or the
header view-menu naturally drops the overlay. The desktop
sidebar separately accepts a `?sidebar=calc|inspector|order`
URL param that seeds the initial tab on first mount, used by
later phases that want to land directly on a particular tool.
6. **Sidebar tool filenames — `*-tab.svelte`.** Phase 12 / 13 / 30
each name their final implementation
(`order-tab.svelte`, `inspector-tab.svelte`,
`calculator-tab.svelte`). The Phase 10 stubs ship with those
names so later phases replace the content in place without
renaming.
7. **Race-name and turn-counter placeholders.** The header race
name is the static `race ?` string from i18n, mirroring the
spec's static `turn ?` placeholder. Phase 11 wires both from
`user.games.report` data through `lib/header/turn-counter.svelte`.
8. **Auth gate inherited.** The root `+layout.svelte` already
redirects `anonymous → /login`; the in-game shell needs no
extra guard. Phase 10 verified this by booting the e2e shell
spec via `__galaxyDebug.setDeviceSessionId` and observing the
post-`session.init` `authenticated` status.
9. **More drawer mirrors the view-menu.** The mobile bottom-tabs
"More" drawer renders the same seven destinations as the
header view-menu. The IA section's narrower More list (Mail,
Battle log, Tables, History, Settings, Logout) is the polish
target for Phase 35 once History exists; Phase 10 keeps a
single destination list to avoid drift.
Artifacts (delivered):
- `ui/frontend/src/routes/games/[id]/+layout.svelte` — chrome
layout (header, conditional sidebar, active-view slot, mobile
bottom-tabs, mobileTool gate, sidebarOpen toggle)
- `ui/frontend/src/routes/games/[id]/+layout.ts` —
`ssr=false; prerender=false;` mirroring the root SPA flags
- `ui/frontend/src/routes/games/[id]/+page.ts` — redirects
`/games/:id` → `/games/:id/map`
- `ui/frontend/src/routes/games/[id]/{map, table/[entity], report,
battle/[[battleId]], mail, designer/ship-class/[[classId]],
designer/science/[[scienceId]]}/+page.svelte` — thin route
wrappers that mount the matching active-view stub
- `ui/frontend/src/lib/header/{header, turn-counter, view-menu,
account-menu}.svelte` — header composition with race
placeholder, turn counter (static `?`), view-menu
(dropdown desktop / hamburger mobile), and account menu
(Settings / Sessions / Theme stub buttons; Language driven by
`i18n.setLocale`; Logout calls `session.signOut("user")`)
- `ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab,
inspector-tab, order-tab, bottom-tabs}.svelte` — three-tab
sidebar with `inspector` default and `?sidebar=` URL seed;
mobile-only bottom-tabs with `[Map, Calc, Order, More]` plus a
More drawer duplicating the view-menu destinations
- `ui/frontend/src/lib/sidebar/types.ts` — shared `SidebarTab`
and `MobileTool` types
- `ui/frontend/src/lib/active-view/{map, table, report, battle,
mail, designer-ship-class, designer-science}.svelte` — Phase 10
stubs rendering localised titles plus `coming soon` copy with
stable testids that later phases replace
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — full
`game.shell.*`, `game.view.*`, `game.sidebar.*`,
`game.bottom_tabs.*` catalogue
- Topic doc `ui/docs/navigation.md`
- Vitest: `tests/game-shell-{header,sidebar,stubs}.test.ts`
- Playwright: `tests/e2e/game-shell.spec.ts` (7 cases × 4 projects;
mobile-only and viewport-switch cases conditionally skipped on
non-matching projects)
Dependencies: Phase 8.
Acceptance criteria (met):
- entering `/games/:id/map` from the lobby renders the shell with
all navigation chrome;
- header dropdown switches to every other view; mobile hamburger
does the same;
- sidebar tabs preserve their stub state across switches;
- the responsive layout matches the breakpoint diagrams in
`Information Architecture and Navigation` (with the swipe
gesture deferred to Phase 35).
Targeted tests (delivered):
- Vitest component tests for the header (race / turn placeholders,
view-menu navigation to every IA destination, account-menu
Logout / Language wiring);
- Vitest component tests for the sidebar (default tab, switching,
empty-state copy, `?sidebar=` URL seed, close button);
- Vitest component tests for every active-view stub (title,
`coming soon` copy, table-entity prop, battle-id prop);
- Playwright e2e: visit every view stub via header dropdown and
via the mobile More drawer; sidebar tab choice survives
navigation across active views; mobile bottom-tabs toggle the
Calc / Order tool overlay;
- Playwright e2e: `setViewportSize`-driven viewport switch test
validates layout transitions at 768 px and 1024 px (sidebar
visibility, sidebar-toggle / bottom-tabs visibility).
Verified on local-ci run 3 (`success`, fc371c7).
## ~~Phase 11. Map Wired to Live Game State~~
Status: done.
Goal: replace the map fixture with real planet data fetched from the
gateway for the selected game; planets only, read-only.
Decisions taken with the project owner during implementation:
1. **`current_turn` on `GameSummary`.** The user-facing
`lobby.my.games.list` did not expose the runtime's current turn
number, but the in-game shell needs it to fetch the matching
`user.games.report`. Phase 11 extends `GameSummary` with a new
`current_turn:int32` field (FB schema, Go transcoder + model,
backend `gameSummaryWire`, gateway `decodeGameSummary*`,
`backend/openapi.yaml`, TS bindings, `api/lobby.ts`). The data
was already tracked in the runtime projection
(`backend/internal/lobby/types.go RuntimeSnapshot.CurrentTurn`);
exposing it is purely a wire change. Two alternatives were
rejected: a brand-new `user.games.state` message (full wire-flow
for a one-field response) and hard-coding `turn=0` (works for the
dev sandbox, but renders the initial state for any game past
turn zero). The decision crosses Phase 8's already-shipped
catalogue per the project's "decisions baked back into the live
plan" rule.
2. **Per-game state store with context.** A `GameStateStore` lives
in `lib/game-state.svelte.ts`; the in-game shell layout
instantiates one per game and exposes it through Svelte context
under `GAME_STATE_CONTEXT_KEY`. Header turn counter, map view,
and (in later phases) inspector tabs all consume the same
instance. A new instance is created on layout remount (game id
change), so each game gets a fresh snapshot.
3. **Lobby lookup for current turn.** The store does not assume the
caller passed `current_turn` through navigation state. On
`setGame`, it calls `lobby.my.games.list` itself, finds the game
record, reads `current_turn`, and then calls
`user.games.report`. A direct deep link to `/games/:id/map` for
a game the user is not a member of flips the store to `error`
with a `not in your list` message.
4. **Refresh on tab focus.** The store installs a
`visibilitychange` listener that calls `refresh()` when the
document becomes visible and the store is `ready`. The map
view's mount effect skips a re-render when the new snapshot's
turn matches the previously-mounted turn (and the wrap mode is
unchanged), so a no-op refresh does not flicker the canvas.
5. **Wrap-mode preference.** `Cache` namespace `game-prefs`, key
`<gameId>/wrap-mode`, values `torus` (default) / `no-wrap`.
Phase 11 reads through `wrapMode`; `setWrapMode` writes back.
Phase 29 wires the toggle UI on top of these primitives.
6. **State binding.** `map/state-binding.ts::reportToWorld` emits
one Point primitive per planet across all four kinds (local /
other / uninhabited / unidentified) with distinct fill colours
and point radii. Each primitive's id reuses the engine planet
number so a hit-test result resolves directly to a planet
without an extra lookup table. Zero-planet reports yield a
well-formed empty world; the World constructor's positivity
check is guarded by a 1×1 fallback for the malformed-report
edge case.
7. **Renderer remount on snapshot change.** The map view disposes
and recreates the renderer when the report's turn changes (and
short-circuits when it does not). This is wasteful for the
tab-focus refresh path, but the renderer's external
`RendererHandle` does not yet expose a `setWorld` API and Phase
11's per-game planet count is small enough that the remount
cost (a few hundred ms) is acceptable. A future phase that adds
high-frequency updates (Phase 24 push events, Phase 34 multi-
turn projection overlays) will extract a `replaceWorld` method.
8. **e2e bootstrap reuses `__galaxyDebug`.** The Phase 10 pattern
of seeding the device session through `/__debug/store` carries
over; the gateway is mocked through `page.route` for
`lobby.my.games.list`, `user.games.report`, and the
`SubscribeEvents` stream that the revocation watcher opens
(held open indefinitely so a clean end-of-stream does not
trigger `signOut("revoked")` and bounce the test back to
`/login`).
Artifacts (delivered):
- `ui/frontend/src/api/game-state.ts` — typed wrapper for
`user.games.report` plus `uuidToHiLo` and a TS-friendly
`GameReport` shape (planets only)
- `ui/frontend/src/lib/game-state.svelte.ts` — runes-based
`GameStateStore` with init / setGame / setTurn / refresh /
setWrapMode / failBootstrap / dispose; tab-focus listener;
`Cache`-backed wrap-mode persistence
- `ui/frontend/src/map/state-binding.ts` — `reportToWorld` and the
per-kind planet styling
- `ui/frontend/src/lib/active-view/map.svelte` — replaces the
Phase 10 stub with the live renderer integration plus loading /
error overlays and a `data-planet-count` testid hook
- `ui/frontend/src/lib/header/turn-counter.svelte` — reads
`store.report.turn` through context, falls back to the static
`?` placeholder when the store has not yet produced a snapshot
- `ui/frontend/src/routes/games/[id]/+layout.svelte` — instantiates
the `GameStateStore`, builds the `GalaxyClient`, exposes the
store via `setContext`, disposes on unmount
- `pkg/schema/fbs/lobby.fbs` — `current_turn:int32` field
- `pkg/schema/fbs/lobby/GameSummary.go` (regenerated)
- `pkg/transcoder/lobby.go` — encode/decode `current_turn`
- `pkg/transcoder/lobby_test.go` — non-zero `current_turn` in the
round-trip fixture
- `pkg/model/lobby/lobby.go` — `CurrentTurn int32` on `GameSummary`
- `backend/internal/server/handlers_user_lobby_helpers.go` —
`gameSummaryWire.CurrentTurn` + `gameSummaryToWire` reads it
from `RuntimeSnapshot.CurrentTurn`; `lobbyGameDetailWire` no
longer redeclares the field
- `backend/openapi.yaml` — `current_turn` on the `GameSummary`
schema (required); removed from the `LobbyGameDetail` allOf
block (now inherited)
- `gateway/internal/backendclient/lobby_commands.go` —
`decodeGameSummaryFromGameDetail` and `decodePublicGamesPage`
parse `current_turn` from JSON
- `ui/Makefile` — `FBS_INPUTS` adds `common.fbs` (so the
`common/uuid.ts` directory is generated) and `report.fbs`
- `ui/frontend/src/proto/galaxy/fbs/{common,report}/...` —
regenerated TS bindings
- `ui/frontend/src/api/lobby.ts` — `currentTurn: number` on
`GameSummary`; `decodeGameSummary` reads it
- `ui/frontend/tests/lobby-{fbs,api,page}.test.ts` and
`tests/e2e/fixtures/lobby-fbs.ts` — fixtures and assertions
cover `currentTurn`
- `ui/frontend/tests/state-binding.test.ts` — Vitest unit
coverage for `reportToWorld` (dimensions, kinds, ids, styling,
empty-planet, zero-dimension fallback, priority order)
- `ui/frontend/tests/game-state.test.ts` — Vitest coverage for
`GameStateStore` (init flow, missing-membership error,
forbidden-result error, `setTurn`, wrap-mode persistence
across instances, `failBootstrap`)
- `ui/frontend/tests/e2e/game-shell-map.spec.ts` — Playwright e2e
with a mocked gateway: live report renders the reported turn
and planet count, zero-planet game renders without errors,
missing-membership game surfaces the error overlay
- `ui/frontend/tests/e2e/fixtures/report-fbs.ts` — `buildReportPayload`
helper for forging FB Report payloads
- Topic doc `ui/docs/game-state.md`
- `ui/docs/lobby.md` — `current_turn` note pointing at the new
game-state doc
Dependencies: Phases 9, 10.
Acceptance criteria (met):
- entering `/games/:id/map` for a game with real planets renders them
on the map;
- the turn counter in the header reflects the actual turn number;
- map state refreshes on tab focus;
- view mode (torus / no-wrap) honours the per-game preference if set,
defaults to torus otherwise.
Targeted tests (delivered):
- Vitest: `tests/state-binding.test.ts` covers the report→world
translation across every planet kind plus malformed-dimension
guards; `tests/game-state.test.ts` covers the store lifecycle
end-to-end with a stubbed `listMyGames` and a fake `GalaxyClient`;
- Playwright e2e: `tests/e2e/game-shell-map.spec.ts` exercises the
live data path with a mocked gateway across all four projects,
including the zero-planet regression and the
missing-membership error path;
- per-game wrap-scrolling preference round-trips through `Cache`
in `game-state.test.ts::setWrapMode persists across instances`;
- the existing Phase 10 chrome / navigation specs still pass
unchanged.
Verified on local-ci run 4 (`success`, ce7a66b).
## ~~Phase 12. Order Composer Skeleton~~
Status: done.
Goal: implement the empty order composer as a persistent vertical list
that survives navigation and reloads, ready to receive commands in
later phases.
Decisions taken with the project owner during implementation:
1. **Store filename uses the runes extension.** PLAN.md originally
listed `ui/frontend/src/sync/order-draft.ts`. Svelte 5 runes only
compile inside `*.svelte` / `*.svelte.ts` files; the draft state
has to be reactive so `order-tab.svelte` re-renders on
add/remove/move. The artifact ships as
`ui/frontend/src/sync/order-draft.svelte.ts`, mirroring the
Phase 11 `lib/game-state.svelte.ts` pattern.
2. **Single `placeholder` variant in the discriminated union.** The
project compactness rule rejects defining surface for the next
phase. Phase 14 owns `planetRename` end-to-end (inspector UI,
command type, submit pipeline, server-result merging) and is the
right place to add the first real variant. Phase 12 ships exactly
one variant — `{ kind: "placeholder"; id: string; label: string }`
— sufficient for the add/remove/reorder/persist tests.
3. **Reorder API is `move(fromIndex, toIndex)`.** One canonical
operation; up/down at the call site is a one-line index
arithmetic. No `moveUp`/`moveDown` aliases.
4. **Write-on-every-mutation persistence.** `add`/`remove`/`move`
each call `Cache.put` with the full draft snapshot. Phase 25 may
profile the submit pipeline and batch writes if needed; until
then deterministic writes are easier to test.
5. **Per-game scoping via Svelte context.** One `OrderDraftStore`
instance per game is created in `routes/games/[id]/+layout.svelte`
alongside `GameStateStore`, exposed through
`ORDER_DRAFT_CONTEXT_KEY`, disposed on layout destroy.
6. **`historyMode` as a prop, not a module.** Layout passes
`historyMode={false}` (a constant in Phase 12) to `Sidebar` and
`BottomTabs`; both forward to their tab-bar children which omit
the order entry when the flag is true. Phase 26 introduces the
real `lib/history-mode.ts` module and replaces the constant in
one place.
7. **Empty-state copy is `order is empty` / `приказ пуст`.** The
`coming soon` placeholder text is replaced; per-row delete
button reads `delete` / `удалить`.
8. **e2e seeding via `__galaxyDebug.seedOrderDraft`.** The existing
debug surface in `routes/__debug/store/+page.svelte` is extended
with `seedOrderDraft(gameId, commands)` and
`clearOrderDraft(gameId)` helpers that write directly to the
`order-drafts` cache namespace. The store loads the seeded draft
on the next layout mount the same way it would after a real
reload.
9. **Race / disposal hygiene mirrors `GameStateStore`.** Mutations
are gated on `status === "ready"` so calls before `init`
resolves are no-ops, and `persist` checks a `destroyed` flag so
in-flight writes after `dispose` resolve into nothing.
Artifacts (delivered):
- `ui/frontend/src/sync/order-types.ts` — `OrderCommand`
discriminated union (single `placeholder` variant) and
`CommandStatus` lifecycle type.
- `ui/frontend/src/sync/order-draft.svelte.ts` —
`OrderDraftStore` runes class with
`init` / `add` / `remove` / `move` / `dispose`, plus
`ORDER_DRAFT_CONTEXT_KEY`. Persists the full draft on every
mutation under namespace `order-drafts`, key `{gameId}/draft`.
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — replaces the
Phase 10 stub. Empty state from `game.sidebar.empty.order`;
ordered list with stable `data-testid="order-command-{i}"`
rows and a per-row delete button.
- `ui/frontend/src/lib/sidebar/sidebar.svelte`,
`tab-bar.svelte`, `bottom-tabs.svelte` — `historyMode` prop on
the sidebar forwards to `hideOrder` on tab-bar / bottom-tabs;
active-tab `order` is reset to `inspector` if the flag flips
on, and the `?sidebar=order` URL seed falls back to
`inspector` while the flag is true.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
instantiates `OrderDraftStore`, sets context, runs
`init({ cache, gameId })` next to `gameState.init` through
one `Promise.all`, disposes on destroy, passes
`historyMode={false}` down.
- `ui/frontend/src/routes/__debug/store/+page.svelte` — extended
`DebugSurface` with `seedOrderDraft` / `clearOrderDraft`.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
`game.sidebar.order.command_delete` key plus updated
`game.sidebar.empty.order` copy.
- `ui/docs/order-composer.md` — topic doc describing the
draft-replaces-server-order model, local-validation invariant,
command status state machine, persistence, history-mode wiring,
and test layout. Cross-references `storage.md` and
`navigation.md`.
- `ui/docs/storage.md` — namespace registry row for
`order-drafts`.
- `ui/docs/navigation.md` — describes the historyMode prop wiring
through Sidebar / BottomTabs.
- `ui/README.md` — new entry under topic docs for
`order-composer.md`.
- Vitest: `ui/frontend/tests/order-draft.test.ts`.
- Playwright: `ui/frontend/tests/e2e/order-composer.spec.ts`.
Dependencies: Phases 6, 10.
Acceptance criteria:
- programmatically adding a stub command shows it in the order tab;
- closing and reopening the browser preserves the draft;
- removing a command from the order tab persists the removal;
- the order tab is hidden in history mode (Phase 26) — wired here as a
prop so later phases can flip it.
Targeted tests:
- Vitest unit tests for `order-draft` covering add, remove, reorder,
persistence;
- Playwright e2e: programmatically add three stub commands, reload,
assert all three persist.
Verified on local-ci run 7 (`success`, 460591c).
## Phase 13. Inspector — Planet (Read-Only)
Status: done.
Goal: show planet details in the inspector when a planet is clicked
on the map; read-only, no actions yet.
Artifacts:
- `ui/frontend/src/lib/sidebar/inspector-tab.svelte` empty state
(`select an object on the map`) and routing per selected-object
kind. The tab reads the selection and game-state stores from
context and hands a resolved `ReportPlanet` to the planet inspector
component.
- `ui/frontend/src/lib/inspectors/planet.svelte` read-only display of
every planet field carried by the FBS report and documented in
the `rules.txt` planet section: name, coordinates, size, population,
colonists, industry, industry stockpile (`capital`, `$`), materials
stockpile (`material`, `M`), natural resources, current production
type, free production potential. Per-kind nullable fields collapse
silently — uninhabited and unidentified planets render the smaller
field set the engine carries for them.
- `ui/frontend/src/lib/inspectors/planet-sheet.svelte` mobile-only
bottom-sheet that wraps the same planet component for the < 768 px
breakpoint. Visibility is gated on `effectiveTool === "map"` so the
sheet does not stack with the calc / order overlays.
- `ui/frontend/src/lib/active-view/map.svelte` registers a click
handler against the new `RendererHandle.onClick` (built on
`pixi-viewport`'s `clicked` event), translates the hit into a
planet, and calls `SelectionStore.selectPlanet(number)`.
- `ui/frontend/src/lib/selection.svelte.ts` runes store with the
selected-object union (`{ kind: "planet"; id: number } | null`),
exposed via `setContext` from the in-game shell layout. Lifetime
matches the layout instance — selection survives every active-view
switch but does not persist across reloads.
- `ui/frontend/src/api/game-state.ts` projection extended to surface
every planet field needed by the inspector (`industryStockpile`,
`materialsStockpile`, `industry`, `population`, `colonists`,
`production`, `freeIndustry`, plus the existing `owner`).
- `ui/frontend/src/routes/games/[id]/+layout.svelte` lifts
`activeTab` into a layout-level rune bound into the sidebar, owns
the `SelectionStore`, mounts the bottom-sheet, and runs the
reveal `$effect` that flips the sidebar to the inspector tab and
opens the tablet drawer when a new selection lands.
Dependencies: Phase 11.
Acceptance criteria:
- clicking any visible planet on the map shows its details in the
inspector tab on desktop and tablet (drawer auto-opens), and in a
bottom-sheet on mobile;
- selection state persists across view switches inside `/games/:id/*`
(per global state-preservation rule); reload starts fresh;
- a click on empty map area is a no-op — selection is cleared only
by the explicit close button (``) on the mobile sheet;
- empty inspector renders the empty-state message when no planet is
selected;
- mobile dismissal is the close button only; swipe-to-dismiss and
tap-outside-to-dismiss are deferred to Phase 35;
- a selection that no longer matches a visible planet (visibility
lost between turns) collapses to the empty state instead of
showing stale rows;
- selected-planet visual feedback on the map (ring / halo) is
intentionally out of scope and rolls into Phase 35.
Targeted tests:
- Vitest unit (`tests/selection-store.test.ts`) for the runes store;
- Vitest component (`tests/inspector-planet.test.ts`) for per-kind
field rendering against synthetic `ReportPlanet` fixtures;
- Vitest component (`tests/game-shell-sidebar.test.ts`) extended for
the selection-driven inspector content and the missing-planet
fallback;
- Playwright e2e (`tests/e2e/game-shell-inspector.spec.ts`) clicks a
seeded planet on `chromium-desktop` and asserts the sidebar
inspector content, and on `chromium-mobile-iphone-13` asserts the
bottom-sheet appears and the close button clears it.
## Phase 14. First End-to-End Command — Rename Planet
Status: pending.
Goal: prove the entire pipeline (inspector → composer → submit →
server → state refresh) by wiring up exactly one action: renaming a
planet.
Artifacts:
- `ui/frontend/src/lib/inspectors/planet.svelte` adds a `Rename` action
that opens a small inline editor and adds a `RenamePlanet` command
to the order draft on confirm
- `ui/frontend/src/sync/submit.ts` `submitOrder()` function that POSTs
the entire draft via `GalaxyClient.execute('user.games.order', ...)`
and applies per-command results
- `ui/frontend/src/lib/sidebar/order-tab.svelte` adds a `Submit order`
button calling `submitOrder()` and renders accepted / rejected
status per command after submit
- on successful submit, refresh game state so the rename appears on the
map and in the inspector
Dependencies: Phases 12, 13.
Acceptance criteria:
- the user can select a planet, click `Rename`, type a new name, see
the command appear in the order tab, click `Submit`, and observe the
planet's name change everywhere within one second;
- attempting an empty or invalid name is blocked locally (button
disabled with tooltip);
- a server-side rejection (race condition) is surfaced as `rejected`
status in the order tab.
Targeted tests:
- Vitest unit tests for `submitOrder` with mocked `GalaxyClient`;
- Vitest component test for the inline rename editor including
validation;
- Playwright e2e: rename a seeded planet, reload, confirm the new name
persists.
## Phase 15. Inspector — Planet Production Controls
Status: pending.
Goal: let the user switch a planet's production type to industry,
materials, research a science, or build a ship class; each change
appends a command to the order draft.
Artifacts:
- `ui/frontend/src/lib/inspectors/planet/production.svelte` segmented
control with the four production options; a sub-picker for science
and ship class targets
- `ui/frontend/src/sync/order-types.ts` extends with
`SetProductionType` command variant
- references to `pkg/calc/` predictions (free production potential,
forecast output for current type) — wired through `ui/core/calc/`
- audit `ui/docs/calc-bridge.md` updates this phase's required calc
functions; if any are missing in `pkg/calc/`, raise as blocker
Dependencies: Phase 14.
Acceptance criteria:
- changing production type adds exactly one `SetProductionType`
command to the order draft;
- repeated changes for the same planet collapse to the latest choice
(no duplicate commands per planet);
- forecast output number reflects the chosen production type and
matches `pkg/calc/` outputs.
Targeted tests:
- Vitest unit tests for the collapse-duplicates logic in order draft;
- Vitest component tests for forecast number rendering;
- Playwright e2e: switch production three times, submit, confirm
server reflects the latest choice.
## Phase 16. Inspector — Cargo Routes
Status: pending.
Goal: configure up to four cargo routes per planet (colonists,
industry, materials, empty) through the inspector.
Artifacts:
- `ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte`
four-slot UI listing existing routes and offering add / edit /
remove
- `ui/frontend/src/sync/order-types.ts` extends with
`SetCargoRoute` and `RemoveCargoRoute` command variants
- destination-planet picker filtered by reach (uses `pkg/calc/` reach
function via `ui/core/calc/`)
- `ui/frontend/src/map/cargo-routes.ts` renders route arrows on the
map between source and destination planet, styled per cargo type
- topic doc `ui/docs/cargo-routes-ux.md` capturing the priority
semantics from [`rules.txt`](../game/rules.txt) (`colonists → industry → materials →
empty`)
Dependencies: Phase 15.
Acceptance criteria:
- the user can add, edit, and remove cargo routes through the
inspector;
- destination picker disables planets outside reach with a tooltip
explaining the constraint;
- 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.
Targeted tests:
- Vitest unit tests for slot-conflict detection;
- Vitest unit tests for cargo-route arrow rendering on torus and
no-wrap fixtures;
- Playwright e2e: add a route end-to-end, confirm server applies it
on next turn and the arrow is visible on the map.
## Phase 17. Ship Classes — CRUD Without Calc
Status: pending.
Goal: list, view, and edit ship classes through a dedicated table view
and a designer view; numeric calculations are stubbed pending Phase
18.
Artifacts:
- `ui/frontend/src/routes/games/[id]/table/ship-classes/+page.svelte`
table of ship classes with sort and filter
- `ui/frontend/src/routes/games/[id]/designer/ship-class/[id]/+page.svelte`
designer form with all five fields (Drive, Armament, Weapons,
Shields, Cargo) plus name; validation rules from [`rules.txt`](../game/rules.txt)
(each field 0 or ≥1; armament integer; weapons and armament both
zero or both nonzero)
- `ui/frontend/src/sync/order-types.ts` extends with
`CreateShipClass` and `UpdateShipClass` command variants
Dependencies: Phase 14.
Acceptance criteria:
- the user can create, list, edit, and delete ship classes;
- field validation matches [`rules.txt`](../game/rules.txt) constraints with disabled
Submit + tooltip when invalid;
- double-tapping a row in the ship-classes table opens its designer.
Targeted tests:
- Vitest component tests for designer field validation;
- Playwright e2e: create a class, list it, edit it, delete it.
## Phase 18. Ship Classes — Calc Bridge
Status: pending.
Goal: wire `pkg/calc/` ship math into the designer for live mass,
speed, range, and cargo capacity previews.
Artifacts:
- `ui/core/calc/ship.go` thin Go bridge wrapping `pkg/calc/.FullMass`,
`EmptyMass`, `Speed`, `CargoCapacity`, `WeaponsBlockMass`,
`DriveEffective` in JSON-marshallable signatures, exported through
the `Core` API
- `ui/frontend/src/platform/core/index.ts` extends `Core` interface
with the new calc methods
- live-updating preview pane in the ship-class designer showing mass,
full-load mass, max speed, range, and cargo capacity at the player's
current tech levels
- audit step recorded in `ui/docs/calc-bridge.md`: every wired
function listed against its `pkg/calc/` source
- if any required `pkg/calc/` function is missing, this phase raises a
blocker and the function is added to `pkg/calc/` first (owner-led)
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/` against `pkg/calc/` outputs on
shared fixtures;
- Vitest snapshot tests for the preview pane on canonical inputs;
- Playwright e2e: edit a ship class, observe preview updates and
submit, confirm server-side mass matches.
## Phase 19. Inspector — Ship Group (Read-Only)
Status: pending.
Goal: render ship groups on the map and display group details in the
inspector when a group is selected; read-only, no actions yet.
Artifacts:
- `ui/frontend/src/map/ship-groups.ts` renders ship groups on the map:
own and visible foreign groups stationed on planets, groups in
hyperspace at their current coordinates, and incoming groups with a
distinct visual style and an ETA label
- `ui/frontend/src/map/state-binding.ts` extends to feed groups into
the renderer alongside planets
- `ui/frontend/src/lib/inspectors/ship-group.svelte` read-only display
of group fields: class, count, tech levels of components, location
(planet or hyperspace coordinates), cargo type and amount, fleet
membership
- map click handler that selects a group and switches sidebar to
Inspector (or raises bottom-sheet on mobile)
- selection store extended to support `ShipGroup` selections
Dependencies: Phases 11, 13.
Acceptance criteria:
- own and visible foreign ship groups render on the map for a seeded
game in both torus and no-wrap modes;
- incoming groups are visually distinct and show ETA;
- clicking any rendered group shows its details in the inspector;
- groups in hyperspace show coordinates and remaining distance in the
inspector;
- cargo type and amount display when applicable.
Targeted tests:
- Vitest unit tests for the rendering of each group variant
(on-planet, in-hyperspace, incoming);
- Vitest component tests for the ship-group inspector with fixture
data covering planet-based, hyperspace, and cargo-loaded variants;
- Playwright e2e: click each variant from a seeded game, assert all
expected fields render.
## Phase 20. Inspector — Ship Group Actions
Status: pending.
Goal: enable group operations from the inspector: split, send, load,
unload, modernize, dismantle, transfer to race, add to fleet.
Artifacts:
- action buttons in `ui/frontend/src/lib/inspectors/ship-group.svelte`
with disabled-state and tooltip when local validation rejects
- `ui/frontend/src/sync/order-types.ts` extends with `SplitGroup`,
`SendGroup`, `LoadCargo`, `UnloadCargo`, `Modernize`, `Dismantle`,
`TransferToRace`, `AssignToFleet` command variants
- `Send` action picks destination through a planet picker filtered by
the group's reach (uses `pkg/calc/` reach function via Core)
- `Modernize` cost preview using `pkg/calc/` formula via Core
- confirmation dialog for `Dismantle` over a foreign planet with
colonists onboard (special-case from [`rules.txt`](../game/rules.txt): colonists die)
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:
- Vitest unit tests for action enablement logic per action;
- Vitest component tests for the dismantle-with-colonists confirmation;
- Playwright e2e for at least one complete flow (send a group between
two planets) against a local stack.
## 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.svelte`
list of sciences with name and four tech proportions
- `ui/frontend/src/routes/games/[id]/designer/science/[id]/+page.svelte`
designer with four numeric inputs that auto-normalise to 1.0 and a
name field
- `ui/frontend/src/sync/order-types.ts` extends with `CreateScience`
and `UpdateScience` command variants
- topic doc `ui/docs/science-designer-ux.md` covering
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.txt`](../game/rules.txt) constraints (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.svelte` table
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
- per-row toggle for declaring war or peace (adds
`SetDiplomaticStance` command)
- voting control: a single slot for `give my votes to <race>` (adds
`SetVoteRecipient` command)
- 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`](../game/rules.txt) and
`docs/FUNCTIONAL.md` §6.4.
Artifacts:
- `ui/frontend/src/routes/games/[id]/report/+page.svelte` scrollable
layout with one section per report category (galaxy summary, votes,
player status, my sciences, foreign sciences, my ship classes,
foreign ship classes, battles, bombings, approaching groups, my
planets, ships in production, cargo routes, foreign planets,
uninhabited planets, unknown planets, my fleets, my ship groups,
foreign ship groups, unidentified groups)
- per-section anchor navigation in a sticky sidebar for quick jumping
- a `back to map` action visible at all times
Dependencies: Phase 11.
Acceptance criteria:
- every report section renders for a seeded game with non-empty data;
- empty sections render explicit empty-state copy;
- scroll position resets when switching to another view and is
restored on return.
Targeted tests:
- Vitest component tests for one representative section per data shape
(table, list, sub-table);
- Playwright e2e: open the report, scroll to each section via anchor
navigation, assert content present.
## 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.ts` push-stream subscription wired
through `GalaxyClient.subscribeEvents` and Connect server-streaming
- on `game.turn.ready` event: 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.ts` handling 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.ts` send 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_closed` for
a submit, mark the entire draft as `conflict` and surface a
`Turn N closed before your order was accepted. Edit and resubmit.`
banner in the order tab
- topic doc `ui/docs/sync-protocol.md` covering 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`, `conflict` states per command.
Targeted tests:
- Vitest unit tests for `order-queue` covering 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.svelte` clickable turn
counter expansion: popover (desktop) / bottom-sheet (mobile) listing
recent turns and a search field for jumping to a turn number
- `ui/frontend/src/lib/history-mode.ts` global toggle wired into every
view's data source: when active, all `state-binding`, table, report,
inspector, and map sources read from the historical snapshot for the
selected turn
- `ui/frontend/src/lib/header/history-banner.svelte` persistent banner
reading `Viewing turn N · read-only` with a `Return to current turn`
action
- 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-mode` toggle 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.ts` renders markers on the map
for current-turn battles and bombings within visibility, clickable
to open the battle viewer
- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte`
view with the combatant list, the round-by-round log, and a player
control bar
- `ui/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.md` covering 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.svelte` two-pane on
desktop (list + reading), one-pane stack on mobile
- compose form for new messages targeting any other race in the game
- inbox sorted by arrival time, with read/unread state persisted via
`Cache`
- push-event integration: new mail surfaces a toast and increments an
unread badge in the header
Dependencies: Phases 22, 24.
Acceptance criteria:
- the user can read incoming mail, compose new mail, and reply to mail
end-to-end;
- unread state persists across reloads;
- server-side delivery confirmations appear on the message thread.
Targeted tests:
- Vitest component tests for the compose form including field
validation;
- Playwright e2e: send a message between two seeded players, confirm
arrival.
## Phase 29. Map Toggles
Status: pending.
Goal: deliver the gear-icon control for hiding categories of map
content and switching between torus and no-wrap view modes. All
toggleable categories are already rendered by earlier phases; this
phase only exposes the controls.
Artifacts:
- `ui/frontend/src/lib/active-view/map-toggles.svelte` gear icon in
the map view's corner; popover (desktop) / bottom sheet (mobile)
- two sections inside the popover:
- object visibility: hyperspace groups, incoming groups, cargo
routes, reach / visibility zones, battle and bombing markers
- view options: wrap scrolling (torus / no-wrap)
- planets are always rendered and not toggleable
- `ui/frontend/src/lib/map/reach-zones.ts` implementation of reach /
visibility zone overlays, off by default (the only category not yet
rendered by earlier phases)
- toggle state persists per game in `Cache`
Dependencies: Phases 9 (no-wrap engine), 11 (planets), 16 (cargo
routes), 19 (groups, incoming), 27 (battle markers).
Acceptance criteria:
- toggling each object visibility category hides or shows the
corresponding objects on the map within one frame;
- switching wrap scrolling switches the renderer between torus and
no-wrap mode without losing camera position when possible;
- toggle state persists across reloads per game;
- the gear popover is reachable on mobile through a comfortable tap
target (≥ 44 px).
Targeted tests:
- Vitest component tests for toggle state persistence;
- Vitest unit tests for reach-zone rendering on torus and no-wrap
fixtures;
- Playwright e2e in desktop and mobile viewports: toggle each
category and the wrap scrolling, assert visual change.
## Phase 30. Calculator Tab
Status: pending.
Goal: ship an independent calculator in the sidebar, callable from any
view, exposing the full set of `pkg/calc/` functions wired through
`Core`.
Artifacts:
- `ui/frontend/src/lib/sidebar/calculator-tab.svelte` UI with mode
selector (ship calculator, path calculator, modernization cost,
bombing power) and per-mode forms
- bridge entries in `ui/core/calc/` for any function not already
wrapped by Phase 18
- topic doc `ui/docs/calculator-ux.md` documenting modes,
layouts, and the rule that calculator inputs persist across
navigation
Dependencies: Phase 18.
Acceptance criteria:
- every calculator mode produces results identical to direct
`pkg/calc/` calls;
- inputs persist across view switches per global state-preservation
rule;
- calculator works in history mode against the snapshot's tech levels.
Targeted tests:
- Vitest snapshot tests per mode on canonical inputs;
- Playwright e2e: switch modes, confirm input persistence.
## Phase 31. Wails Desktop Wrapper
Status: pending. Re-evaluate Wails v2 vs v3 at phase start.
Goal: build a native desktop app for macOS, Windows, and Linux that
runs the same frontend bundle and replaces the WASM core with embedded
Go code.
Artifacts:
- topic doc `ui/docs/wails-version.md` recording the v2-vs-v3
decision made at phase start with rationale
- `ui/desktop/main.go` Wails entry point
- `ui/desktop/app.go` IPC bindings exposing `ui/core` API to the
WebView through a structured adapter
- `ui/desktop/keychain/` per-OS secure-storage helpers (macOS Keychain
via `Security` framework, Windows DPAPI, Linux Secret Service / file
fallback at `~/.config/galaxy/keypair` with mode `0600`)
- `ui/desktop/sqlite/` `modernc.org/sqlite` cache wired through Wails
IPC
- `ui/frontend/src/platform/core/wails.ts` `WailsCore` adapter
- `ui/frontend/src/platform/store/wails.ts` `WailsKeyStore` and
`WailsCache` adapters
- `ui/desktop/build/icon.icns` macOS app icon
- `ui/desktop/build/icon.ico` Windows app icon
- `ui/desktop/build/icon.png` Linux app icon
- `ui/Makefile` targets `desktop-mac`, `desktop-win`, `desktop-linux`
- topic doc `ui/docs/desktop-secure-storage.md` documenting the
Linux/Windows file fallback for missing keychains
Dependencies: Phase 6 (KeyStore and Cache interfaces); Phases 7
through 30 in their web form (the desktop wrapper exercises the same
TypeScript code).
Acceptance criteria:
- the macOS, Windows, and Linux binaries each launch, complete login,
and preserve the keypair across restarts on a fresh user profile;
- a single source codebase produces all three OS bundles;
- the same `Core` and `Storage` TypeScript interfaces are satisfied as
on web, with no platform-specific code outside `platform/`;
- Linux file fallback activates when Secret Service is absent and
writes with `0600` permissions.
Targeted tests:
- Go unit tests for each keychain helper, including file fallback;
- desktop e2e smoke test driven by Wails headless mode running the
Phase 7 login Playwright scenario via CDP;
- regression test: keychain absence on a Linux container without
libsecret falls back to file storage.
## Phase 32. Capacitor Mobile Wrapper
Status: pending.
Goal: build native iOS and Android apps that run the same frontend
bundle and call into a gomobile-compiled `ui/core`.
Artifacts:
- `ui/mobile-bridge/bridge.go` gomobile-friendly façade over `ui/core`
- `ui/Makefile` target `gomobile` producing `Galaxy.framework` and
`galaxy.aar`
- `ui/mobile/capacitor.config.ts` Capacitor project configuration
- `ui/mobile/plugins/galaxy-core/` custom Capacitor plugin (Swift +
Kotlin) wrapping the gomobile artifacts
- `ui/frontend/src/platform/core/capacitor.ts` `CapacitorCore` adapter
- `ui/frontend/src/platform/store/capacitor.ts` `CapacitorKeyStore`
and `CapacitorCache` using `@capacitor-community/secure-storage-plugin`
and `@capacitor-community/sqlite`
- `ui/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/` iOS app
icon set
- `ui/mobile/android/app/src/main/res/mipmap-*/` Android app icon
set
- iOS launch screen and Android splash screen
- `ui/Makefile` targets `ios` and `android`
- topic doc `ui/docs/mobile-bridge.md` describing the plugin
API, marshalling strategy, and the manual smoke procedure for this
phase
Dependencies: Phase 6; Phases 7 through 30 in their web form.
Acceptance criteria:
- both the iOS Simulator and an Android Emulator launch the app,
complete login, and preserve the keypair across restarts (validated
by manual smoke);
- the same `Core` and `Storage` TypeScript interfaces are satisfied as
on web and desktop;
- gomobile build produces deterministic outputs reproducible in CI on
a macOS runner.
Targeted tests:
- Go unit tests for the `mobile-bridge` façade;
- Capacitor plugin unit tests on iOS (XCTest) and Android (Espresso);
- manual smoke procedure: login flow on iOS Simulator and Android
Emulator, recorded in `ui/docs/mobile-bridge.md`. Full Appium
automation lands in Phase 36 as part of the acceptance pass.
## Phase 33. PWA — Service Worker, Manifest, Web Icons
Status: pending.
Goal: make the web build installable and offline-tolerant on every
browser. Native packaging icons live with their respective wrapper
phases (31 for desktop, 32 for mobile) — this phase is web-only.
Artifacts:
- `ui/frontend/src/service-worker.ts` cache-first asset strategy with
stale invalidation on app update
- `ui/frontend/static/manifest.webmanifest` PWA manifest
- `ui/frontend/static/icons/` web icon set sized per
`manifest.webmanifest` requirements
- topic doc `ui/docs/pwa-strategy.md` covering update flow and
offline scope
Dependencies: Phase 25 (offline order queue).
Acceptance criteria:
- the web app installs as a PWA on Chrome, Edge, and iOS Safari;
- the service worker survives an app update without serving stale code
on the next reload.
Targeted tests:
- Lighthouse PWA audit at score ≥ 90;
- Playwright test: install the app, take it offline, verify the cached
login route still loads;
- regression test: bumping the app version invalidates the prior
service worker.
## Phase 34. Multi-Turn Projection — Single-Turn Forecast and Range Circles
Status: pending. Long-term scope deferred but this phase ships real
features.
Goal: ship two concrete projection features (planet next-turn
forecast and ship-designer reach circles) plus the transient
map-overlay back-stack mechanism that the reach-circles feature is
the first user of.
Artifacts:
- `ui/frontend/src/lib/projection/` minimal projection engine that
computes one-turn-ahead state for a single planet using `pkg/calc/`
- planet inspector forecast section showing next-turn population,
industry, materials stockpile, and production progress
- `ui/frontend/src/lib/navigation/transient-overlay.ts` push/pop
back-stack mechanism for map overlays driven by other views, with
a back-button affordance on the map that returns to the originating
view with state preserved
- ship-designer `Preview range on map` action that pushes a transient
overlay onto the map showing concentric reach circles for 1, 2, 3,
4 turns from a chosen origin, computed from the in-progress ship
design and the player's current Drive tech via `ui/core/calc/`
- topic doc `ui/docs/multi-turn-projection.md` describing the
long-term vision (multi-turn planning mode, scenario branches) and
the phased path to it
Dependencies: Phases 17, 18.
Acceptance criteria:
- the planet inspector shows a forecast section with next-turn values
matching `pkg/calc/` outputs;
- the ship-designer `Preview range on map` button transitions to the
map with reach circles drawn from the chosen origin; back returns
to the designer with all in-progress state intact;
- the transient overlay is cleared if the user navigates to any other
view via the header dropdown.
Targeted tests:
- Vitest unit tests for the projection engine on canonical fixtures;
- Vitest unit tests for the transient-overlay push/pop logic and
state preservation;
- Playwright e2e: open a planet inspector, observe one-turn forecast;
open a ship designer, click `Preview range on map`, see reach
circles, click back, return with state intact.
## Phase 35. Polish — Accessibility, Localisation, Error UX
Status: pending.
Goal: prepare the client for technical beta with end-user-quality
polish.
Artifacts:
- `ui/frontend/src/lib/i18n/` translation bundles for English and
Russian, covering every visible string
- `ui/frontend/src/lib/error/` central error surface with stable codes
and retry / escalation guidance
- accessibility audit results recorded under `ui/docs/a11y.md`
- keyboard-only navigation paths for lobby, game view, and login
- focus rings, ARIA labels, screen-reader-only text where needed
- mobile bottom-sheet swipe-down dismissal and tap-outside dismissal,
on top of the close button shipped in Phase 13
- selected-planet visual on the map (ring or halo), wired off the
Phase 13 `SelectionStore`
Dependencies: Phase 33.
Acceptance criteria:
- WCAG 2.2 AA compliance on lobby, login, and the in-game shell per
axe-core scan;
- the entire UI is reachable by keyboard only with visible focus
rings;
- every server-side error is mapped to a translated, actionable user
message in both languages;
- locale switch persists across reloads on every platform.
Targeted tests:
- axe-core integration tests on every top-level view;
- Vitest tests for the i18n bundle structure and missing-translation
detection;
- Playwright keyboard-only navigation tests.
## Phase 36. Acceptance Pass
Status: pending.
Goal: reconcile implementation, documentation, and regression coverage
before declaring the client ready for technical beta.
Artifacts:
- updated `ui/README.md`, topic docs, and any drift in
`docs/ARCHITECTURE.md` or `docs/FUNCTIONAL.md` (mirrored to
`docs/FUNCTIONAL_ru.md`)
- final cross-platform regression run on a release-candidate build
- `ui/docs/release-checklist.md` for repeatable releases
- visual regression baselines committed under
`ui/frontend/tests/__snapshots__/`; if maintenance proves heavy,
follow-up issue to switch to self-hosted Argos
- Appium harness for iOS Simulator and Android Emulator covering the
login flow, push-event flow, and at least one full turn loop;
`.gitea/workflows/ui-release.yaml` extended with macOS-runner Appium
job (mandatory pre-release gate)
Dependencies: Phases 1 through 35.
Acceptance criteria:
- implementation matches every documented contract and live topic
doc;
- the cross-cutting regression scenarios listed below pass on web,
desktop, and mobile;
- Appium smoke passes on both iOS and Android in CI.
Targeted tests:
- run focused package tests for `ui/core` and every TypeScript
module;
- rerun cross-platform Playwright suites against release-candidate
builds;
- run Tier 2 visual regression baselines;
- run Appium smoke suites on iOS and Android.
---
## Cross-Cutting Regression Scenarios
- A fresh device generates a keypair, completes email-code login, and
successfully signs a follow-up authenticated request on every target
platform.
- A returning device resumes its session without re-login, preserves
queued orders, and continues receiving push events without gaps.
- Server-side session revocation tears down the active push stream and
forces a re-login on every target platform within one second.
- Tampering with `payload_bytes`, `payload_hash`, `request_id`,
`message_type`, or any signature byte is rejected by the verifier
in `ui/core` with a stable error code.
- Requests outside the freshness window are rejected before they
reach network, and the client surfaces a clock-skew warning when
its local clock disagrees with the server time event by more than
the freshness window.
- The map renderer holds 60 fps with a 1000-primitive fixture on
mid-range hardware on web (Chrome, Edge, Safari, Firefox), desktop
(Wails on macOS, Windows, Linux), and mobile (latest iPhone, mid-
range Android).
- The single-tool sidebar preserves state across tab switches; the
active view preserves state across view switches; designers
preserve their in-progress state when navigating to the map and
back through a transient overlay.
- Order draft is preserved across page reloads, view switches, network
drops, and history-mode entry / exit.
- Orders queued offline are flushed in order on reconnect; a turn-
cutoff conflict surfaces as a clearly failed-order banner without
retrying forever.
- History mode applies to every view; the order tab disappears in
history mode and the prior draft is restored on return to the
current turn.
- The ship-class designer's calculations match `pkg/calc/` byte-for-
byte; any drift between client mirror and server fails CI.
- Linux desktop builds without Secret Service still complete login by
falling back to the `0600` file under `~/.config/galaxy/`.
- The web service worker invalidates correctly on app update and
never serves stale code on the first load after a deploy.
- Push-event signature verification is mandatory; any verification
failure tears down the stream and reconnects with backoff.
- Locale switch persists across reloads and applies to every visible
string on every platform.
## TODO — deferred follow-ups from Phases 1-5
These items are explicit decisions to defer, not unknown work. Each
should be picked up either as a follow-up patch or folded into the
phase listed in the parenthesis when that phase lands.
- **Build `core.wasm` in CI, drop the committed artefacts** — install
TinyGo on the Gitea Actions runner (`brew install tinygo` is not
available on Linux runners, so use the official tarball or
`curl … | tar -xz` step), add `make -C ui wasm` ahead of the Vitest
step in `.gitea/workflows/ui-test.yaml`, then remove
`ui/frontend/static/core.wasm` and `ui/frontend/static/wasm_exec.js`
from the repo and re-tighten `ui/.gitignore`. Phase 5 committed the
binaries only as a stop-gap so contributors did not have to install
TinyGo. (Phase 5 cleanup, blocks before Phase 33 PWA.)
- **Restore `js.CopyBytesToGo` when TinyGo fixes the
`instanceof Uint8Array` check** — the per-element loop in
`ui/wasm/main.go::copyBytesFromJS` is a workaround for TinyGo 0.41
panicking on Uint8Arrays whose prototype chain crosses Node's
`Buffer`. Track upstream
(<https://github.com/tinygo-org/tinygo/issues>) and revert the
helper once a release is pinned. (Phase 5 follow-up.)
- **Migrate TS codegen to Connect-ES v2 BSR plugin once published** —
`ui/buf.gen.yaml` runs `protoc-gen-es` v2 locally because
`buf.build/connectrpc/es` is still on v1.6.1 and emits
v1-incompatible imports. When the v2 plugin lands on the BSR, we
can either keep the local plugin (no network dep) or move back to
the remote, depending on whether buf.build rate limits are hit in
CI. (Phase 5 follow-up; revisit when next regenerating.)
- **Rename `gateway/internal/grpcapi/` → `gateway/internal/connectapi/`**
— the package now hosts a Connect-Go listener that natively serves
Connect, gRPC, and gRPC-Web; the `grpcapi` name is historical.
Touches imports in `gateway/cmd/gateway/main.go` and a couple of
cross-package refs. Pure rename, no behaviour change. (Phase 4
cleanup; do alongside the next gateway change.)
- **Rename `GATEWAY_AUTHENTICATED_GRPC_*` env vars to drop the `GRPC`
infix** — they label the authenticated-edge tier, not the wire
protocol. Affects `gateway/internal/config/`, the integration
testenv defaults in `integration/testenv/gateway.go`, the README,
and the runbook. Coordinated with the package rename above.
(Phase 4 cleanup; not before the env vars are referenced by
external operators.)
- **Add a Docker-stack integration test for Connect end-to-end** —
Phase 4 closed with service-level Connect tests only. Once a phase
already brings up the full stack (Phase 7 onward, since auth flow
needs backend), drop a `integration/connect_call_test.go` that
exercises a unary Connect call and a server-streaming Connect call
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
needs it.)