Files
galaxy-game/ui/docs/navigation.md
T
Ilia Denisov a89048f6c5 docs(ui): finalize MVP plan structure and de-archaeologize topic docs
MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that.

- PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path.

- ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35.

- ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups.

- ui/docs/README.md (new): grouped topic-doc index.

- De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:17:51 +02:00

12 KiB
Raw Blame History

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

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