2ecdecad1e
- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome: page-list sidebar (Overview/Profile) and a top "Player-xxxx" identity strip mirroring the project site's monospace look. - Strip the legacy `lobby.title`, device-session-id `<code>`, and `lobby.greeting` paragraph; the identity strip both names the user and opens the profile editor. - Add a top-level `profile` AppScreen with a three-field form (`display_name`, `preferred_language`, `time_zone`) backed by a new `src/api/account.ts` wrapper around `user.account.get`, `user.profile.update`, and `user.settings.update`. Saving switches the active i18n locale in-place when the new preferred language is one the UI ships translations for. - Update e2e fixture + auth-flow / lobby-flow specs to use the new `lobby-account-name` testid and wait for the loaded identity before releasing pending `SubscribeEvents` (webkit revocation race). New `profile-screen.spec.ts` covers navigation, edit-save, and cancel. - Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new layout. Closes #47
326 lines
17 KiB
Markdown
326 lines
17 KiB
Markdown
# 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` / `profile` / `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`, `profile-screen.svelte`) or, for
|
||
`screen === "game"`, the in-game shell
|
||
`src/lib/game/game-shell.svelte`. Lobby and profile share a
|
||
post-login chrome (sidebar + identity strip) implemented in
|
||
`lib/screens/lobby-shell.svelte`; 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`.
|
||
|
||
### 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.
|