# 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.

> **Status — MVP complete.** Phases 1–30 below (the full web client) are
> done. Remaining work is tracked outside this file: the active web
> finalization pass (visual system, accessibility, localisation, error
> UX, PWA, docs) in [PLAN-finalize.md](PLAN-finalize.md), and the
> deferred platform wrappers (Wails desktop, Capacitor mobile), the
> realistic multi-turn projection, and the cross-platform acceptance
> pass in [ROADMAP.md](ROADMAP.md). This file is retained as the staged
> record of how the MVP was built.
>
> **Routing — superseded by the app-shell.** After the MVP, the
> URL-based routing the per-phase artifacts below describe was refactored
> into a single-URL **app-shell**: the game UI is one SvelteKit route at
> `/game/`, the screen and the in-game view are in-memory rune state
> (`lib/app-nav.svelte.ts`), the `routes/games/[id]/` subtree and the
> per-view `+page.svelte` wrappers were removed, the in-game layout
> became `lib/game/game-shell.svelte`, and the login / lobby /
> lobby-create screens moved under `lib/screens/`. Browser Back/Forward
> move between screens via shallow routing without changing the URL — a
> model that also suits the bundled standalone targets (Wails /
> Capacitor / gomobile) that have no URLs. The current navigation model
> is described in [docs/navigation.md](docs/navigation.md) and in the
> reframed `Information Architecture and Navigation` section and Phase 10
> decisions below; the per-phase `routes/games/[id]/…` artifact paths are
> left as the historical record of what each phase delivered at the time.

The existing Fyne client in `client/` is deprecated and is not modified
or imported by the new code. The architectural overview is mirrored into
`ui/README.md`.

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 staged the MVP web client as 30 small reviewable phases (1–30,
all complete; the finalization pass and post-MVP work live in
[PLAN-finalize.md](PLAN-finalize.md) and [ROADMAP.md](ROADMAP.md)). Each
phase has a single primary goal, clear deliverables, explicit
dependencies, acceptance criteria, and focused tests. Tests live
alongside the code added in the phase; a phase is not closed until its
tests are green on the targets it claims to support.

The intended v1 architecture is:

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

## Assumptions and Defaults

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

## Information Architecture and Navigation

The client is a single-page **app-shell** with **one active view at a
time**. It is served at a single URL (`/game/`) that never changes:
the visible screen and view are in-memory state, not routes. 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.

### Screen and view model

Two pieces of in-memory state (rune singletons in
`lib/app-nav.svelte.ts`) replace what URLs used to encode — `appScreen`
(the top-level screen plus the active game id) and `activeView` (the
in-game view plus its sub-parameters):

```text
appScreen.screen ∈ {
  login,             (anonymous only)
  lobby,             (auth required)
  lobby-create,      (auth required)
  game,              (auth required; carries appScreen.gameId)
}

activeView.view ∈ {        (meaningful only while screen === game)
  map,                     (default in-game view)
  table,                   (+ tableEntity ∈ planets | ship-classes |
                            ship-groups | fleets | sciences | races)
  report,
  battle,                  (+ battleId, turn)
  mail,
  designer-science,        (+ scienceId; absent = new-science form)
}
```

The top-level screen is chosen by the single-route dispatcher
(`routes/+page.svelte`) from `session.status` + `appScreen.screen`;
the in-game shell (`lib/game/game-shell.svelte`) renders the active
view from `activeView`. Browser Back/Forward move between screens
(Back from a game → lobby) via SvelteKit shallow routing, without
changing the URL; in-game view switches do not create history entries.

Switching between views happens through the header dropdown (desktop)
or hamburger menu (mobile), driven by `activeView.select(...)`.
Double-tapping a row in a table view returns to the map focused on the
object. 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
selects any other view. The implementation is documented in
[docs/navigation.md](docs/navigation.md).

### 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 (768–1024 px): same as desktop but sidebar collapses to a
swipe-from-right drawer; a tab bar of three icons sits in the header
right corner.

Mobile (< 768 px):

```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 screen transitions

All transitions are in-memory screen/view changes; the URL stays
`/game/` throughout.

- login → lobby after successful confirm-email-code (`session.status`
  settles to `authenticated`).
- lobby → game (view `map`) when a game card is selected
  (`appScreen.go("game", { gameId })`).
- any screen → login immediately on session revocation push event
  (`session.status` settles back to `anonymous`).
- the in-game header carries a "return to lobby" control
  (`appScreen.go("lobby")`); browser Back from a game does the same.
- Designer views can push a transient overlay onto the 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.order`);
- 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/edge/v1/edgev1connect/edge_gateway.connect.go`
- `gateway/internal/grpcapi/server.go` rewritten around `http.Server`
  + `h2c.NewHandler` + `edgev1connect.NewGatewayHandler`
- new `gateway/internal/grpcapi/connect_handler.go` adapting the
  existing `edgev1.GatewayServer` 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 `edgev1connect.GatewayClient` 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 +
  `GatewayClient` factory.
- `ui/frontend/src/api/galaxy-client.ts` — `GalaxyClient` skeleton
  with injected `Signer` and `Sha256` dependencies.
- `ui/frontend/src/proto/edge/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_order`,
  `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 — single-URL app-shell, in-memory dispatch.** The game
   UI is one SvelteKit route served at `/game/`; the address bar never
   changes. The "view router" called out in the original artifact list
   is the in-memory dispatch in `lib/game/game-shell.svelte` — an
   `{#if}` ladder over `activeView.view` that mounts the matching
   `lib/active-view/<name>.svelte` stub. The top-level screen
   (login / lobby / lobby-create / game) is chosen by the single-route
   dispatcher `routes/+page.svelte` from `session.status` +
   `appScreen.screen`. Both `appScreen` and `activeView` are rune
   singletons in `lib/app-nav.svelte.ts`; there are no per-screen or
   per-view file routes (only the dev/test `/__debug/*` ones remain).
   Screen-level browser history (Back → lobby) is layered on top via
   SvelteKit shallow routing (`pushState`/`replaceState` + `page.state`)
   so the URL stays `/game/`. This single-URL model is also the natural
   fit for the deferred standalone wrappers (Wails desktop, Capacitor /
   gomobile mobile in [ROADMAP.md](ROADMAP.md)), which load a single
   bundled `index.html` with no URLs or history. See
   [docs/navigation.md](docs/navigation.md).

   > This decision supersedes the original "file-system routes plus
   > thin `+page.svelte` wrappers" plan. The app-shell transition was
   > implemented after the MVP phases: the `routes/games/[id]/`
   > subtree and the per-view route wrappers were removed, the layout
   > became `lib/game/game-shell.svelte`, and the login / lobby /
   > lobby-create screens moved under `lib/screens/`. The
   > `lib/active-view/*` components are unchanged — only how they are
   > mounted changed.
2. **In-game view sub-parameters — `activeView` state, not URL
   segments.** What were optional URL segments are now optional fields
   on `activeView` state: the science designer reads `scienceId`
   (absent = new-science form), the battle view reads `battleId`
   (empty = list) and `turn`, and the table view reads `tableEntity`.
   Later phases set these through `activeView.select(view, params)`
   instead of navigating a URL.
3. **Battle view — optional id.** The battle view accepts both the
   list state (no `battleId`) and a specific battle (`battleId` set).
   Phase 27 keeps the optional sub-param and switches behaviour based
   on presence.
4. **Tablet sidebar — click toggle, not swipe.** The 768–1024 px
   tablet sidebar slides in from a header-button click rather
   than the IA section's swipe-from-right gesture. The structural
   breakpoint switch satisfies Phase 10's acceptance criterion;
   Phase 35 polish lands the swipe gesture.
5. **Mobile tool overlay — `mobileTool` state, gated by active view.**
   The mobile bottom-tabs Calc / Order select the map view and
   set a shell-owned `mobileTool` rune. The shell's derived
   `effectiveTool` only honours the rune while `activeView.view ===
   "map"`, so selecting any other view via the More drawer or the
   header view-menu naturally drops the overlay. The sidebar tool
   state is pure in-memory rune state — there is no `?sidebar=` URL
   param (the app-shell carries no per-screen URL); the sidebar opens
   on its `inspector` default and external events flip the tab.
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 — state-based in the dispatcher.** The single-route
   dispatcher (`routes/+page.svelte`) renders the login screen for an
   `anonymous` session and the authenticated screens for an
   `authenticated` one; there is no `goto` redirect (the app-shell
   stays at `/game/`). The in-game shell needs no extra guard. Phase 10
   verified the gate by booting the e2e shell spec via
   `__galaxyDebug.setDeviceSessionId` and observing the
   post-`session.init` `authenticated` status. (Originally the gate was
   a `goto("/login")` redirect in the root layout; the app-shell
   transition replaced it with state-based rendering.)
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 (the app-shell transition later
  dropped the original `?sidebar=` URL seed — there is no per-screen
  URL to carry it); 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, 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 superseded the
   "introduce `lib/history-mode.ts`" half of this decision: the
   single derivation `historyMode = $derived(gameState.historyMode)`
   lives directly in `+layout.svelte`, the rune split between
   `currentTurn` and `viewedTurn` lives in `GameStateStore`, and
   no separate module is introduced. See Phase 26 decisions for
   the rationale.
7. **Empty-state copy is `order is empty` / `приказ пуст`.** The
   `coming soon` placeholder text is replaced; per-row delete
   button reads `delete` / `удалить`.
8. **e2e seeding via `__galaxyDebug.seedOrderDraft`.** The existing
   debug surface in `routes/__debug/store/+page.svelte` is extended
   with `seedOrderDraft(gameId, commands)` and
   `clearOrderDraft(gameId)` helpers that write directly to the
   `order-drafts` cache namespace. The store loads the seeded draft
   on the next layout mount the same way it would after a real
   reload.
9. **Race / disposal hygiene mirrors `GameStateStore`.** Mutations
   are gated on `status === "ready"` so calls before `init`
   resolves are no-ops, and `persist` checks a `destroyed` flag so
   in-flight writes after `dispose` resolve into nothing.

Artifacts (delivered):

- `ui/frontend/src/sync/order-types.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 while it is selected.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
  instantiates `OrderDraftStore`, sets context, runs
  `init({ cache, gameId })` next to `gameState.init` through
  one `Promise.all`, disposes on destroy, passes
  `historyMode={false}` down.
- `ui/frontend/src/routes/__debug/store/+page.svelte` — extended
  `DebugSurface` with `seedOrderDraft` / `clearOrderDraft`.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
  `game.sidebar.order.command_delete` key plus updated
  `game.sidebar.empty.order` copy.
- `ui/docs/order-composer.md` — topic doc describing the
  draft-replaces-server-order model, local-validation invariant,
  command status state machine, persistence, history-mode wiring,
  and test layout. Cross-references `storage.md` and
  `navigation.md`.
- `ui/docs/storage.md` — namespace registry row for
  `order-drafts`.
- `ui/docs/navigation.md` — describes the historyMode prop wiring
  through Sidebar / BottomTabs.
- `ui/README.md` — new entry under topic docs for
  `order-composer.md`.
- Vitest: `ui/frontend/tests/order-draft.test.ts`.
- Playwright: `ui/frontend/tests/e2e/order-composer.spec.ts`.

Dependencies: Phases 6, 10.

Acceptance criteria:

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

Targeted tests:

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

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

## ~~Phase 13. Inspector — Planet (Read-Only)~~

Status: done.

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

Artifacts:

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

Dependencies: Phase 11.

Acceptance criteria:

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

Targeted tests:

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

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

Status: done.

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

Decisions taken with the project owner during implementation:

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

Artifacts (delivered):

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

Dependencies: Phases 12, 13.

Acceptance criteria:

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

Targeted tests:

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

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

## ~~Phase 15. Inspector — Planet Production Controls~~

Status: done.

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

Decisions taken with the project owner during implementation:

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

Artifacts (delivered):

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

Dependencies: Phase 14.

Acceptance criteria:

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

Targeted tests:

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

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

## ~~Phase 16. Inspector — Cargo Routes~~

Status: done. Verified on local-ci run 17
([gitea actions](http://localhost:3000/galaxy/galaxy/actions/runs/17),
commit `7c8b5ae`).

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

Artifacts (Pass A — renderer foundation):

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

Artifacts (Pass B — feature):

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

Dependencies: Phase 15.

Acceptance criteria:

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

Targeted tests:

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

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

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

## ~~Phase 17. Ship Classes — CRUD Without Calc~~

Status: done (local-ci run 20). Note: Phase 30 removed the standalone
designer view/route described below and folded ship-class create/view
into the sidebar calculator; the table's row-activate and "new" button
now open the calculator. The table itself and the validator are
unchanged.

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

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

Artifacts:

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

Dependencies: Phase 14.

Acceptance criteria:

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

Targeted tests:

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

## ~~Phase 18. Ship Classes — Calc Bridge~~

Status: done. Note: the live mass/speed/range preview built here moved
into the Phase 30 calculator when the standalone designer was removed;
the `ui/core/calc` ship bridge wrapped here is reused unchanged and
extended by Phase 30 (combat, planet build, solvers).

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

Artifacts:

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

Dependencies: Phases 5 (Core skeleton), 17.

Acceptance criteria:

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

Targeted tests:

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

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

Status: done (local-ci run 24).

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

Artifacts:

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

Dependencies: Phases 11, 13.

Acceptance criteria:

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

Targeted tests:

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

## ~~Phase 20. Inspector — Ship Group Actions~~

Status: done (local-ci run 28).

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

Artifacts:

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

Dependencies: Phases 18, 19.

Acceptance criteria:

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

Targeted tests:

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

Decisions during stage:

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

## ~~Phase 21. Sciences — CRUD List + Designer~~

Status: done (local-ci run 30).

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

Artifacts:

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

Dependencies: Phase 17.

Decisions during stage:

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

Acceptance criteria:

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

Targeted tests:

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

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

Status: done.

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

Artifacts:

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

Cross-stack notes:

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

Dependencies: Phase 14.

Acceptance criteria:

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

Targeted tests:

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

## ~~Phase 23. Reports View — Current Turn Sections~~

Status: done (local-ci run 2).

Goal: present every section of the current turn's report as readable
panels, mirroring the structure documented in [`site/ru/rules.md`](../site/ru/rules.md) and
`docs/FUNCTIONAL.md` §6.4.

Artifacts:

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

Dependencies: Phase 11.

Acceptance criteria:

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

Targeted tests:

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

Decisions during stage:

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

## ~~Phase 24. Push Events — Turn-Ready~~

Status: done.

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

Artifacts (delivered):

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

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

Decisions baked back in (this stage):

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

Acceptance criteria (met):

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

Targeted tests (delivered):

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

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

Status: done.

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

Decisions baked in during implementation:

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

Backend artifacts:

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

UI artifacts:

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

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

Acceptance criteria:

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

Targeted tests:

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

## ~~Phase 26. History Mode~~

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

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

Decisions baked in during implementation:

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

Artifacts (delivered):

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

Dependencies: Phases 11, 12, 23.

Acceptance criteria:

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

Targeted tests:

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

## ~~Phase 27. Battle Viewer~~

Status: done (local-ci run 14).

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

Artifacts:

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

Dependencies: Phase 23.

Acceptance criteria:

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

Targeted tests:

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

Decisions during stage:

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

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

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

## ~~Phase 28. Diplomatic Mail View~~

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

Decisions baked in during implementation:

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

Artifacts (delivered):

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

Original phase brief follows.

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

Artifacts:

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

Dependencies: Phases 22, 24.

Acceptance criteria:

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

Targeted tests:

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

## ~~Phase 29. Map Toggles~~

Status: done.

Goal: deliver the gear-icon control for hiding categories of map
content and switching between torus and no-wrap view modes. LOCAL
planets stay always-on; every other category gets a toggle that
applies within one frame via the renderer's hide-by-id facility.

Artifacts:

- `ui/frontend/src/lib/active-view/map-toggles.svelte` — gear icon
  in the map view's corner; popover (desktop) / bottom sheet
  (mobile). Three fieldsets:
  - **Objects** — hyperspace groups, incoming groups,
    unidentified groups, cargo routes, battle markers, bombing
    markers (six independent checkboxes; battle and bombing have
    their own toggles, not a shared one).
  - **Planets** — foreign / uninhabited / unidentified kind
    toggles plus a `unreachablePlanets` switch that, when off,
    hides planets beyond `FlightDistance(localPlayerDrive)` of
    every LOCAL planet (torus-aware).
  - **View** — "visible hyperspace" checkbox + torus / no-wrap
    radios. The fog overlay is named for the visible part of the
    map (intelligence/scan area), since that is what the toggle
    controls from the player's perspective.
- `RendererHandle.setHiddenPrimitiveIds(ids)` —
  declarative hide set; flips `Graphics.visible` per copy and
  threads the set into `hitTest` so click-through to deeper
  primitives is correct.
- `RendererHandle.setVisibilityFog(circles)` — fog overlay
  drawn via Pixi v8 `Graphics.cut()` per torus copy, below
  primitives in z-order.
- `src/map/visibility.ts` — pure helpers (`computeHiddenPlanetNumbers`,
  `computeHiddenIds`, `computeFogCircles`, `isCategoryVisible`,
  `fingerprintHiddenPlanets`) consumed by the map view.
- `GameStateStore.mapToggles` rune + `setMapToggle` method;
  single-blob persistence in cache namespace `game-map-toggles`
  (key `{gameId}`, value `{toggles, lastResetTurn}`).
- New-turn reset path inside `setGame` / `advanceToPending`
  drops user overrides when `lastResetTurn < currentTurn`.

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:

- `tests/visibility-helpers.test.ts` — unit coverage for the
  hide-set / fog computation;
- `tests/state-binding-cascade.test.ts` — `reportToWorld` emits
  the `categories` + `planetDependents` maps;
- `tests/map-toggles-component.test.ts` — popover lifecycle +
  store wiring;
- `tests/map-toggles-state.test.ts` — single-blob persistence +
  new-turn reset path against a real fake-IndexedDB cache;
- `tests/map-hit-test.test.ts` — `hitTest` honours the
  `hiddenIds` parameter;
- `tests/e2e/map-toggles.spec.ts` — cascade, fog, wrap-mode
  camera preservation, reload persistence, plus render-on-demand
  (an idle map does not repaint; a released drag does not coast)
  across the four Playwright projects.

Decisions:

1. **"Reach zones" reinterpreted as `unreachablePlanets` filter.**
   The original plan listed a "reach / visibility zones" category
   rendered as concentric circles. The realised stage drops the
   circle overlay and instead exposes an inverse
   `unreachablePlanets` toggle that hides planets beyond the
   player's `FlightDistance`. Reach is already implicit in the
   reach-aware destination picker (Phase 16+), so the cleaner UX
   is filtering, not adding extra rings.
2. **Visible-hyperspace overlay**. A separate `visibleHyperspace`
   toggle draws a slightly lighter fog over the world outside the
   union of `VisibilityDistance` circles around LOCAL planets. The
   fog is a renderer-level concept (layered overpaint — fog rect
   then background-coloured circles on top — instead of Pixi's
   `Graphics.cut()`, which produced incorrect unions of holes), not
   a primitive: it never participates in hit-test.
3. **Per-kind planet toggles + unidentified-group toggle**. The
   spec's original "object visibility" list was extended:
   foreign / uninhabited / unidentified planet kinds and
   unidentified ship groups each get their own toggle.
4. **Battle and bombing markers are independent toggles.** The
   spec text grouped them as a single line item; on review the
   player wanted finer control, so each kind gets its own
   checkbox.
5. **Single-blob persistence + new-turn reset.** Toggles persist
   per game as one JSON blob `{toggles, lastResetTurn}` under
   `game-map-toggles/{gameId}`. A new server-side turn force-
   resets every flag to defaults so a hidden category cannot
   silently swallow the next turn's news. History-mode
   navigation (`viewTurn`) keeps the shared state.
6. **Hide-by-id renderer extension.** The wrap-mode toggle keeps
   the existing remount + camera-preserve path (it has to —
   torus copies need different `.visible` flags). Every
   visibility flip uses the new `setHiddenPrimitiveIds` / hide-
   aware `hitTest` so it applies within one frame.
7. **`pkg/calc/race.go` typo fixed**. The Go-side helper was
   `FligthDistance`; the Phase 29 work renamed it to
   `FlightDistance` (and the only TS call site duplicates the
   formula directly, awaiting a future race-level WASM bridge).
8. **Render-on-demand + no pan inertia (fog perf, stage 1).** The
   renderer originally kept Pixi's continuous auto-render loop, so
   the visibility fog's layered overpaint re-rasterised every frame
   and froze the whole UI on large reports in Safari (Pixi's WebGPU
   backend) — even while idle. The renderer now stops the auto-render
   loop (`app.stop()`) and paints on demand: a single `Ticker.shared`
   flush renders only when `viewport.dirty` (camera moved) or an
   internal `requestRender()` fires from a content mutation
   (`setVisibilityFog` / `setHiddenPrimitiveIds` /
   `setExtraPrimitives` / `applyMode` / `resize` / pick overlay);
   plain hover paints nothing. The `decelerate` (drag-inertia) plugin
   is removed so a released drag stops instantly and the viewport
   goes idle immediately. `RendererHandle.getRenderCount()` (mirrored
   on `__galaxyDebug` as `getMapRenderCount`) backs the e2e
   assertions. The owner confirmed this removed the idle / whole-system
   freeze, but panning a loaded map with the fog on stayed heavy in
   Safari (the overpaint fill-rate was untouched) — addressed in
   decision 9.
9. **Inverse stencil mask for the fog (fog perf, stage 2).** The fog's
   visibility holes were previously cut by opaque background-coloured
   circle overpaint — on a large report dozens of near-world-sized
   opaque circles repainted every frame, the fill-rate cliff that kept
   Safari's WebGPU pan heavy after stage 1. Stage 2 replaces the
   overpaint with an INVERSE stencil mask: `setVisibilityFog` draws the
   `FOG_COLOR` rectangle(s) into `fogLayer` and collects the visibility
   circles into one `Graphics` set as
   `fogLayer.setMask({ mask, inverse: true })`, so the fog shows
   everywhere except the union of the circles. Per-frame cost drops to
   one rectangle fill plus a stencil pass (no blended colour writes,
   friendly to Apple's tile-based GPU), and the fog stays fully vector
   — crisp at any zoom. `fogPaintOps` and its unit tests are unchanged
   (the circle ops now feed the mask instead of an overpaint); the
   rendered result is verified by a high-contrast screenshot during
   development plus the existing fog / render-on-demand e2e.

## ~~Phase 30. Ship Class Calculator~~

Status: done (gitea `go-unit` + `ui-test` green; deployed to dev). UI
polish deferred to a later pass; the core functionality is in place.

Goal: replace the standalone Phase 17/18 ship-class designer with a
fused designer + calculator living in the sidebar. It shows the ship
design blocks, live derived results (mass, speed, attack, defence,
bombing), and a planet build-rate readout, and adds single-target
goal-seek — the player pins one result and the model back-solves the
single input it claims. A second mode reuses the design area to price
ship-class modernization. The standalone designer view and route are
removed; the ship-classes table and the view/bottom menus open the
calculator instead.

The original four detached modes (ship / path / modernization /
bombing) were dropped during planning: path is deferred (MVP path is
brute force) and replaced by auto reach circles on the map; bombing is
folded in as a per-ship result; ship and modernization are the two
modes. See `ui/CALCULATOR.md` history (removed) and the interview
decisions baked below.

Goal-seek claim map (one lock at a time): attack → weapons,
defence → shields, empty/loaded speed → drive, empty mass → cargo,
loaded mass → cargo load. Locking one result disables the others'
lock affordances; an unreachable target shows the locked cell in an
error state. Tech levels and the planet MAT are override inputs with a
reset lock; the player tech is the default.

Artifacts:

- `pkg/calc/` additions, single-sourced (no mirroring): `BombingPower`
  extracted from `game/internal/model/game/group.go`; a pure
  `ProduceShipsInTurn` extracted from `controller.ProduceShip` (the
  engine now delegates to both); inverse solvers in `pkg/calc/solve.go`
  (`WeaponsForAttack`, `DriveForSpeed`, `ShieldsForDefence` by
  bisection, `CargoForEmptyMass`, `LoadForFullMass`)
- thin bridges in `ui/core/calc/` (combat, planet build, solvers),
  registered in `ui/wasm/main.go`, typed on `Core`
  (`platform/core/index.ts` + `wasm.ts`)
- `ui/frontend/src/lib/calculator/calc-model.ts` pure orchestration
  (forward results + single-target goal-seek + planet build)
- `ui/frontend/src/lib/calculator/ship-design-area.svelte` reusable
  design block (5 blocks + 4 techs, override locks, computed-block
  read-only) — earmarked for the future ship-group upgrade flow
- `ui/frontend/src/lib/sidebar/calculator-tab.svelte` shell (mode
  selector, name combobox + Create / Delete, the calc and planet areas
  inline)
- `ui/frontend/src/map/reach-circles.ts` + `lib/calculator/reach.svelte.ts`
  shared store: the calculator publishes the selected planet origin and
  loaded speed, the map draws 1–3 reach circles
- `lib/calculator/load-request.svelte.ts` shared store: the table /
  menus ask the layout to open the calculator on a class
- topic doc `ui/docs/calculator-ux.md`; `ui/docs/calc-bridge.md`
  extended with the new wired functions

Dependencies: Phases 17, 18, 19/20 (selection store), 29 (map modes).

Acceptance criteria:

- every result is byte-identical to direct `pkg/calc/` calls on shared
  fixtures (Go parity tests);
- locking one result back-solves its claimed input; a second lock is
  disabled; an unreachable target shows the error state;
- Create reuses the existing ship-class command flow and validator;
  selecting an existing class loads it as a template;
- inputs persist across view switches per the global state-preservation
  rule; the calculator works in history mode against the snapshot's
  tech levels;
- selecting an own planet draws the reach circles; clearing the
  selection or an invalid design removes them;
- the standalone designer view/route no longer exists.

Targeted tests:

- Go: `pkg/calc` unit tests + engine parity (`ProduceShip`,
  `BombingPower`); `ui/core/calc` bridge parity; solver round-trips;
- Vitest: `calc-model` (forward, goal-seek per claim, infeasible),
  `calculator-tab` (results, goal-seek, Create, planet area),
  `reach-circles` math;
- Playwright e2e: create / list / delete via the table + calculator,
  Create-disabled-while-invalid (`tests/e2e/ship-classes.spec.ts`).

Note: the WASM artefact `ui/frontend/static/core.wasm` must be rebuilt
(`make wasm`, needs TinyGo) for the new bridge functions to be present
at runtime and in the Playwright suite; Vitest injects a fake `Core`
and does not need the rebuild.
