Split GameStateStore into currentTurn (server's latest) and viewedTurn (displayed snapshot) so history excursions don't corrupt the resume bookmark or the live-turn bound. Add viewTurn / returnToCurrent / historyMode rune, plus a game-history cache namespace that stores past-turn reports for fast re-entry. OrderDraftStore.bindClient takes a getHistoryMode getter and short-circuits add / remove / move while the user is viewing a past turn; RenderedReportSource skips the order overlay in the same case. Header replaces the static "turn N" with a clickable triplet (TurnNavigator), the layout mounts HistoryBanner under the header, and visibility-refresh is a no-op while history is active. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
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, 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 | Phase that fills it |
|---|---|---|
| URL | Active view component | Phase that fills it |
| ------------------------------------------ | ---------------------------------------------- | ----------------------- |
/games/:id/map |
lib/active-view/map.svelte |
Phase 11 |
/games/:id/table/:entity |
lib/active-view/table.svelte |
Phase 11 / 17 / 19 / 22 |
/games/:id/report |
lib/active-view/report.svelte (see report-view.md) |
Phase 23 |
/games/:id/battle/:battleId? |
lib/active-view/battle.svelte |
Phase 27 |
/games/:id/mail |
lib/active-view/mail.svelte |
Phase 28 |
/games/:id/designer/ship-class/:classId? |
lib/active-view/designer-ship-class.svelte |
Phase 17 (CRUD) / 18 (calc preview) |
/games/:id/designer/science/:scienceId? |
lib/active-view/designer-science.svelte |
Phase 21 |
/games/:id (no trailing view) redirects to /games/:id/map. The
optional :classId? / :scienceId? segments on the designer
routes match SvelteKit's [[classId]] syntax — /designer/ship-class
opens the empty new-class form, /designer/ship-class/{name}
opens the read-only view of the named class with the Delete
affordance. Phase 17 lights up the ship-class CRUD path; Phase 18
adds the live pkg/calc/-backed preview pane on top.
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
in Phase 17; the others fall back to the Phase 10 stub copy until
their respective phases land).
Sidebar tools and state preservation
The desktop sidebar hosts three tools:
| Tool | Component | Phase that fills it |
|---|---|---|
| Calculator | lib/sidebar/calculator-tab.svelte |
Phase 30 |
| Inspector | lib/sidebar/inspector-tab.svelte |
Phase 13 / 19 |
| Order | lib/sidebar/order-tab.svelte |
Phase 12 / 14 |
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 — Phase 13's planet click, future similar
flows — 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. Later phases that want to land
the user on a particular tool (for example, Phase 14's first
end-to-end command flow) can set it on navigation.
The Order entry is hidden when the layout's historyMode flag is
true. Phase 12 plumbs the flag end-to-end as a prop —
+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.
Phase 26 wires the flag to 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 ships, 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
for the draft-store side of the flow and
game-state.md for the rune split between
currentTurn and viewedTurn.
Header turn navigator and history banner
The header replaces the Phase 11 inline turn N text with 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 in the IA section is deferred to Phase 35 polish — the click toggle satisfies the "layout switches at 768 px" acceptance criterion on Phase 10.
- < 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.
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. The IA section's narrower "More" list (Mail, Battle log,
Tables, History, Settings, Logout) is the polish target for Phase 35
— Phase 10 keeps a single source of truth for destinations.
Transient map overlays
Some views can push a transient overlay onto /map with a back
affordance — for example, the ship-class designer pushes a
range-preview overlay onto the map. The transient overlay clears
when the user navigates to any other view via the header or the
bottom-tabs.
Phase 10 documents this concept but does not implement the back-stack mechanism. Phase 34 lands the back-stack alongside its first user (multi-turn projection, range circles in the ship-class designer).
Planet selection (Phase 13)
The map view turns into the entry point for the inspector by translating a renderer click into a planet selection. The flow:
- The renderer (
src/map/render.ts) exposesonClick(cb)next to the existinghitAt(cursor). It is built onpixi-viewport'sclickedevent, which already differentiates a click from a pan-drag, so a click handler will not race the pan plugin. lib/active-view/map.sveltewires that callback after a successfulmountRenderer. On a click it asks the renderer for the hit primitive, looks the planet up bynumberin the liveGameStateStore.report, and callsSelectionStore.selectPlanet(number).SelectionStore(lib/selection.svelte.ts) is a runes store instantiated by the layout and exposed via Svelte context underSELECTION_CONTEXT_KEY. It carries a discriminated union — Phase 13 only models{ kind: "planet"; id: number }; Phase 19 widens it 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.- The layout watches the selection rune and, on the null → planet
transition, flips its bound
activeTabtoinspectorandsidebarOpentotrue. 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. lib/sidebar/inspector-tab.svelteandlib/inspectors/planet-sheet.svelteboth read the selection store, resolve it against the live report, and either renderlib/inspectors/planet.svelteor 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. Phase 13 ships
the minimal dismissal surface: a close button (✕) that calls
SelectionStore.clear(). Tap-outside and swipe-down dismissal from
the IA section are deferred to Phase 35 polish. 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 not shipped in Phase 13. It rolls into Phase 35 polish 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.