# In-game shell — navigation model

This doc covers the chrome that wraps every in-game view: the
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.

## App-shell: one URL, screens and views as state

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`:

- **`appScreen`** — the top-level screen plus the active `gameId`. The
  literal values are:
  - `login` — anonymous entry point
  - `lobby` — historical alias; the dispatcher renders a tiny resolver
    that immediately navigates to the first visible games sub-panel
    (kept for snapshots persisted before the F8-04b split)
  - `lobby-create` — create-game form
  - `profile` — profile editor
  - `game` — in-game shell (drives `activeView`, see below)
  - `games-active-past`, `games-recruitment`, `games-invitations`,
    `games-private-games` — the four lobby sub-panels (F8-04b)
  - `synthetic-reports` — DEV-only legacy-report loader, gated by
    `VITE_GALAXY_DEV_AFFORDANCES === "true"`

  It replaces the old `goto`-based redirects and the `[id]` route
  param. Sanitize on session-restore allows every literal above, but
  the lobby shell's $effect re-routes a restored
  `games-private-games` (free tier) or `synthetic-reports` (prod
  bundle) to the first visible games sub-panel silently — no toast.
- **`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.

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` resolver,
`lobby-create-screen.svelte`, `profile-screen.svelte`,
`games-active-past-screen.svelte`, `games-recruitment-screen.svelte`,
`games-invitations-screen.svelte`,
`games-private-games-screen.svelte`,
`synthetic-reports-screen.svelte`) or, for `screen === "game"`, the
in-game shell `src/lib/game/game-shell.svelte`. Every authenticated
non-game screen wraps its body in
`lib/screens/lobby-shell.svelte`, which renders the two-level sidebar
+ identity strip; see [`lobby.md`](lobby.md). 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`.

### Lobby submenu

The lobby shell renders the `games` parent as an always-expanded
submenu on desktop (>640px) whenever the active screen is one of the
`games-*` literals. The submenu order is canonical
(`active-past` → `recruitment` → `invitations` → `private-games`), and
visibility is computed per-render from the
`account.entitlement.is_paid` flag, `lobbyData.myGames.length`, and
the build-time `VITE_GALAXY_DEV_AFFORDANCES` flag — see the
[Games panels](lobby.md#games-panels) table for the rules.

On mobile (≤640px) the sidebar collapses to a horizontal strip
(F8-04). The `games` entry then renders as a single
`games · {active-sub} ▾` button; tapping it opens a popover
(`role="listbox"`) of every visible sub-panel. Tapping a sub-panel
selects it and closes the popover; tapping outside or pressing
`Escape` closes it without changing the active page; re-tapping the
active sub-panel inside the popover is a no-op (the same idiom as the
F8-02 turn-navigator fix).

### 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 `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`, `profile`) and `replaceState(...)` for
`lobby` / `login`, so browser **Back from a game (or profile) 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. Re-entering a game from the lobby is not a restore: the
lobby resets `activeView` to the map before `appScreen.go("game")`, so
only an in-place refresh replays the saved view — browser Back and the
in-game return-to-lobby control both exit to the lobby. 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

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

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 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. 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
`game-shell.svelte`
(`const historyMode = $derived(gameState.historyMode)`) — no
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 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
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)`. Selecting the row
already on screen (`viewedTurn`) is a pure no-op — it only closes
the popover — so re-picking the live turn (most visibly turn 0,
where it is the only row) never re-fetches the report to redraw the
same snapshot. 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 game shell 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 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 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,
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 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 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
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 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
   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
   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
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
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 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.
