docs(ui): sync docs to the app-shell; fix stale nav comments
Tests · UI / test (push) Failing after 9m28s

Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy,
game-state + secondary topic docs) and ui/README for the single-URL
app-shell (in-memory screens/views, Back→lobby via shallow routing,
sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a
Phase-10 supersede note (implemented; standalone-compatible). Fix stale
code comments (session-store auth gate, report-sections spec contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-23 21:04:11 +02:00
parent 4e0058d46c
commit e31fb2c17a
17 changed files with 453 additions and 262 deletions
+135 -67
View File
@@ -18,6 +18,22 @@ module is a pure compute boundary on every platform.
> 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
@@ -130,38 +146,55 @@ The intended v1 architecture is:
## Information Architecture and Navigation
The client is a single-page application with **one active view at a
time**. Navigation is mobile-first: floating panels never overlap the
map, the main area never splits into multiple visible panels on small
screens. Desktop and mobile share the same model; on desktop, the
sidebar sits beside the active view, on mobile it lives behind a
bottom-tab bar.
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.
### View model
### 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
ActiveView ∈ {
/login, (anonymous only)
/lobby, (auth required)
/games/:id/map, (default in-game view)
/games/:id/table/:entity, (entity ∈
planets | ship-classes |
ship-groups | fleets |
sciences | races)
/games/:id/report,
/games/:id/battle/:battleId,
/games/:id/mail,
/games/:id/designer/ship-class/:id?,
/games/:id/designer/science/:id?,
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). Double-tapping a row in a `table:` view
returns to `/map` with `focus=<objectId>`. Some views can push a
transient map overlay with a back affordance (for example, ship-class
designer pushes a range-preview overlay onto the map). The transient
overlay clears when the user navigates to any other view.
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
@@ -257,12 +290,20 @@ turn current` action.
- The account menu (top-right on desktop, last hamburger entry on
mobile) holds Settings, Sessions, Theme, Language, Logout.
### Authenticated route transitions
### Authenticated screen transitions
- `/login``/lobby` after successful confirm-email-code.
- `/lobby``/games/:id/map` when a game card is selected.
- Any view → `/login` immediately on session revocation push event.
- Designer views can push a transient overlay onto `/map`; the back
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)
@@ -1062,37 +1103,58 @@ end-to-end before any data is wired.
Decisions taken with the project owner during implementation:
1. **Routing — file-system based, no extra dispatcher.** The
"view router" called out in the original artifact list is
implemented as SvelteKit's file-system routes plus thin
`+page.svelte` wrappers that mount the matching
`lib/active-view/<name>.svelte` stub. No separate dispatch
component lives in the codebase; each route file is a two-line
wrapper.
2. **Optional designer ID segments.** Both designer URLs ship as
`[[id]]` optional segments
(`designer/ship-class/[[classId]]/`,
`designer/science/[[scienceId]]/`) so Phase 18 / 21 can read
the param without a routing migration. Phase 10 stubs ignore
the param.
3. **Battle URL — optional id.** `battle/[[battleId]]/` accepts
both the list URL (`/battle`) and a specific battle URL
(`/battle/<id>`). Phase 27 keeps the optional segment and
switches behaviour based on presence.
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 7681024 px
tablet sidebar slides in from a header-button click rather
than the IA section's swipe-from-right gesture. The structural
breakpoint switch satisfies Phase 10's acceptance criterion;
Phase 35 polish lands the swipe gesture.
5. **Mobile tool overlay — `mobileTool` state, gated by URL.**
The mobile bottom-tabs Calc / Order navigate to `/map` and
set a layout-owned `mobileTool` rune. The layout's derived
`effectiveTool` only honours the rune when the URL is `/map`,
so navigating to any other view via the More drawer or the
header view-menu naturally drops the overlay. The desktop
sidebar separately accepts a `?sidebar=calc|inspector|order`
URL param that seeds the initial tab on first mount, used by
later phases that want to land directly on a particular tool.
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`,
@@ -1103,11 +1165,16 @@ Decisions taken with the project owner during implementation:
name is the static `race ?` string from i18n, mirroring the
spec's static `turn ?` placeholder. Phase 11 wires both from
`user.games.report` data through `lib/header/turn-counter.svelte`.
8. **Auth gate inherited.** The root `+layout.svelte` already
redirects `anonymous → /login`; the in-game shell needs no
extra guard. Phase 10 verified this by booting the e2e shell
spec via `__galaxyDebug.setDeviceSessionId` and observing the
post-`session.init` `authenticated` status.
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,
@@ -1136,9 +1203,11 @@ Artifacts (delivered):
`i18n.setLocale`; Logout calls `session.signOut("user")`)
- `ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab,
inspector-tab, order-tab, bottom-tabs}.svelte` — three-tab
sidebar with `inspector` default and `?sidebar=` URL seed;
mobile-only bottom-tabs with `[Map, Calc, Order, More]` plus a
More drawer duplicating the view-menu destinations
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,
@@ -1173,7 +1242,7 @@ Targeted tests (delivered):
view-menu navigation to every IA destination, account-menu
Logout / Language wiring);
- Vitest component tests for the sidebar (default tab, switching,
empty-state copy, `?sidebar=` URL seed, close button);
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
@@ -1430,8 +1499,7 @@ Artifacts (delivered):
`tab-bar.svelte`, `bottom-tabs.svelte` — `historyMode` prop on
the sidebar forwards to `hideOrder` on tab-bar / bottom-tabs;
active-tab `order` is reset to `inspector` if the flag flips
on, and the `?sidebar=order` URL seed falls back to
`inspector` while the flag is true.
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
+13 -5
View File
@@ -53,9 +53,15 @@ quick orientation; deeper design notes live under `ui/docs/`.
+ 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; the sidebar holds a single tool (calculator, inspector,
or order) with persistent state on switch.
- **Single-URL app-shell navigation:** the game UI is one route served
at `/game/`; the screen (login / lobby / game) and the in-game view
are in-memory state (`lib/app-nav.svelte.ts`), not URLs, so the
address bar never changes. Browser Back/Forward move between screens
via shallow routing without touching the URL — a model that also
suits the bundled standalone targets (Wails / Capacitor) that have no
URLs. One active view occupies the main area at a time; the sidebar
holds a single tool (calculator, inspector, or order) with persistent
state on switch. See [`docs/navigation.md`](docs/navigation.md).
## Repository layout
@@ -81,16 +87,18 @@ ui/
├── mobile/ Capacitor project (planned — see ROADMAP.md)
└── frontend/ SvelteKit / Vite source
├── src/api/ GalaxyClient + typed Connect client + auth + session
├── src/lib/ env config, session store, revocation watcher
├── src/lib/ app-shell nav + screens + game shell, env config, session store, stores
├── src/platform/core/ Core interface + WasmCore adapter
├── src/platform/store/ KeyStore/Cache interfaces + web adapter
├── src/proto/ generated Protobuf-ES + Connect descriptors + FlatBuffers TS bindings
├── src/routes/ SvelteKit routes (/, /login, /lobby, /lobby/create)
├── src/routes/ single-URL app-shell: `/game/` dispatcher (+page.svelte) + `/__debug/*`
└── static/ core.wasm + wasm_exec.js (built by `make wasm` / CI; gitignored)
```
Linked topic docs:
- [`docs/navigation.md`](docs/navigation.md) — single-URL app-shell,
screens and views as in-memory state, screen history, sidebar tools.
- [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login,
session store state machine, revocation watcher.
- [`docs/lobby.md`](docs/lobby.md) — lobby UI sections, application
+29 -20
View File
@@ -18,10 +18,15 @@ authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md).
- `ui/frontend/src/lib/revocation-watcher.ts` — minimal
`SubscribeEvents` watcher that triggers `signOut("revoked")` on
any non-aborted stream termination.
- `ui/frontend/src/routes/login/+page.svelte` — two-step form.
- `ui/frontend/src/routes/lobby/+page.svelte` — placeholder lobby
that issues the first authenticated `user.account.get`.
- `ui/frontend/src/routes/+layout.svelte`route guard plus the
- `ui/frontend/src/lib/screens/login-screen.svelte` — two-step form.
- `ui/frontend/src/lib/screens/lobby-screen.svelte` — lobby that
issues the first authenticated `user.account.get`.
- `ui/frontend/src/routes/+page.svelte`the state-based auth gate /
screen dispatcher (anonymous → login, authenticated → the
`appScreen` screen). The single-URL app-shell has no per-screen
routes; see [`navigation.md`](navigation.md).
- `ui/frontend/src/routes/+layout.svelte` — boot-time session init,
the `loading` / `unsupported` interception, and the
browser-not-supported blocker.
## State machine (`SessionStatus`)
@@ -50,8 +55,9 @@ authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md).
```
`signOut("revoked")` shares the same observable end state as
`signOut("user")`; the reason exists only for telemetry. Both
trigger the layout effect's `anonymous → /login` redirect.
`signOut("user")`; the reason exists only for telemetry. Both settle
`status` to `anonymous`, which the dispatcher renders as the login
screen — there is no URL redirect (the app-shell stays at `/game/`).
## UX states and error mapping
@@ -67,7 +73,7 @@ those branches.
| 200 from `send-email-code` | advance to step `code`, focus the code input |
| `invalid_request` from `send` | stay on step `email`, surface the gateway message |
| `service_unavailable` from `send` | stay on step `email`, surface "service is temporarily unavailable" |
| 200 from `confirm-email-code` | persist `device_session_id`, redirect to `/lobby` |
| 200 from `confirm-email-code` | persist `device_session_id`, settle `status` to `authenticated` (dispatcher shows the lobby) |
| `invalid_request` from `confirm` | bounce to step `email`, message: "code expired or already used" |
| any other error from `confirm` | stay on step `code`, surface the gateway message |
@@ -89,8 +95,10 @@ After `confirm-email-code` succeeds, `session.signIn` writes the
`device_session_id` into the IDB cache (`namespace=session`,
`key=device-session-id`). On the next page load,
`SessionStore.init` reads it back and settles `status` to
`authenticated`, so the layout effect routes the user straight to
`/lobby`.
`authenticated`, so the dispatcher renders the authenticated screen
straight away. Which authenticated screen it is comes from the
restored `appScreen` snapshot (lobby by default; see
[`navigation.md`](navigation.md)), not from the URL.
The keypair lives next to the id in the same database (object
store `keypair`, key `device`). Clearing site data wipes both;
@@ -102,21 +110,22 @@ again. This is the documented re-login path — there is no paired
The keystore relies on WebCrypto Ed25519, which currently lands in
Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see
[`storage.md`](storage.md) for the rationale). On boot the layout
runs a sanity probe (`crypto.subtle.generateKey` for `Ed25519`); if
it rejects, the layout switches to a `browser not supported` page
instead of rendering `/login`. The client deliberately does not ship a
JavaScript Ed25519 fallback — the design decision is modern-browser
baseline only.
[`storage.md`](storage.md) for the rationale). On boot the root
layout runs a sanity probe (`crypto.subtle.generateKey` for
`Ed25519`); if it rejects, `status` settles to `unsupported` and the
layout renders a `browser not supported` page instead of the login
screen. The client deliberately does not ship a JavaScript Ed25519
fallback — the design decision is modern-browser baseline only.
## Revocation
The lobby layout opens a long-running `SubscribeEvents` stream as
The root layout opens a long-running `SubscribeEvents` stream as
soon as `status` becomes `authenticated`. Its only contract is
liveness: any non-aborted termination of the stream is treated as
a server-side session revocation, the watcher calls
`session.signOut("revoked")`, and the layout effect redirects to
`/login`.
`session.signOut("revoked")`, `status` settles to `anonymous`, and
the dispatcher swaps to the login screen on the next render — the
URL stays `/game/`.
Session revocation closes the active client within one second: the
gateway closes the stream the moment it observes a
@@ -126,8 +135,8 @@ reacts on the next event-loop tick.
## Localisation
The login form, the root layout's blocker page, and the lobby
placeholder go through the i18n primitive in `src/lib/i18n/`. The
language picker on `/login` lists every entry in
screen go through the i18n primitive in `src/lib/i18n/`. The
language picker on the login screen lists every entry in
`SUPPORTED_LOCALES` by its native name and is initialised from
`navigator.languages` (web) with `en` as the fallback. Picking a
different language re-renders the form in place and is forwarded
+10 -7
View File
@@ -1,7 +1,9 @@
# Battle Viewer UX
The battle viewer is a dedicated view for battles
(`/games/<id>/battle/<battleId>`). Bombings are a separate static
The battle viewer is a dedicated active view for battles
(`activeView.view === "battle"`, with `battleId` and `turn`
sub-parameters; the app-shell has no per-view URL — see
[`navigation.md`](navigation.md)). Bombings are a separate static
table in the Reports view (`section-bombings.svelte`). The two
domains are deliberately not mixed in any visual surface or click
target.
@@ -212,7 +214,8 @@ result is an X-shaped cross overlaid on the planet glyph.
The stroke width is computed by `battleMarkerStrokeWidth(shots)`:
1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between
(`width = 1 + (shots 1) × 4 / 99`, clamped). A click on either
line navigates to `/games/<id>/battle/<battleId>?turn=<turn>`.
line opens the battle viewer in memory via
`activeView.select("battle", { battleId, turn })`.
### Bombing marker — colored ring
@@ -223,10 +226,10 @@ Colour:
- yellow (`#FFD400`) when `wiped: false`,
- red (`#FF3030`) when `wiped: true`.
A click on the ring navigates to `/games/<id>/report#report-bombings`
and scrolls the matching `report-bombing-row` (by `data-planet`)
into view. Bombing markers never open the Battle Viewer — the two
domains stay separate.
A click on the ring switches to the report view
(`activeView.select("report")`) and scrolls the matching
`report-bombing-row` (by `data-planet`) into view. Bombing markers
never open the Battle Viewer — the two domains stay separate.
## Data source
+8 -6
View File
@@ -1,9 +1,10 @@
# In-game diplomatic mail UI
The in-game mail view consumes the `diplomail` subsystem in the
backend. The route lives at `/games/:id/mail` and replaces the
active view when the user opens the "diplomatic mail" entry in the
header menu.
backend. It is the `mail` active view (`activeView.view === "mail"`)
and replaces the active view when the user opens the "diplomatic mail"
entry in the header menu (`activeView.select("mail")`). The app-shell
has no per-view URL — see [`navigation.md`](navigation.md).
## Wire surface
@@ -70,11 +71,12 @@ render the original directly with no toggle.
`diplomail.message.received` push frames are dispatched from
`api/events.svelte.ts` via the singleton SubscribeEvents stream. The
in-game layout (`routes/games/[id]/+layout.svelte`) parses the
in-game shell (`lib/game/game-shell.svelte`) parses the
verified payload, calls `mailStore.applyPushEvent(gameId)` (which
re-fetches the inbox — the payload only carries a preview), and
raises a toast through `lib/toast.svelte.ts` with a "view"
deep-link to `/games/:id/mail`.
raises a toast through `lib/toast.svelte.ts` whose "view" action
switches to the mail view in memory (`activeView.select("mail")`) —
no URL navigation.
The header view-menu's mail entry shows `mailStore.unreadCount` as
an inline pill — the only chrome the badge needs.
+5 -5
View File
@@ -93,11 +93,11 @@ reconnect.
});
onDestroy(off);
```
2. If the handler reads scoped data (per-game, per-route), register
from a layout that owns that scope and pass the gameId via a
closure. The handler should filter events whose payload does not
match its scope (see `routes/games/[id]/+layout.svelte` for the
`game.turn.ready` filter).
2. If the handler reads scoped data (per-game), register from a
component that owns that scope and pass the gameId via a closure.
The handler should filter events whose payload does not match its
scope (see `lib/game/game-shell.svelte` for the `game.turn.ready`
filter).
3. The payload encoding is owned by the producer side: the
`game.turn.ready` event uses JSON `{game_id, turn}`. Document
the schema next to the producer (e.g. `backend/README.md` §10).
+32 -12
View File
@@ -6,13 +6,15 @@ inspector tabs, the order composer, and the calculator.
## Lifecycle
`routes/games/[id]/+layout.svelte` instantiates one `GameStateStore`
per game (the layout remounts when the user navigates to a different
game id, so each game gets a fresh store). The layout exposes the
instance through Svelte context under `GAME_STATE_CONTEXT_KEY`;
descendants read it via `getContext(GAME_STATE_CONTEXT_KEY)`.
The in-game shell (`lib/game/game-shell.svelte`) instantiates one
`GameStateStore` per game. The shell is mounted by the single-route
dispatcher only while `appScreen.screen === "game"`, and remounts when
`appScreen.gameId` changes, so each game gets a fresh store. The shell
exposes the instance through Svelte context under
`GAME_STATE_CONTEXT_KEY`; descendants read it via
`getContext(GAME_STATE_CONTEXT_KEY)`.
The layout's `onMount` builds the `GalaxyClient`, loads `Cache`
The shell's boot effect builds the `GalaxyClient`, loads `Cache`
through `loadStore()`, then calls `gameState.init({ client, cache,
gameId })`. `init`:
@@ -21,9 +23,10 @@ gameId })`. `init`:
2. calls `setGame(gameId)`, which:
- reads the per-game wrap-mode preference from `Cache`
(`game-prefs / <gameId>/wrap-mode`, default `torus`);
- calls `lobby.my.games.list` and finds the game record
(`GameSummary` carries `current_turn`); if the user is not a
member, the store flips to `error`;
- calls `lobby.my.games.list` (`findGame`) and finds the game
record (`GameSummary` carries `current_turn`); if the game is not
in the player's list, the store sets the `notFound` flag (see
below);
- calls `user.games.report` for the discovered turn and decodes
the FlatBuffers response into a TS-friendly `GameReport` shape.
@@ -40,6 +43,23 @@ The store exposes:
| `pendingTurn` | `number \| null` | latest server turn the user has not yet opened |
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
| `error` | `string \| null` | localised error message when `status === "error"` |
| `notFound` | `boolean` | true when the game is not in the player's list (cancelled / removed / access revoked); the shell drops to the lobby |
## Missing or inaccessible game
A restored or stale game id (a `sessionStorage` snapshot pointing at a
game that was cancelled, removed, or whose access was revoked) is a
distinct case from a transient failure. When `findGame` returns no
matching record, `setGame` sets the boolean `notFound` flag rather
than synthesising an error message. After `init` resolves, the in-game
shell reads `gameState.notFound` and, when true, calls
`appScreen.go("lobby")` and shows a `game.events.unavailable` toast —
the player lands back in the lobby instead of on an in-game error
screen. A transient network failure takes the catch path instead,
leaving `notFound` false and flipping `status` to `error` so the
in-game error state offers a retry. `notFound` resets to false at the
start of every `setGame` / `advanceToPending`. See
[`navigation.md`](navigation.md) for the restore-and-validate flow.
## Store extensions
@@ -48,7 +68,7 @@ wire lands (ships, fleets, sciences, routes, battles, mail).
`currentTurn` is split from `viewedTurn`, and `viewTurn(turn)` /
`returnToCurrent()` handle history navigation. The derived
`historyMode` rune flips automatically when `viewedTurn <
currentTurn`; the layout passes it to the sidebar / bottom-tabs
currentTurn`; the shell passes it to the sidebar / bottom-tabs
wiring (which hides the order tab) and to
`OrderDraftStore.bindClient` (which gates `add` / `remove` / `move`).
See "History mode" below for the cache and refresh rules.
@@ -161,9 +181,9 @@ without losing the live snapshot. The store keeps two turn runes:
The derived `historyMode` rune (`status === "ready" && viewedTurn
< currentTurn`) drives every history-aware consumer:
- the layout passes it to `Sidebar` / `BottomTabs` so the order
- the shell passes it to `Sidebar` / `BottomTabs` so the order
tab vanishes;
- the layout passes a `getHistoryMode` getter to
- the shell passes a `getHistoryMode` getter to
`OrderDraftStore.bindClient` so `add` / `remove` / `move` are
no-ops while the user is looking at a past turn;
- `RenderedReportSource` returns the raw report (no order overlay)
+1 -1
View File
@@ -85,7 +85,7 @@ helper is platform-agnostic by design.
The boot locale resolves once at module load (no async init):
an explicit stored choice wins, otherwise browser/system detection,
otherwise `DEFAULT_LOCALE`. Callers that mutate the locale (the language
pickers on `/login` and in the account menu) call `i18n.setLocale(next)`,
pickers on the login screen and in the account menu) call `i18n.setLocale(next)`,
which **persists** the choice to `localStorage` (key `galaxy-locale`) so
it survives reloads. An unrecognised stored value is ignored and falls
back to detection.
+6 -5
View File
@@ -15,8 +15,8 @@ width.
| Section | Empty state | Source | Action |
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
| `create new game` | (always visible) | — | Navigates to `/lobby/create` |
| `my games` | `no games yet` | `lobby.my.games.list` | Click → `/games/:id/map` |
| `create new game` | (always visible) | — | Opens the create screen (`appScreen.go("lobby-create")`) |
| `my games` | `no games yet` | `lobby.my.games.list` | Click → enters the game on the map view (`activeView.reset()` + `appScreen.go("game", { gameId })`) |
| `pending invitations`| `no invitations` | `lobby.my.invites.list` | Accept (`lobby.invite.redeem`) / Decline (`lobby.invite.decline`) |
| `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) |
| `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) |
@@ -85,9 +85,10 @@ public game (FUNCTIONAL.md §3.3). Fields:
| `start_gap_players` | Advanced toggle | `2` | |
| `target_engine_version` | Advanced toggle | `v1` | Falls back to `v1` if blank |
On success the page navigates back to `/lobby` and the new game shows
up in `my games` once the lobby's onMount has had a chance to refresh
the list.
On success the create screen returns to the lobby
(`appScreen.go("lobby")`) and the new game shows up in `my games`
once the lobby's onMount has had a chance to refresh the list (the
lobby screen remounts on return, so its onMount re-fires).
## Errors
+158 -76
View File
@@ -1,46 +1,120 @@
# In-game shell — navigation model
This doc covers the chrome that wraps every in-game view: the
responsive layout shell, the active-view router built on SvelteKit's
file-system routes, the sidebar with three tools and its
state-preservation rule, and the mobile bottom-tabs. The user-facing
spec — view list, breakpoint diagrams, history-mode plans — lives in
[`../PLAN.md`](../PLAN.md), section
single-URL app-shell that selects screens and views from in-memory
state, the responsive layout shell, the sidebar with three tools and
its state-preservation rule, and the mobile bottom-tabs. The
user-facing spec — view list, breakpoint diagrams, history-mode plans
— lives in [`../PLAN.md`](../PLAN.md), section
`Information Architecture and Navigation`. This doc is the source of
truth for how those rules are implemented.
## Active-view model
## App-shell: one URL, screens and views as state
The client renders **one active view at a time**. Every active view is
a SvelteKit route under `routes/games/[id]/`; the route file is a
two-line wrapper that mounts the matching content component from
`src/lib/active-view/<name>.svelte`. The "view router" mentioned in
the plan is the file system plus those wrappers — there is no
separate dispatch component.
The game UI is a **single SvelteKit route served at `/game/`**. There
are no per-screen or per-view routes — the address bar stays `/game/`
for the whole session. The only other routes are the dev/test-only
`/__debug/*` surfaces. What the URL used to encode now lives in two
rune singletons in `src/lib/app-nav.svelte.ts`:
| URL | Active view component |
| ------------------------------------------ | ---------------------------------------------------------------------- |
| `/games/:id/map` | `lib/active-view/map.svelte` |
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` |
| `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) |
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` |
| `/games/:id/mail` | `lib/active-view/mail.svelte` |
| `/games/:id/designer/science/:scienceId?` | `lib/active-view/designer-science.svelte` |
- **`appScreen`** — the top-level screen
(`login` / `lobby` / `lobby-create` / `game`) plus the active
`gameId`. It replaces the old `goto`-based redirects and the `[id]`
route param.
- **`activeView`** — the in-game view (`map` / `table` / `report` /
`battle` / `mail` / `designer-science`) plus the sub-parameters the
old route segments carried (`tableEntity`, `battleId`, `turn`,
`scienceId`). It replaces the URL params the route wrappers read.
`/games/:id` (no trailing view) redirects to `/games/:id/map`. The
optional `:scienceId?` segment on the science designer route matches
SvelteKit's `[[scienceId]]` syntax — `/designer/science` opens the
empty new-science form, `/designer/science/{name}` opens the named
science. Ship-class design is folded into the sidebar ship-class
A single-route dispatcher (`src/routes/+page.svelte`) chooses what to
render: it gates on `session.status` (anonymous → login, authenticated
→ the `appScreen.screen`), and for the authenticated tree mounts the
matching screen component from `src/lib/screens/`
(`login-screen.svelte`, `lobby-screen.svelte`,
`lobby-create-screen.svelte`) or, for `screen === "game"`, the in-game
shell `src/lib/game/game-shell.svelte`. The game shell in turn renders
the active view from `activeView` (see below). Navigation is
`appScreen.go(screen, { gameId })` and `activeView.select(view,
params)` — never `goto`.
### Active-view dispatch
The client renders **one active view at a time**. The game shell
(`game-shell.svelte`) holds an `{#if}` ladder keyed on
`activeView.view` that mounts the matching content component from
`src/lib/active-view/<name>.svelte`:
| `activeView.view` | sub-params | Active view component |
| ------------------- | ----------------------- | ---------------------------------------------------------------------- |
| `map` | — | `lib/active-view/map.svelte` |
| `table` | `tableEntity` | `lib/active-view/table.svelte` |
| `report` | — | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) |
| `battle` | `battleId`, `turn` | `lib/active-view/battle.svelte` |
| `mail` | — | `lib/active-view/mail.svelte` |
| `designer-science` | `scienceId` | `lib/active-view/designer-science.svelte` |
Entering a game defaults the view to `map` (`activeView.reset()`).
The optional `scienceId` sub-param on the science designer is absent
for the empty new-science form and set to the science name for an
existing one. Ship-class design is folded into the sidebar ship-class
calculator (`lib/sidebar/calculator-tab.svelte`, see
[calculator-ux.md](calculator-ux.md)), reached from the ship-classes
table and the view/bottom menus.
The `entity` slug on the table route is kebab-case (`planets`,
`ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`).
`table.svelte` is the active-view router: it dispatches by slug to
the per-entity component (`ship-classes``table-ship-classes.svelte`;
other entities dispatch to their respective components).
The `tableEntity` slug is kebab-case (`planets`, `ship-classes`,
`ship-groups`, `fleets`, `sciences`, `races`). `table.svelte` is the
table dispatcher: it switches by slug to the per-entity component
(`ship-classes``table-ship-classes.svelte`; other entities dispatch
to their respective components).
## Screen history: Back/Forward without a URL
Browser **Back/Forward move between screens**, not views, and they do
so without ever changing the URL. The shell layers screen history on
top of `appScreen` via SvelteKit shallow routing: `appScreen.go(...)`
calls `pushState("", { screen, gameId })` for the overlay screens
(`game`, `lobby-create`) and `replaceState(...)` for `lobby` / `login`,
so browser **Back from a game returns to the lobby** beneath it. On
the first authenticated render the dispatcher stamps the restored
overlay on top of the load entry, then mirrors `page.state` back into
the store on every popstate through `appScreen.syncFromHistory(...)`.
The store is the source of truth; history only mirrors it.
In-game **view switches do not create history entries**
`activeView.select(...)` only mutates state and persists the snapshot.
Back from any in-game view therefore leaves the game entirely rather
than stepping through the views the player visited.
A **"return to lobby" control** lives in the in-game header
(`lib/header/header.svelte`, `data-testid="return-to-lobby"`,
label `game.shell.menu.return_to_lobby`); it calls
`appScreen.go("lobby")`.
## Refresh and restore
`appScreen` / `activeView` persist a snapshot (screen, game id, view +
sub-params) to `sessionStorage` (`galaxy-app-nav`) on every mutation
and read it back once at construction, so a refresh restores the last
screen and view. On a full load the dispatcher records the restored
game id (`appScreen.restoredGameId`); the game shell's boot path then
validates it against the player's game list. `GameStateStore.init`
looks the game up through `listMyGames` / `findGame`, and if the game
is gone (cancelled, removed, or access revoked) it sets the distinct
`gameState.notFound` flag. The shell reacts by dropping to the lobby
(`appScreen.go("lobby")`) with an `game.events.unavailable` toast
rather than stranding the user on an in-game error. A transient
network error keeps `notFound` false and surfaces the in-game error
state instead. See [`game-state.md`](game-state.md) for the
`notFound` semantics.
## Standalone-target compatibility
The single-URL app-shell is the natural fit for the planned
standalone wrappers (Wails desktop, Capacitor / gomobile mobile —
see [`../ROADMAP.md`](../ROADMAP.md)). Those targets load a single
bundled `index.html` with no server, no per-route URLs, and no browser
history to rely on; an in-memory screen/view model and shallow-routing
history that never touches the address bar work there unchanged.
## Sidebar tools and state preservation
@@ -53,36 +127,38 @@ The desktop sidebar hosts three tools:
| Order | `lib/sidebar/order-tab.svelte` |
The selected-tab state is a `$state` rune in
`routes/games/[id]/+layout.svelte`, bound into
`lib/sidebar/sidebar.svelte` via `$bindable()`. The layout owns the
`lib/game/game-shell.svelte`, bound into
`lib/sidebar/sidebar.svelte` via `$bindable()`. The shell owns the
rune so external events — such as a planet click — can drive the
active tab from outside the sidebar without plumbing callbacks. The component is mounted by the layout, and
SvelteKit keeps that layout instance alive while the user navigates
between child routes (`/games/:id/map``/games/:id/report` → …),
so the rune survives every active-view switch automatically with no
URL coupling needed. The URL seed and the history-mode reset
described below still live inside the sidebar — they mutate the
bindable in place; the layout sees the change through the binding.
active tab from outside the sidebar without plumbing callbacks. The
shell instance lives for the lifetime of the `game` screen, and an
in-game view switch is a pure `activeView` state change that never
remounts the shell, so the rune survives every active-view switch
automatically — it is in-memory state, with no URL coupling. The
history-mode reset described below lives inside the sidebar — it
mutates the bindable in place; the shell sees the change through the
binding.
A `?sidebar=calc|calculator|inspector|order` URL param is read once
on mount and seeds the initial tab. Navigation flows that want to
land the user on a particular tool can set this param on navigation.
The tool state is pure in-memory rune state. There is no `?sidebar=`
URL param (the app-shell has no per-screen URL to carry one) and no
default-tab URL seed; the shell opens on its `inspector` default and
external events flip the tab.
The Order entry is hidden when the layout's `historyMode` flag is
true. `+layout.svelte` forwards a derived value to `Sidebar`, which
The Order entry is hidden when the shell's `historyMode` flag is
true. `game-shell.svelte` forwards a derived value to `Sidebar`, which
forwards `hideOrder` to its `TabBar`; the same flag goes to
`BottomTabs` so the mobile `Order` button is also suppressed. A
`?sidebar=order` URL seed that arrives while the flag is true falls
back to `inspector`, and an `$effect` on the sidebar resets
`activeTab` away from `order` if the flag flips on mid-session.
`BottomTabs` so the mobile `Order` button is also suppressed. An
`$effect` on the sidebar resets `activeTab` away from `order` if the
flag flips on mid-session.
The `historyMode` flag is derived from the live history signal owned
by `GameStateStore`. The derivation lives directly in `+layout.svelte`
by `GameStateStore`. The derivation lives directly in
`game-shell.svelte`
(`const historyMode = $derived(gameState.historyMode)`) — no
separate `lib/history-mode.ts` module exists, because the layout is
separate `lib/history-mode.ts` module exists, because the shell is
the single consumer and the project's compactness rule rejects a
one-line indirection. The order draft survives the toggle because
`OrderDraftStore` lives one level above the sidebar in the layout
`OrderDraftStore` lives one level above the sidebar in the shell
hierarchy; the same `historyMode` derivation is also fed into
`OrderDraftStore.bindClient` so inspector-driven mutations
(`add` / `remove` / `move`) become no-ops while the user is
@@ -107,7 +183,7 @@ header whenever `gameState.historyMode === true`. It shows
"Viewing turn {N} · read-only" with a "Return to current turn"
button that delegates back to `gameState.returnToCurrent()`. Both
the navigator and the banner read `gameState` through context, so
the layout is the only place where the wiring lives.
the game shell is the only place where the wiring lives.
## Layout breakpoints
@@ -134,18 +210,20 @@ raises a bottom-sheet — see [Planet selection](#planet-selection).
## Mobile bottom-tabs and tool overlay
The bottom-tabs row is `[Map, Calc, Order, More]`. Map navigates to
`/games/:id/map` and clears any tool overlay. Calc and Order navigate
to `/games/:id/map` too — but they also flip the layout's
`mobileTool` state to `calc` / `order`, which the layout uses to
swap the active-view slot for the Calculator / Order tool component.
The bottom-tabs row is `[Map, Calc, Order, More]`. Map selects the
map view (`activeView.select("map")`) and clears any tool overlay.
Calc and Order select the map view too — but they also flip the
shell's `mobileTool` state to `calc` / `order`, which the shell uses
to swap the active-view slot for the Calculator / Order tool
component.
The tool overlay only applies when the URL is `/map`. Navigating to
any other view through the More drawer or the header view-menu makes
the layout's derived `effectiveTool` collapse back to `map`, so the
user always sees the URL's active view rather than a stale overlay.
The next time the user taps a Calc or Order bottom-tab, the
navigation re-routes them to `/map` and re-applies the overlay.
The tool overlay only applies while the active view is the map.
The shell's derived `effectiveTool` is gated by
`activeView.view === "map"`: selecting any other view through the More
drawer or the header view-menu collapses `effectiveTool` back to
`map`, so the user always sees the active view rather than a stale
overlay. The next time the user taps a Calc or Order bottom-tab, the
selection switches back to the map view and re-applies the overlay.
The `More` button opens a drawer that mirrors the header view-menu
content. A narrower "More" list (Mail, Battle log, Tables, History,
@@ -155,12 +233,12 @@ a single source of truth for destinations.
## Transient map overlays
Some views can push a transient overlay onto `/map` with a back
Some views can push a transient overlay onto the map view with a back
affordance. (The calculator reach circles are a simpler, always-on
map extra rather than a back-stacked overlay; the transient
back-stack mechanism is planned — see
[../ROADMAP.md](../ROADMAP.md).) A transient overlay clears when the
user navigates to any other view via the header or the bottom-tabs.
user selects any other view via the header or the bottom-tabs.
The back-stack mechanism is not yet implemented; it is planned
alongside its first user (multi-turn projection, range circles in the
@@ -180,14 +258,14 @@ translating a renderer click into a planet selection. The flow:
primitive, looks the planet up by `number` in the live
`GameStateStore.report`, and calls `SelectionStore.selectPlanet(number)`.
3. `SelectionStore` (`lib/selection.svelte.ts`) is a runes store
instantiated by the layout and exposed via Svelte context under
instantiated by the game shell and exposed via Svelte context under
`SELECTION_CONTEXT_KEY`. It carries a discriminated union —
`{ kind: "planet"; id: number }` for planets and widened for
ship groups. Selection is in-memory only: it survives the
layout's lifetime (active-view switches inside `/games/:id/*`)
but does not persist across reloads — that contrast with the
order draft is intentional.
4. The layout watches the selection rune and, on the null → planet
shell's lifetime (in-memory `activeView` switches inside the game
screen) but does not persist across reloads — that contrast with
the order draft is intentional.
4. The shell watches the selection rune and, on the null → planet
transition, flips its bound `activeTab` to `inspector` and
`sidebarOpen` to `true`. Desktop already has the sidebar pinned;
tablet needs the drawer to surface; mobile is unaffected by the
@@ -201,7 +279,7 @@ translating a renderer click into a planet selection. The flow:
state instead of holding stale rows.
The mobile bottom-sheet is mounted alongside `<BottomTabs />` in the
layout. Its visibility is conditional on `effectiveTool === "map"` so
game shell. Its visibility is conditional on `effectiveTool === "map"` so
it does not stack on top of the calc / order overlays. The dismissal
surface is a close button (`✕`) that calls `SelectionStore.clear()`.
Tap-outside and swipe-down dismissal are deferred to the finalization
@@ -224,8 +302,12 @@ together with the sheet's swipe-to-dismiss gesture.
## Auth gate
The root `+layout.svelte` redirects `anonymous → /login` for any
non-`/__debug/` path; the in-game shell inherits that gate without
any extra check. When a session is revoked while the user is in the
shell, the same redirect fires through the existing
revocation watcher.
The auth gate is state-based, applied by the dispatcher
(`src/routes/+page.svelte`): an `anonymous` session renders the login
screen, an `authenticated` one renders the `appScreen.screen` (lobby /
game / …). There is no `goto("/login")` redirect. When a session is
revoked while the user is in the game shell, the revocation watcher
flips `session.status` back to `anonymous`, and the dispatcher swaps
the whole tree to the login screen on the next render — the URL stays
`/game/` throughout. See [`auth-flow.md`](auth-flow.md) for the
session state machine.
+9 -11
View File
@@ -195,15 +195,15 @@ Lifecycle:
| `dispose()` | Marks the store destroyed; subsequent `persist()` calls are no-ops so a fast game-switch does not write stale state into the next id. |
Mutations made before `init` resolves are silently ignored — the
layout always awaits `init` through `Promise.all([...])` next to
shell always awaits `init` through `Promise.all([...])` next to
`gameState.init` before exposing the store.
Layout integration mirrors `GameStateStore`:
Shell integration mirrors `GameStateStore`:
- One instance per game, created in
[`../frontend/src/routes/games/[id]/+layout.svelte`](../frontend/src/routes/games/[id]/+layout.svelte).
- One instance per game, created in the in-game shell
[`../frontend/src/lib/game/game-shell.svelte`](../frontend/src/lib/game/game-shell.svelte).
- Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context.
- Disposed in the layout's `onDestroy`.
- Disposed in the shell's `onDestroy`.
The order tab and the planet inspector both consume the store via
`getContext(ORDER_DRAFT_CONTEXT_KEY)` to push new commands.
@@ -257,7 +257,7 @@ snapshot (history mode is the planned reader).
`OrderDraftStore` records `needsServerHydration = true` when no
cache row exists for the active game (fresh install, cleared
storage, switching device). After the layout boot resolves both
storage, switching device). After the shell boot resolves both
`gameState.init` and `orderDraft.init`, it calls
`orderDraft.hydrateFromServer({ client, turn })` which issues
`user.games.order.get` against the gateway. A `found = false`
@@ -294,14 +294,12 @@ report as it was. The Order tab is hidden when history mode is active
— the player is browsing an immutable snapshot, and composing commands
against it would be confusing.
The layout owns the `historyMode` flag and passes it to:
The in-game shell owns the `historyMode` flag and passes it to:
- `Sidebar` as `historyMode`. The sidebar forwards it to its
`TabBar` as `hideOrder`. The Order entry is filtered out of the
tab list when true. If a `?sidebar=order` URL seed lands while
the flag is true, the sidebar falls back to `inspector`. If the
active tab is `order` when the flag flips on, an effect resets
it to `inspector`.
tab list when true. If the active tab is `order` when the flag
flips on, an effect resets it to `inspector`.
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
button is suppressed when true.
+13 -3
View File
@@ -4,12 +4,21 @@ The web client is an installable, offline-tolerant PWA. It uses
SvelteKit's native service worker (no Workbox) so there is no extra
build dependency and the cache logic stays explicit.
The single-URL app-shell (see [`navigation.md`](navigation.md)) makes
the offline story simpler: the whole game UI lives at one route
(`${base}/`, i.e. `/game/` under the single-origin deployment), so
there is exactly one navigation target to precache and fall back to —
no per-screen routes to enumerate. The service-worker scope and the
manifest are unchanged by that refactor; both were already base-aware
(the SW keys everything off `$service-worker`'s `base`, and the
manifest uses relative `./` `start_url` / `scope`).
## Pieces
- [`src/service-worker.ts`](../frontend/src/service-worker.ts) — the
worker. SvelteKit registers it automatically in the production build.
It precaches the app shell (`/`), the build artefacts (JS/CSS +
`core.wasm`), and the static files under a **version-keyed** cache
It precaches the app shell (`${base}/`), the build artefacts (JS/CSS
+ `core.wasm`), and the static files under a **version-keyed** cache
(`galaxy-cache-<version>`, `version` from `$service-worker`). On
`activate` it deletes every other cache, so a new deploy never serves
stale code. Strategy: cache-first for the version-keyed build/files;
@@ -17,7 +26,8 @@ build dependency and the cache logic stays explicit.
shell answers navigations when fully offline. The gateway (cross-
origin) is never intercepted — it is always live network.
- [`static/manifest.webmanifest`](../frontend/static/manifest.webmanifest)
— name, `standalone` display, `start_url`/`scope` `/`, dark
— name, `standalone` display, relative `./` `start_url`/`scope` (so
it resolves under whatever `base` the build is deployed at), dark
`theme_color`/`background_color`, and the icon set.
- [`static/icons/`](../frontend/static/icons/) — `192`/`512` (`any`),
a `512` `maskable`, and a `180` apple-touch icon. They are placeholder
+18 -28
View File
@@ -107,36 +107,23 @@ visible area so that scrolling down advances the highlight
naturally. The observer is created on mount and torn down on
unmount.
The in-game shell layout (`routes/games/[id]/+layout.svelte`)
The in-game shell (`lib/game/game-shell.svelte`)
expands `<main class="active-view-host">` to fit content rather
than constraining it, so the document body is the actual scroll
container — not the host. The IntersectionObserver root is `null`
to match.
## Scroll save / restore
## Scroll position
`routes/games/[id]/report/+page.svelte` exports a SvelteKit
`Snapshot<{ scrollY: number }>`:
- `capture()` reads `window.scrollY` when SvelteKit's
`beforeNavigate` cycle runs.
- `restore(value)` schedules a short
`requestAnimationFrame` poll that waits for
`document.documentElement.scrollHeight` to grow tall enough to
honour the saved offset, then calls `window.scrollTo(0, value)`.
The poll caps at ~60 frames (≈ 1 second) so a never-tall-enough
body never pins a frame loop.
The capture / restore pair is keyed by route, so:
- Forward navigation from `/report` to `/map` lands `/map` at
scrollY 0 (no snapshot for `/map` to restore from).
- History-back from `/map` to `/report` restores the previously
captured scrollY — the user returns to the same section.
The Snapshot API does not capture the active sidebar slug; the
IntersectionObserver re-derives it from the restored scroll
position on the next animation frame, which keeps the TOC
highlight consistent without a second source of truth.
The report is the `report` active view; switching to another view is
an in-memory `activeView` state change, not a navigation, and the
report component is remounted when the user returns to it. The
single-URL app-shell therefore does not carry SvelteKit's route-keyed
`Snapshot` scroll save/restore — that mechanism was tied to the old
`/games/:id/report` route and was removed with it. A re-entered report
opens at the top; the IntersectionObserver re-derives the active TOC
slug from the scroll position on the next animation frame, so the
highlight stays consistent without a separate source of truth.
## i18n namespace
@@ -169,10 +156,13 @@ couple them silently.
/ IntersectionObserver are out of scope.
- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
the full integration: every TOC anchor lands its section in
view, the snapshot mechanism preserves `window.scrollY` on
history navigation, the back-to-map button reaches `/map`, the
mobile `<select>` scrolls to the chosen section on a narrow
viewport.
view, the back-to-map button switches to the map view
(`activeView.select("map")`), and the mobile `<select>` scrolls
to the chosen section on a narrow viewport. The spec drives the
app-shell through `window.__galaxyNav` (the dev-only nav surface)
instead of `page.goto` per-view URLs. The old "scroll position
survives a `/map` round-trip via SvelteKit `Snapshot`" case was
dropped — see the [scroll position](#scroll-position) note.
Test IDs follow the pattern `report-section-<slug>` for section
roots, `report-toc-<slug>` for TOC anchors, and per-section row
+2 -1
View File
@@ -186,7 +186,8 @@ Thin orchestration layer over `KeyStore` + `Cache`:
push-event-driven revocation path.
A `null` `deviceSessionId` is the signal that the session is
unauthenticated — the root layout routes such users to `/login`.
unauthenticated — `session.status` settles to `anonymous` and the
dispatcher renders the login screen (the app-shell stays at `/game/`).
## Test layout
+9 -8
View File
@@ -128,9 +128,10 @@ report" affordance (`import.meta.env.DEV`). The flow is:
2. Run the UI dev server (`pnpm -C ui/frontend dev`), open the lobby,
and use the "Load JSON…" file picker in the **Synthetic test
reports (DEV)** section. The page navigates to
`/games/synthetic-<uuid>/map` with the report wired into the
in-game shell.
reports (DEV)** section. The lobby enters a `synthetic-<uuid>` game
on the map view (`activeView.reset()` + `appScreen.go("game", {
gameId })`) with the report wired into the in-game shell. The
app-shell URL stays `/game/` — see [`navigation.md`](navigation.md).
In synthetic mode:
@@ -139,16 +140,16 @@ In synthetic mode:
- Composing orders works locally (overlay applies through
`applyOrderOverlay` as usual), but **nothing is sent to the
gateway** — `OrderDraftStore.scheduleSync` short-circuits because
the synthetic id is not a UUID and the layout deliberately does
not bind a `GalaxyClient` for this game.
the synthetic id is not a UUID and the in-game shell deliberately
does not bind a `GalaxyClient` for this game.
- The order draft is persisted into the platform `Cache` under the
same `order-drafts` namespace as real games, keyed by the
synthetic id, so navigating back into the same synthetic session
restores the draft. The cache is cleared with
`__galaxyDebug.clearOrderDraft(gameId)` (DEV debug surface).
- A page reload loses the in-memory report registry; opening the
same synthetic id afterwards redirects to /lobby. Re-load the JSON
to reseed.
- A page reload loses the in-memory report registry; a restored
synthetic game whose report is gone falls back to the lobby
(`appScreen.go("lobby")`). Re-load the JSON to reseed.
The synthetic-report parity rule requires every change that extends
`decodeReport` to also extend the legacy parser in lockstep, or to
+3 -3
View File
@@ -16,8 +16,8 @@
// asynchronously; the watcher in `lib/revocation-watcher.ts` calls
// it without user interaction. The post-condition is the same as
// `signOut("user")` — keypair regenerated, session id wiped,
// status returned to `anonymous` — so the layout's existing
// `anonymous → /login` redirect handles both reasons uniformly.
// status returned to `anonymous` — so the dispatcher's state-based
// auth gate renders the login screen for both reasons uniformly.
import type {
Cache,
@@ -83,7 +83,7 @@ export class SessionStore {
* revoked public key, and returns the status to `anonymous`. The
* `reason` is recorded in console output for telemetry but does
* not change the post-state — both user-driven logout and
* gateway-driven revocation land the user back on `/login`.
* gateway-driven revocation return the user to the login screen.
*/
async signOut(reason: "user" | "revoked"): Promise<void> {
if (this.keyStore === null || this.cache === null) {
@@ -6,10 +6,8 @@
// 1. Every TOC anchor click scrolls the matching section into view
// and the section is present in the DOM with at least one row
// (or its empty-state copy when it is intentionally empty).
// 2. Snapshot save/restore on the active-view-host scroll
// container survives a /map navigation round-trip.
// 3. The "back to map" button navigates to the map URL.
// 4. The mobile <select> fallback scrolls a section into view on
// 2. The "back to map" button switches to the map view.
// 3. The mobile <select> fallback scrolls a section into view on
// a narrow viewport.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";