a89048f6c5
MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that. - PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path. - ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35. - ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups. - ui/docs/README.md (new): grouped topic-doc index. - De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
12 KiB
Markdown
232 lines
12 KiB
Markdown
# 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
|
||
`Information Architecture and Navigation`. This doc is the source of
|
||
truth for how those rules are implemented.
|
||
|
||
## Active-view model
|
||
|
||
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.
|
||
|
||
| 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` |
|
||
|
||
`/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
|
||
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).
|
||
|
||
## Sidebar tools and state preservation
|
||
|
||
The desktop sidebar hosts three tools:
|
||
|
||
| Tool | Component |
|
||
| ---------- | ----------------------------------- |
|
||
| Calculator | `lib/sidebar/calculator-tab.svelte` |
|
||
| Inspector | `lib/sidebar/inspector-tab.svelte` |
|
||
| 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
|
||
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.
|
||
|
||
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 Order entry is hidden when the layout's `historyMode` flag is
|
||
true. `+layout.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.
|
||
|
||
The `historyMode` flag is derived from the live history signal owned
|
||
by `GameStateStore`. The derivation lives directly in `+layout.svelte`
|
||
(`const historyMode = $derived(gameState.historyMode)`) — no
|
||
separate `lib/history-mode.ts` module exists, because the layout 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
|
||
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
|
||
viewing a past turn. See [`order-composer.md`](order-composer.md)
|
||
for the draft-store side of the flow and
|
||
[`game-state.md`](game-state.md) for the rune split between
|
||
`currentTurn` and `viewedTurn`.
|
||
|
||
## Header turn navigator and history banner
|
||
|
||
The header shows a `← Turn N →` triplet (`lib/header/turn-navigator.svelte`). The
|
||
arrows step `viewedTurn` by ±1 (disabled at boundaries `0` and
|
||
`currentTurn`); clicking the middle button opens an absolute
|
||
popover (desktop) or a fixed full-width drawer (mobile, ≤ 767.98
|
||
px) listing every turn from `currentTurn` down to `0`. Selecting
|
||
the current-turn row routes through `gameState.returnToCurrent()`;
|
||
any other row calls `gameState.viewTurn(N)`. The popover reuses
|
||
`view-menu.svelte`'s outside-click / Escape pattern.
|
||
|
||
`lib/header/history-banner.svelte` renders directly under the
|
||
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.
|
||
|
||
## Layout breakpoints
|
||
|
||
Three discrete CSS modes matched to the IA section diagrams:
|
||
|
||
- **≥ 1024 px (desktop)** — the sidebar sits beside the active view
|
||
and is always rendered. The header view-menu trigger uses the
|
||
dropdown icon (▾). Bottom-tabs and the tablet sidebar-toggle are
|
||
CSS-hidden.
|
||
- **768–1024 px (tablet)** — the sidebar collapses behind a click
|
||
toggle in the header right corner. Tapping the toggle slides the
|
||
sidebar in as a fixed overlay above the active view; a close
|
||
button on the sidebar dismisses it. The full swipe-from-right
|
||
gesture is deferred to the finalization plan
|
||
([../PLAN-finalize.md](../PLAN-finalize.md)).
|
||
- **< 768 px (mobile)** — the sidebar is hidden entirely and the
|
||
bottom-tabs row appears at the bottom of the viewport. The
|
||
view-menu trigger swaps to a hamburger icon (☰) that opens the
|
||
drop-down as a full-width drawer below the header.
|
||
|
||
On mobile the bottom tab row does not include `Inspector`. The
|
||
inspector content is reached by tapping a map object instead, which
|
||
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 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 `More` button opens a drawer that mirrors the header view-menu
|
||
content. A narrower "More" list (Mail, Battle log, Tables, History,
|
||
Settings, Logout) is deferred to the finalization plan
|
||
([../PLAN-finalize.md](../PLAN-finalize.md)); the current drawer keeps
|
||
a single source of truth for destinations.
|
||
|
||
## Transient map overlays
|
||
|
||
Some views can push a transient overlay onto `/map` 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.
|
||
|
||
The back-stack mechanism is not yet implemented; it is planned
|
||
alongside its first user (multi-turn projection, range circles in the
|
||
ship-class designer) in [../ROADMAP.md](../ROADMAP.md).
|
||
|
||
## Planet selection
|
||
|
||
The map view is the entry point for the inspector by
|
||
translating a renderer click into a planet selection. The flow:
|
||
|
||
1. The renderer (`src/map/render.ts`) exposes `onClick(cb)` next to
|
||
the existing `hitAt(cursor)`. It is built on `pixi-viewport`'s
|
||
`clicked` event, which already differentiates a click from a
|
||
pan-drag, so a click handler will not race the pan plugin.
|
||
2. `lib/active-view/map.svelte` wires that callback after a successful
|
||
`mountRenderer`. On a click it asks the renderer for the hit
|
||
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
|
||
`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
|
||
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
|
||
tab rune because the sidebar is CSS-hidden there.
|
||
5. `lib/sidebar/inspector-tab.svelte` and
|
||
`lib/inspectors/planet-sheet.svelte` both read the selection
|
||
store, resolve it against the live report, and either render
|
||
`lib/inspectors/planet.svelte` or fall back to the empty state.
|
||
A selection that points at a planet missing from the current
|
||
report (visibility lost between turns) collapses to the empty
|
||
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
|
||
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
|
||
plan ([../PLAN-finalize.md](../PLAN-finalize.md)). A click that lands
|
||
on empty space is a no-op — selection is mutated only by an explicit
|
||
planet click or by the close button.
|
||
|
||
The planet inspector itself is a presentational component: it takes
|
||
a `ReportPlanet` snapshot as a prop and renders the documented field
|
||
set per planet kind. The wrapper in `api/game-state.ts` exposes every
|
||
field the FBS schema carries (`industryStockpile` for `capital`,
|
||
`materialsStockpile` for `material`, `industry`, `population`,
|
||
`colonists`, `production`, `freeIndustry`, plus `owner` for `other`).
|
||
Fields the FBS table does not project for a given kind read as `null`
|
||
and the inspector simply omits the row.
|
||
|
||
The selected-planet visual on the map (a ring or halo) is deferred
|
||
to the finalization plan ([../PLAN-finalize.md](../PLAN-finalize.md))
|
||
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.
|