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