# 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 (`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. 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/.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`) 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 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)`. 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 `` 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.