docs(ui): sync docs to the app-shell; fix stale nav comments
Tests · UI / test (push) Failing after 9m28s
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:
+158
-76
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user