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