Files
galaxy-game/ui/docs/navigation.md
T
Ilia Denisov e82c9f8bbd
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 3m35s
fix(ui): no-op when re-selecting the turn already on screen
Clicking the current-turn row in the header turn navigator while
already viewing it routed through returnToCurrent() →
viewTurn(currentTurn), which re-fetches the live report and flips the
view through `loading`. At turn 0 the only row is the live turn, so
the dropdown always fired a pointless backend round-trip and redraw.

Guard goToTurn() against re-selecting the on-screen turn
(turn === viewedTurn): just close the popover and stop. Leaving
history is unaffected — there the viewed turn differs from the target.

Closes #45

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 00:18:30 +02:00

17 KiB
Raw Blame History

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, 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/<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)
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), 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-classestable-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 entriesactiveView.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 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). 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 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 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.
  • 7681024 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).
  • < 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 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); 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.) 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.

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). 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) 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 for the session state machine.