diff --git a/ui/PLAN.md b/ui/PLAN.md index 79849f0..f352374 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -18,6 +18,22 @@ module is a pure compute boundary on every platform. > realistic multi-turn projection, and the cross-platform acceptance > pass in [ROADMAP.md](ROADMAP.md). This file is retained as the staged > record of how the MVP was built. +> +> **Routing — superseded by the app-shell.** After the MVP, the +> URL-based routing the per-phase artifacts below describe was refactored +> into a single-URL **app-shell**: the game UI is one SvelteKit route at +> `/game/`, the screen and the in-game view are in-memory rune state +> (`lib/app-nav.svelte.ts`), the `routes/games/[id]/` subtree and the +> per-view `+page.svelte` wrappers were removed, the in-game layout +> became `lib/game/game-shell.svelte`, and the login / lobby / +> lobby-create screens moved under `lib/screens/`. Browser Back/Forward +> move between screens via shallow routing without changing the URL — a +> model that also suits the bundled standalone targets (Wails / +> Capacitor / gomobile) that have no URLs. The current navigation model +> is described in [docs/navigation.md](docs/navigation.md) and in the +> reframed `Information Architecture and Navigation` section and Phase 10 +> decisions below; the per-phase `routes/games/[id]/…` artifact paths are +> left as the historical record of what each phase delivered at the time. The existing Fyne client in `client/` is deprecated and is not modified or imported by the new code. The architectural overview is mirrored into @@ -130,38 +146,55 @@ The intended v1 architecture is: ## Information Architecture and Navigation -The client is a single-page application with **one active view at a -time**. Navigation is mobile-first: floating panels never overlap the -map, the main area never splits into multiple visible panels on small -screens. Desktop and mobile share the same model; on desktop, the -sidebar sits beside the active view, on mobile it lives behind a -bottom-tab bar. +The client is a single-page **app-shell** with **one active view at a +time**. It is served at a single URL (`/game/`) that never changes: +the visible screen and view are in-memory state, not routes. Navigation +is mobile-first: floating panels never overlap the map, the main area +never splits into multiple visible panels on small screens. Desktop +and mobile share the same model; on desktop, the sidebar sits beside +the active view, on mobile it lives behind a bottom-tab bar. -### View model +### Screen and view model + +Two pieces of in-memory state (rune singletons in +`lib/app-nav.svelte.ts`) replace what URLs used to encode — `appScreen` +(the top-level screen plus the active game id) and `activeView` (the +in-game view plus its sub-parameters): ```text -ActiveView ∈ { - /login, (anonymous only) - /lobby, (auth required) - /games/:id/map, (default in-game view) - /games/:id/table/:entity, (entity ∈ - planets | ship-classes | - ship-groups | fleets | - sciences | races) - /games/:id/report, - /games/:id/battle/:battleId, - /games/:id/mail, - /games/:id/designer/ship-class/:id?, - /games/:id/designer/science/:id?, +appScreen.screen ∈ { + login, (anonymous only) + lobby, (auth required) + lobby-create, (auth required) + game, (auth required; carries appScreen.gameId) +} + +activeView.view ∈ { (meaningful only while screen === game) + map, (default in-game view) + table, (+ tableEntity ∈ planets | ship-classes | + ship-groups | fleets | sciences | races) + report, + battle, (+ battleId, turn) + mail, + designer-science, (+ scienceId; absent = new-science form) } ``` +The top-level screen is chosen by the single-route dispatcher +(`routes/+page.svelte`) from `session.status` + `appScreen.screen`; +the in-game shell (`lib/game/game-shell.svelte`) renders the active +view from `activeView`. Browser Back/Forward move between screens +(Back from a game → lobby) via SvelteKit shallow routing, without +changing the URL; in-game view switches do not create history entries. + Switching between views happens through the header dropdown (desktop) -or hamburger menu (mobile). Double-tapping a row in a `table:` view -returns to `/map` with `focus=`. Some views can push a -transient map overlay with a back affordance (for example, ship-class -designer pushes a range-preview overlay onto the map). The transient -overlay clears when the user navigates to any other view. +or hamburger menu (mobile), driven by `activeView.select(...)`. +Double-tapping a row in a table view returns to the map focused on the +object. Some views can push a transient map overlay with a back +affordance (for example, ship-class designer pushes a range-preview +overlay onto the map). The transient overlay clears when the user +selects any other view. The implementation is documented in +[docs/navigation.md](docs/navigation.md). ### Layout per breakpoint @@ -257,12 +290,20 @@ turn current` action. - The account menu (top-right on desktop, last hamburger entry on mobile) holds Settings, Sessions, Theme, Language, Logout. -### Authenticated route transitions +### Authenticated screen transitions -- `/login` → `/lobby` after successful confirm-email-code. -- `/lobby` → `/games/:id/map` when a game card is selected. -- Any view → `/login` immediately on session revocation push event. -- Designer views can push a transient overlay onto `/map`; the back +All transitions are in-memory screen/view changes; the URL stays +`/game/` throughout. + +- login → lobby after successful confirm-email-code (`session.status` + settles to `authenticated`). +- lobby → game (view `map`) when a game card is selected + (`appScreen.go("game", { gameId })`). +- any screen → login immediately on session revocation push event + (`session.status` settles back to `anonymous`). +- the in-game header carries a "return to lobby" control + (`appScreen.go("lobby")`); browser Back from a game does the same. +- Designer views can push a transient overlay onto the map; the back affordance returns to the originating designer. Per-screen behaviour (validations, exact field names, error mappings) @@ -1062,37 +1103,58 @@ end-to-end before any data is wired. Decisions taken with the project owner during implementation: -1. **Routing — file-system based, no extra dispatcher.** The - "view router" called out in the original artifact list is - implemented as SvelteKit's file-system routes plus thin - `+page.svelte` wrappers that mount the matching - `lib/active-view/.svelte` stub. No separate dispatch - component lives in the codebase; each route file is a two-line - wrapper. -2. **Optional designer ID segments.** Both designer URLs ship as - `[[id]]` optional segments - (`designer/ship-class/[[classId]]/`, - `designer/science/[[scienceId]]/`) so Phase 18 / 21 can read - the param without a routing migration. Phase 10 stubs ignore - the param. -3. **Battle URL — optional id.** `battle/[[battleId]]/` accepts - both the list URL (`/battle`) and a specific battle URL - (`/battle/`). Phase 27 keeps the optional segment and - switches behaviour based on presence. +1. **Routing — single-URL app-shell, in-memory dispatch.** The game + UI is one SvelteKit route served at `/game/`; the address bar never + changes. The "view router" called out in the original artifact list + is the in-memory dispatch in `lib/game/game-shell.svelte` — an + `{#if}` ladder over `activeView.view` that mounts the matching + `lib/active-view/.svelte` stub. The top-level screen + (login / lobby / lobby-create / game) is chosen by the single-route + dispatcher `routes/+page.svelte` from `session.status` + + `appScreen.screen`. Both `appScreen` and `activeView` are rune + singletons in `lib/app-nav.svelte.ts`; there are no per-screen or + per-view file routes (only the dev/test `/__debug/*` ones remain). + Screen-level browser history (Back → lobby) is layered on top via + SvelteKit shallow routing (`pushState`/`replaceState` + `page.state`) + so the URL stays `/game/`. This single-URL model is also the natural + fit for the deferred standalone wrappers (Wails desktop, Capacitor / + gomobile mobile in [ROADMAP.md](ROADMAP.md)), which load a single + bundled `index.html` with no URLs or history. See + [docs/navigation.md](docs/navigation.md). + + > This decision supersedes the original "file-system routes plus + > thin `+page.svelte` wrappers" plan. The app-shell transition was + > implemented after the MVP phases: the `routes/games/[id]/` + > subtree and the per-view route wrappers were removed, the layout + > became `lib/game/game-shell.svelte`, and the login / lobby / + > lobby-create screens moved under `lib/screens/`. The + > `lib/active-view/*` components are unchanged — only how they are + > mounted changed. +2. **In-game view sub-parameters — `activeView` state, not URL + segments.** What were optional URL segments are now optional fields + on `activeView` state: the science designer reads `scienceId` + (absent = new-science form), the battle view reads `battleId` + (empty = list) and `turn`, and the table view reads `tableEntity`. + Later phases set these through `activeView.select(view, params)` + instead of navigating a URL. +3. **Battle view — optional id.** The battle view accepts both the + list state (no `battleId`) and a specific battle (`battleId` set). + Phase 27 keeps the optional sub-param and switches behaviour based + on presence. 4. **Tablet sidebar — click toggle, not swipe.** The 768–1024 px tablet sidebar slides in from a header-button click rather than the IA section's swipe-from-right gesture. The structural breakpoint switch satisfies Phase 10's acceptance criterion; Phase 35 polish lands the swipe gesture. -5. **Mobile tool overlay — `mobileTool` state, gated by URL.** - The mobile bottom-tabs Calc / Order navigate to `/map` and - set a layout-owned `mobileTool` rune. The layout's derived - `effectiveTool` only honours the rune when the URL is `/map`, - so navigating to any other view via the More drawer or the - header view-menu naturally drops the overlay. The desktop - sidebar separately accepts a `?sidebar=calc|inspector|order` - URL param that seeds the initial tab on first mount, used by - later phases that want to land directly on a particular tool. +5. **Mobile tool overlay — `mobileTool` state, gated by active view.** + The mobile bottom-tabs Calc / Order select the map view and + set a shell-owned `mobileTool` rune. The shell's derived + `effectiveTool` only honours the rune while `activeView.view === + "map"`, so selecting any other view via the More drawer or the + header view-menu naturally drops the overlay. The sidebar tool + state is pure in-memory rune state — there is no `?sidebar=` URL + param (the app-shell carries no per-screen URL); the sidebar opens + on its `inspector` default and external events flip the tab. 6. **Sidebar tool filenames — `*-tab.svelte`.** Phase 12 / 13 / 30 each name their final implementation (`order-tab.svelte`, `inspector-tab.svelte`, @@ -1103,11 +1165,16 @@ Decisions taken with the project owner during implementation: name is the static `race ?` string from i18n, mirroring the spec's static `turn ?` placeholder. Phase 11 wires both from `user.games.report` data through `lib/header/turn-counter.svelte`. -8. **Auth gate inherited.** The root `+layout.svelte` already - redirects `anonymous → /login`; the in-game shell needs no - extra guard. Phase 10 verified this by booting the e2e shell - spec via `__galaxyDebug.setDeviceSessionId` and observing the - post-`session.init` `authenticated` status. +8. **Auth gate — state-based in the dispatcher.** The single-route + dispatcher (`routes/+page.svelte`) renders the login screen for an + `anonymous` session and the authenticated screens for an + `authenticated` one; there is no `goto` redirect (the app-shell + stays at `/game/`). The in-game shell needs no extra guard. Phase 10 + verified the gate by booting the e2e shell spec via + `__galaxyDebug.setDeviceSessionId` and observing the + post-`session.init` `authenticated` status. (Originally the gate was + a `goto("/login")` redirect in the root layout; the app-shell + transition replaced it with state-based rendering.) 9. **More drawer mirrors the view-menu.** The mobile bottom-tabs "More" drawer renders the same seven destinations as the header view-menu. The IA section's narrower More list (Mail, @@ -1136,9 +1203,11 @@ Artifacts (delivered): `i18n.setLocale`; Logout calls `session.signOut("user")`) - `ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab, inspector-tab, order-tab, bottom-tabs}.svelte` — three-tab - sidebar with `inspector` default and `?sidebar=` URL seed; - mobile-only bottom-tabs with `[Map, Calc, Order, More]` plus a - More drawer duplicating the view-menu destinations + sidebar with `inspector` default (the app-shell transition later + dropped the original `?sidebar=` URL seed — there is no per-screen + URL to carry it); mobile-only bottom-tabs with + `[Map, Calc, Order, More]` plus a More drawer duplicating the + view-menu destinations - `ui/frontend/src/lib/sidebar/types.ts` — shared `SidebarTab` and `MobileTool` types - `ui/frontend/src/lib/active-view/{map, table, report, battle, @@ -1173,7 +1242,7 @@ Targeted tests (delivered): view-menu navigation to every IA destination, account-menu Logout / Language wiring); - Vitest component tests for the sidebar (default tab, switching, - empty-state copy, `?sidebar=` URL seed, close button); + empty-state copy, close button); - Vitest component tests for every active-view stub (title, `coming soon` copy, table-entity prop, battle-id prop); - Playwright e2e: visit every view stub via header dropdown and @@ -1430,8 +1499,7 @@ Artifacts (delivered): `tab-bar.svelte`, `bottom-tabs.svelte` — `historyMode` prop on the sidebar forwards to `hideOrder` on tab-bar / bottom-tabs; active-tab `order` is reset to `inspector` if the flag flips - on, and the `?sidebar=order` URL seed falls back to - `inspector` while the flag is true. + on while it is selected. - `ui/frontend/src/routes/games/[id]/+layout.svelte` — instantiates `OrderDraftStore`, sets context, runs `init({ cache, gameId })` next to `gameState.init` through diff --git a/ui/README.md b/ui/README.md index fcaf609..f12bfa9 100644 --- a/ui/README.md +++ b/ui/README.md @@ -53,9 +53,15 @@ quick orientation; deeper design notes live under `ui/docs/`. + SQLite on desktop, iOS Keychain / Android Keystore + SQLite on mobile, all behind a single `KeyStore` and `Cache` TypeScript interface. -- **Mobile-first navigation:** one active view occupies the main area - at a time; the sidebar holds a single tool (calculator, inspector, - or order) with persistent state on switch. +- **Single-URL app-shell navigation:** the game UI is one route served + at `/game/`; the screen (login / lobby / game) and the in-game view + are in-memory state (`lib/app-nav.svelte.ts`), not URLs, so the + address bar never changes. Browser Back/Forward move between screens + via shallow routing without touching the URL — a model that also + suits the bundled standalone targets (Wails / Capacitor) that have no + URLs. One active view occupies the main area at a time; the sidebar + holds a single tool (calculator, inspector, or order) with persistent + state on switch. See [`docs/navigation.md`](docs/navigation.md). ## Repository layout @@ -81,16 +87,18 @@ ui/ ├── mobile/ Capacitor project (planned — see ROADMAP.md) └── frontend/ SvelteKit / Vite source ├── src/api/ GalaxyClient + typed Connect client + auth + session - ├── src/lib/ env config, session store, revocation watcher + ├── src/lib/ app-shell nav + screens + game shell, env config, session store, stores ├── src/platform/core/ Core interface + WasmCore adapter ├── src/platform/store/ KeyStore/Cache interfaces + web adapter ├── src/proto/ generated Protobuf-ES + Connect descriptors + FlatBuffers TS bindings - ├── src/routes/ SvelteKit routes (/, /login, /lobby, /lobby/create) + ├── src/routes/ single-URL app-shell: `/game/` dispatcher (+page.svelte) + `/__debug/*` └── static/ core.wasm + wasm_exec.js (built by `make wasm` / CI; gitignored) ``` Linked topic docs: +- [`docs/navigation.md`](docs/navigation.md) — single-URL app-shell, + screens and views as in-memory state, screen history, sidebar tools. - [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login, session store state machine, revocation watcher. - [`docs/lobby.md`](docs/lobby.md) — lobby UI sections, application diff --git a/ui/docs/auth-flow.md b/ui/docs/auth-flow.md index 0819ab8..15d808b 100644 --- a/ui/docs/auth-flow.md +++ b/ui/docs/auth-flow.md @@ -18,10 +18,15 @@ authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md). - `ui/frontend/src/lib/revocation-watcher.ts` — minimal `SubscribeEvents` watcher that triggers `signOut("revoked")` on any non-aborted stream termination. -- `ui/frontend/src/routes/login/+page.svelte` — two-step form. -- `ui/frontend/src/routes/lobby/+page.svelte` — placeholder lobby - that issues the first authenticated `user.account.get`. -- `ui/frontend/src/routes/+layout.svelte` — route guard plus the +- `ui/frontend/src/lib/screens/login-screen.svelte` — two-step form. +- `ui/frontend/src/lib/screens/lobby-screen.svelte` — lobby that + issues the first authenticated `user.account.get`. +- `ui/frontend/src/routes/+page.svelte` — the state-based auth gate / + screen dispatcher (anonymous → login, authenticated → the + `appScreen` screen). The single-URL app-shell has no per-screen + routes; see [`navigation.md`](navigation.md). +- `ui/frontend/src/routes/+layout.svelte` — boot-time session init, + the `loading` / `unsupported` interception, and the browser-not-supported blocker. ## State machine (`SessionStatus`) @@ -50,8 +55,9 @@ authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md). ``` `signOut("revoked")` shares the same observable end state as -`signOut("user")`; the reason exists only for telemetry. Both -trigger the layout effect's `anonymous → /login` redirect. +`signOut("user")`; the reason exists only for telemetry. Both settle +`status` to `anonymous`, which the dispatcher renders as the login +screen — there is no URL redirect (the app-shell stays at `/game/`). ## UX states and error mapping @@ -67,7 +73,7 @@ those branches. | 200 from `send-email-code` | advance to step `code`, focus the code input | | `invalid_request` from `send` | stay on step `email`, surface the gateway message | | `service_unavailable` from `send` | stay on step `email`, surface "service is temporarily unavailable" | -| 200 from `confirm-email-code` | persist `device_session_id`, redirect to `/lobby` | +| 200 from `confirm-email-code` | persist `device_session_id`, settle `status` to `authenticated` (dispatcher shows the lobby) | | `invalid_request` from `confirm` | bounce to step `email`, message: "code expired or already used" | | any other error from `confirm` | stay on step `code`, surface the gateway message | @@ -89,8 +95,10 @@ After `confirm-email-code` succeeds, `session.signIn` writes the `device_session_id` into the IDB cache (`namespace=session`, `key=device-session-id`). On the next page load, `SessionStore.init` reads it back and settles `status` to -`authenticated`, so the layout effect routes the user straight to -`/lobby`. +`authenticated`, so the dispatcher renders the authenticated screen +straight away. Which authenticated screen it is comes from the +restored `appScreen` snapshot (lobby by default; see +[`navigation.md`](navigation.md)), not from the URL. The keypair lives next to the id in the same database (object store `keypair`, key `device`). Clearing site data wipes both; @@ -102,21 +110,22 @@ again. This is the documented re-login path — there is no paired The keystore relies on WebCrypto Ed25519, which currently lands in Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see -[`storage.md`](storage.md) for the rationale). On boot the layout -runs a sanity probe (`crypto.subtle.generateKey` for `Ed25519`); if -it rejects, the layout switches to a `browser not supported` page -instead of rendering `/login`. The client deliberately does not ship a -JavaScript Ed25519 fallback — the design decision is modern-browser -baseline only. +[`storage.md`](storage.md) for the rationale). On boot the root +layout runs a sanity probe (`crypto.subtle.generateKey` for +`Ed25519`); if it rejects, `status` settles to `unsupported` and the +layout renders a `browser not supported` page instead of the login +screen. The client deliberately does not ship a JavaScript Ed25519 +fallback — the design decision is modern-browser baseline only. ## Revocation -The lobby layout opens a long-running `SubscribeEvents` stream as +The root layout opens a long-running `SubscribeEvents` stream as soon as `status` becomes `authenticated`. Its only contract is liveness: any non-aborted termination of the stream is treated as a server-side session revocation, the watcher calls -`session.signOut("revoked")`, and the layout effect redirects to -`/login`. +`session.signOut("revoked")`, `status` settles to `anonymous`, and +the dispatcher swaps to the login screen on the next render — the +URL stays `/game/`. Session revocation closes the active client within one second: the gateway closes the stream the moment it observes a @@ -126,8 +135,8 @@ reacts on the next event-loop tick. ## Localisation The login form, the root layout's blocker page, and the lobby -placeholder go through the i18n primitive in `src/lib/i18n/`. The -language picker on `/login` lists every entry in +screen go through the i18n primitive in `src/lib/i18n/`. The +language picker on the login screen lists every entry in `SUPPORTED_LOCALES` by its native name and is initialised from `navigator.languages` (web) with `en` as the fallback. Picking a different language re-renders the form in place and is forwarded diff --git a/ui/docs/battle-viewer-ux.md b/ui/docs/battle-viewer-ux.md index 69379a9..7e97143 100644 --- a/ui/docs/battle-viewer-ux.md +++ b/ui/docs/battle-viewer-ux.md @@ -1,7 +1,9 @@ # Battle Viewer UX -The battle viewer is a dedicated view for battles -(`/games//battle/`). Bombings are a separate static +The battle viewer is a dedicated active view for battles +(`activeView.view === "battle"`, with `battleId` and `turn` +sub-parameters; the app-shell has no per-view URL — see +[`navigation.md`](navigation.md)). Bombings are a separate static table in the Reports view (`section-bombings.svelte`). The two domains are deliberately not mixed in any visual surface or click target. @@ -212,7 +214,8 @@ result is an X-shaped cross overlaid on the planet glyph. The stroke width is computed by `battleMarkerStrokeWidth(shots)`: 1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between (`width = 1 + (shots − 1) × 4 / 99`, clamped). A click on either -line navigates to `/games//battle/?turn=`. +line opens the battle viewer in memory via +`activeView.select("battle", { battleId, turn })`. ### Bombing marker — colored ring @@ -223,10 +226,10 @@ Colour: - yellow (`#FFD400`) when `wiped: false`, - red (`#FF3030`) when `wiped: true`. -A click on the ring navigates to `/games//report#report-bombings` -and scrolls the matching `report-bombing-row` (by `data-planet`) -into view. Bombing markers never open the Battle Viewer — the two -domains stay separate. +A click on the ring switches to the report view +(`activeView.select("report")`) and scrolls the matching +`report-bombing-row` (by `data-planet`) into view. Bombing markers +never open the Battle Viewer — the two domains stay separate. ## Data source diff --git a/ui/docs/diplomail-ui.md b/ui/docs/diplomail-ui.md index 36edb7a..69a8876 100644 --- a/ui/docs/diplomail-ui.md +++ b/ui/docs/diplomail-ui.md @@ -1,9 +1,10 @@ # In-game diplomatic mail UI The in-game mail view consumes the `diplomail` subsystem in the -backend. The route lives at `/games/:id/mail` and replaces the -active view when the user opens the "diplomatic mail" entry in the -header menu. +backend. It is the `mail` active view (`activeView.view === "mail"`) +and replaces the active view when the user opens the "diplomatic mail" +entry in the header menu (`activeView.select("mail")`). The app-shell +has no per-view URL — see [`navigation.md`](navigation.md). ## Wire surface @@ -70,11 +71,12 @@ render the original directly with no toggle. `diplomail.message.received` push frames are dispatched from `api/events.svelte.ts` via the singleton SubscribeEvents stream. The -in-game layout (`routes/games/[id]/+layout.svelte`) parses the +in-game shell (`lib/game/game-shell.svelte`) parses the verified payload, calls `mailStore.applyPushEvent(gameId)` (which re-fetches the inbox — the payload only carries a preview), and -raises a toast through `lib/toast.svelte.ts` with a "view" -deep-link to `/games/:id/mail`. +raises a toast through `lib/toast.svelte.ts` whose "view" action +switches to the mail view in memory (`activeView.select("mail")`) — +no URL navigation. The header view-menu's mail entry shows `mailStore.unreadCount` as an inline pill — the only chrome the badge needs. diff --git a/ui/docs/events.md b/ui/docs/events.md index bfc296c..3e573e1 100644 --- a/ui/docs/events.md +++ b/ui/docs/events.md @@ -93,11 +93,11 @@ reconnect. }); onDestroy(off); ``` -2. If the handler reads scoped data (per-game, per-route), register - from a layout that owns that scope and pass the gameId via a - closure. The handler should filter events whose payload does not - match its scope (see `routes/games/[id]/+layout.svelte` for the - `game.turn.ready` filter). +2. If the handler reads scoped data (per-game), register from a + component that owns that scope and pass the gameId via a closure. + The handler should filter events whose payload does not match its + scope (see `lib/game/game-shell.svelte` for the `game.turn.ready` + filter). 3. The payload encoding is owned by the producer side: the `game.turn.ready` event uses JSON `{game_id, turn}`. Document the schema next to the producer (e.g. `backend/README.md` §10). diff --git a/ui/docs/game-state.md b/ui/docs/game-state.md index ce6a136..d377e2e 100644 --- a/ui/docs/game-state.md +++ b/ui/docs/game-state.md @@ -6,13 +6,15 @@ inspector tabs, the order composer, and the calculator. ## Lifecycle -`routes/games/[id]/+layout.svelte` instantiates one `GameStateStore` -per game (the layout remounts when the user navigates to a different -game id, so each game gets a fresh store). The layout exposes the -instance through Svelte context under `GAME_STATE_CONTEXT_KEY`; -descendants read it via `getContext(GAME_STATE_CONTEXT_KEY)`. +The in-game shell (`lib/game/game-shell.svelte`) instantiates one +`GameStateStore` per game. The shell is mounted by the single-route +dispatcher only while `appScreen.screen === "game"`, and remounts when +`appScreen.gameId` changes, so each game gets a fresh store. The shell +exposes the instance through Svelte context under +`GAME_STATE_CONTEXT_KEY`; descendants read it via +`getContext(GAME_STATE_CONTEXT_KEY)`. -The layout's `onMount` builds the `GalaxyClient`, loads `Cache` +The shell's boot effect builds the `GalaxyClient`, loads `Cache` through `loadStore()`, then calls `gameState.init({ client, cache, gameId })`. `init`: @@ -21,9 +23,10 @@ gameId })`. `init`: 2. calls `setGame(gameId)`, which: - reads the per-game wrap-mode preference from `Cache` (`game-prefs / /wrap-mode`, default `torus`); - - calls `lobby.my.games.list` and finds the game record - (`GameSummary` carries `current_turn`); if the user is not a - member, the store flips to `error`; + - calls `lobby.my.games.list` (`findGame`) and finds the game + record (`GameSummary` carries `current_turn`); if the game is not + in the player's list, the store sets the `notFound` flag (see + below); - calls `user.games.report` for the discovered turn and decodes the FlatBuffers response into a TS-friendly `GameReport` shape. @@ -40,6 +43,23 @@ The store exposes: | `pendingTurn` | `number \| null` | latest server turn the user has not yet opened | | `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` | | `error` | `string \| null` | localised error message when `status === "error"` | +| `notFound` | `boolean` | true when the game is not in the player's list (cancelled / removed / access revoked); the shell drops to the lobby | + +## Missing or inaccessible game + +A restored or stale game id (a `sessionStorage` snapshot pointing at a +game that was cancelled, removed, or whose access was revoked) is a +distinct case from a transient failure. When `findGame` returns no +matching record, `setGame` sets the boolean `notFound` flag rather +than synthesising an error message. After `init` resolves, the in-game +shell reads `gameState.notFound` and, when true, calls +`appScreen.go("lobby")` and shows a `game.events.unavailable` toast — +the player lands back in the lobby instead of on an in-game error +screen. A transient network failure takes the catch path instead, +leaving `notFound` false and flipping `status` to `error` so the +in-game error state offers a retry. `notFound` resets to false at the +start of every `setGame` / `advanceToPending`. See +[`navigation.md`](navigation.md) for the restore-and-validate flow. ## Store extensions @@ -48,7 +68,7 @@ wire lands (ships, fleets, sciences, routes, battles, mail). `currentTurn` is split from `viewedTurn`, and `viewTurn(turn)` / `returnToCurrent()` handle history navigation. The derived `historyMode` rune flips automatically when `viewedTurn < -currentTurn`; the layout passes it to the sidebar / bottom-tabs +currentTurn`; the shell passes it to the sidebar / bottom-tabs wiring (which hides the order tab) and to `OrderDraftStore.bindClient` (which gates `add` / `remove` / `move`). See "History mode" below for the cache and refresh rules. @@ -161,9 +181,9 @@ without losing the live snapshot. The store keeps two turn runes: The derived `historyMode` rune (`status === "ready" && viewedTurn < currentTurn`) drives every history-aware consumer: -- the layout passes it to `Sidebar` / `BottomTabs` so the order +- the shell passes it to `Sidebar` / `BottomTabs` so the order tab vanishes; -- the layout passes a `getHistoryMode` getter to +- the shell passes a `getHistoryMode` getter to `OrderDraftStore.bindClient` so `add` / `remove` / `move` are no-ops while the user is looking at a past turn; - `RenderedReportSource` returns the raw report (no order overlay) diff --git a/ui/docs/i18n.md b/ui/docs/i18n.md index 069b6c9..a510ecc 100644 --- a/ui/docs/i18n.md +++ b/ui/docs/i18n.md @@ -85,7 +85,7 @@ helper is platform-agnostic by design. The boot locale resolves once at module load (no async init): an explicit stored choice wins, otherwise browser/system detection, otherwise `DEFAULT_LOCALE`. Callers that mutate the locale (the language -pickers on `/login` and in the account menu) call `i18n.setLocale(next)`, +pickers on the login screen and in the account menu) call `i18n.setLocale(next)`, which **persists** the choice to `localStorage` (key `galaxy-locale`) so it survives reloads. An unrecognised stored value is ignored and falls back to detection. diff --git a/ui/docs/lobby.md b/ui/docs/lobby.md index c8dc065..634134c 100644 --- a/ui/docs/lobby.md +++ b/ui/docs/lobby.md @@ -15,8 +15,8 @@ width. | Section | Empty state | Source | Action | | -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- | -| `create new game` | (always visible) | — | Navigates to `/lobby/create` | -| `my games` | `no games yet` | `lobby.my.games.list` | Click → `/games/:id/map` | +| `create new game` | (always visible) | — | Opens the create screen (`appScreen.go("lobby-create")`) | +| `my games` | `no games yet` | `lobby.my.games.list` | Click → enters the game on the map view (`activeView.reset()` + `appScreen.go("game", { gameId })`) | | `pending invitations`| `no invitations` | `lobby.my.invites.list` | Accept (`lobby.invite.redeem`) / Decline (`lobby.invite.decline`) | | `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) | | `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) | @@ -85,9 +85,10 @@ public game (FUNCTIONAL.md §3.3). Fields: | `start_gap_players` | Advanced toggle | `2` | | | `target_engine_version` | Advanced toggle | `v1` | Falls back to `v1` if blank | -On success the page navigates back to `/lobby` and the new game shows -up in `my games` once the lobby's onMount has had a chance to refresh -the list. +On success the create screen returns to the lobby +(`appScreen.go("lobby")`) and the new game shows up in `my games` +once the lobby's onMount has had a chance to refresh the list (the +lobby screen remounts on return, so its onMount re-fires). ## Errors diff --git a/ui/docs/navigation.md b/ui/docs/navigation.md index bcc8b98..2c0d590 100644 --- a/ui/docs/navigation.md +++ b/ui/docs/navigation.md @@ -1,46 +1,120 @@ # 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`](../PLAN.md), section +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. -## Active-view model +## App-shell: one URL, screens and views as state -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/.svelte`. The "view router" mentioned in -the plan is the file system plus those wrappers — there is no -separate dispatch component. +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`: -| 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](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` | +- **`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. -`/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 +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 `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-classes` → `table-ship-classes.svelte`; -other entities dispatch to their respective components). +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 @@ -53,36 +127,38 @@ The desktop sidebar hosts three tools: | 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 +`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 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. +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. -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 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 layout's `historyMode` flag is -true. `+layout.svelte` forwards a derived value to `Sidebar`, which +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. 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. +`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 `+layout.svelte` +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 layout is +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 layout +`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 @@ -107,7 +183,7 @@ 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. +the game shell is the only place where the wiring lives. ## Layout breakpoints @@ -134,18 +210,20 @@ 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 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 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 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 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, @@ -155,12 +233,12 @@ a single source of truth for destinations. ## Transient map overlays -Some views can push a transient overlay onto `/map` with a back +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 navigates to any other view via the header or the bottom-tabs. +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 @@ -180,14 +258,14 @@ translating a renderer click into a planet selection. The flow: 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 + 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 - 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 + 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 @@ -201,7 +279,7 @@ translating a renderer click into a planet selection. The flow: state instead of holding stale rows. The mobile bottom-sheet is mounted alongside `` in the -layout. Its visibility is conditional on `effectiveTool === "map"` so +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 @@ -224,8 +302,12 @@ 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. +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. diff --git a/ui/docs/order-composer.md b/ui/docs/order-composer.md index dd46e30..d9537bf 100644 --- a/ui/docs/order-composer.md +++ b/ui/docs/order-composer.md @@ -195,15 +195,15 @@ Lifecycle: | `dispose()` | Marks the store destroyed; subsequent `persist()` calls are no-ops so a fast game-switch does not write stale state into the next id. | Mutations made before `init` resolves are silently ignored — the -layout always awaits `init` through `Promise.all([...])` next to +shell always awaits `init` through `Promise.all([...])` next to `gameState.init` before exposing the store. -Layout integration mirrors `GameStateStore`: +Shell integration mirrors `GameStateStore`: -- One instance per game, created in - [`../frontend/src/routes/games/[id]/+layout.svelte`](../frontend/src/routes/games/[id]/+layout.svelte). +- One instance per game, created in the in-game shell + [`../frontend/src/lib/game/game-shell.svelte`](../frontend/src/lib/game/game-shell.svelte). - Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context. -- Disposed in the layout's `onDestroy`. +- Disposed in the shell's `onDestroy`. The order tab and the planet inspector both consume the store via `getContext(ORDER_DRAFT_CONTEXT_KEY)` to push new commands. @@ -257,7 +257,7 @@ snapshot (history mode is the planned reader). `OrderDraftStore` records `needsServerHydration = true` when no cache row exists for the active game (fresh install, cleared -storage, switching device). After the layout boot resolves both +storage, switching device). After the shell boot resolves both `gameState.init` and `orderDraft.init`, it calls `orderDraft.hydrateFromServer({ client, turn })` which issues `user.games.order.get` against the gateway. A `found = false` @@ -294,14 +294,12 @@ report as it was. The Order tab is hidden when history mode is active — the player is browsing an immutable snapshot, and composing commands against it would be confusing. -The layout owns the `historyMode` flag and passes it to: +The in-game shell owns the `historyMode` flag and passes it to: - `Sidebar` as `historyMode`. The sidebar forwards it to its `TabBar` as `hideOrder`. The Order entry is filtered out of the - tab list when true. If a `?sidebar=order` URL seed lands while - the flag is true, the sidebar falls back to `inspector`. If the - active tab is `order` when the flag flips on, an effect resets - it to `inspector`. + tab list when true. If the active tab is `order` when the flag + flips on, an effect resets it to `inspector`. - `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order` button is suppressed when true. diff --git a/ui/docs/pwa-strategy.md b/ui/docs/pwa-strategy.md index acd14b3..0ece7fc 100644 --- a/ui/docs/pwa-strategy.md +++ b/ui/docs/pwa-strategy.md @@ -4,12 +4,21 @@ The web client is an installable, offline-tolerant PWA. It uses SvelteKit's native service worker (no Workbox) so there is no extra build dependency and the cache logic stays explicit. +The single-URL app-shell (see [`navigation.md`](navigation.md)) makes +the offline story simpler: the whole game UI lives at one route +(`${base}/`, i.e. `/game/` under the single-origin deployment), so +there is exactly one navigation target to precache and fall back to — +no per-screen routes to enumerate. The service-worker scope and the +manifest are unchanged by that refactor; both were already base-aware +(the SW keys everything off `$service-worker`'s `base`, and the +manifest uses relative `./` `start_url` / `scope`). + ## Pieces - [`src/service-worker.ts`](../frontend/src/service-worker.ts) — the worker. SvelteKit registers it automatically in the production build. - It precaches the app shell (`/`), the build artefacts (JS/CSS + - `core.wasm`), and the static files under a **version-keyed** cache + It precaches the app shell (`${base}/`), the build artefacts (JS/CSS + + `core.wasm`), and the static files under a **version-keyed** cache (`galaxy-cache-`, `version` from `$service-worker`). On `activate` it deletes every other cache, so a new deploy never serves stale code. Strategy: cache-first for the version-keyed build/files; @@ -17,7 +26,8 @@ build dependency and the cache logic stays explicit. shell answers navigations when fully offline. The gateway (cross- origin) is never intercepted — it is always live network. - [`static/manifest.webmanifest`](../frontend/static/manifest.webmanifest) - — name, `standalone` display, `start_url`/`scope` `/`, dark + — name, `standalone` display, relative `./` `start_url`/`scope` (so + it resolves under whatever `base` the build is deployed at), dark `theme_color`/`background_color`, and the icon set. - [`static/icons/`](../frontend/static/icons/) — `192`/`512` (`any`), a `512` `maskable`, and a `180` apple-touch icon. They are placeholder diff --git a/ui/docs/report-view.md b/ui/docs/report-view.md index fbc682e..da4f074 100644 --- a/ui/docs/report-view.md +++ b/ui/docs/report-view.md @@ -107,36 +107,23 @@ visible area so that scrolling down advances the highlight naturally. The observer is created on mount and torn down on unmount. -The in-game shell layout (`routes/games/[id]/+layout.svelte`) +The in-game shell (`lib/game/game-shell.svelte`) expands `
` to fit content rather than constraining it, so the document body is the actual scroll container — not the host. The IntersectionObserver root is `null` to match. -## Scroll save / restore +## Scroll position -`routes/games/[id]/report/+page.svelte` exports a SvelteKit -`Snapshot<{ scrollY: number }>`: - -- `capture()` reads `window.scrollY` when SvelteKit's - `beforeNavigate` cycle runs. -- `restore(value)` schedules a short - `requestAnimationFrame` poll that waits for - `document.documentElement.scrollHeight` to grow tall enough to - honour the saved offset, then calls `window.scrollTo(0, value)`. - The poll caps at ~60 frames (≈ 1 second) so a never-tall-enough - body never pins a frame loop. - -The capture / restore pair is keyed by route, so: -- Forward navigation from `/report` to `/map` lands `/map` at - scrollY 0 (no snapshot for `/map` to restore from). -- History-back from `/map` to `/report` restores the previously - captured scrollY — the user returns to the same section. - -The Snapshot API does not capture the active sidebar slug; the -IntersectionObserver re-derives it from the restored scroll -position on the next animation frame, which keeps the TOC -highlight consistent without a second source of truth. +The report is the `report` active view; switching to another view is +an in-memory `activeView` state change, not a navigation, and the +report component is remounted when the user returns to it. The +single-URL app-shell therefore does not carry SvelteKit's route-keyed +`Snapshot` scroll save/restore — that mechanism was tied to the old +`/games/:id/report` route and was removed with it. A re-entered report +opens at the top; the IntersectionObserver re-derives the active TOC +slug from the scroll position on the next animation frame, so the +highlight stays consistent without a separate source of truth. ## i18n namespace @@ -169,10 +156,13 @@ couple them silently. / IntersectionObserver are out of scope. - **Playwright** — `tests/e2e/report-sections.spec.ts` exercises the full integration: every TOC anchor lands its section in - view, the snapshot mechanism preserves `window.scrollY` on - history navigation, the back-to-map button reaches `/map`, the - mobile `` scrolls + to the chosen section on a narrow viewport. The spec drives the + app-shell through `window.__galaxyNav` (the dev-only nav surface) + instead of `page.goto` per-view URLs. The old "scroll position + survives a `/map` round-trip via SvelteKit `Snapshot`" case was + dropped — see the [scroll position](#scroll-position) note. Test IDs follow the pattern `report-section-` for section roots, `report-toc-` for TOC anchors, and per-section row diff --git a/ui/docs/storage.md b/ui/docs/storage.md index 8180a0e..58969ac 100644 --- a/ui/docs/storage.md +++ b/ui/docs/storage.md @@ -186,7 +186,8 @@ Thin orchestration layer over `KeyStore` + `Cache`: push-event-driven revocation path. A `null` `deviceSessionId` is the signal that the session is -unauthenticated — the root layout routes such users to `/login`. +unauthenticated — `session.status` settles to `anonymous` and the +dispatcher renders the login screen (the app-shell stays at `/game/`). ## Test layout diff --git a/ui/docs/testing.md b/ui/docs/testing.md index 824db36..5ae8612 100644 --- a/ui/docs/testing.md +++ b/ui/docs/testing.md @@ -128,9 +128,10 @@ report" affordance (`import.meta.env.DEV`). The flow is: 2. Run the UI dev server (`pnpm -C ui/frontend dev`), open the lobby, and use the "Load JSON…" file picker in the **Synthetic test - reports (DEV)** section. The page navigates to - `/games/synthetic-/map` with the report wired into the - in-game shell. + reports (DEV)** section. The lobby enters a `synthetic-` game + on the map view (`activeView.reset()` + `appScreen.go("game", { + gameId })`) with the report wired into the in-game shell. The + app-shell URL stays `/game/` — see [`navigation.md`](navigation.md). In synthetic mode: @@ -139,16 +140,16 @@ In synthetic mode: - Composing orders works locally (overlay applies through `applyOrderOverlay` as usual), but **nothing is sent to the gateway** — `OrderDraftStore.scheduleSync` short-circuits because - the synthetic id is not a UUID and the layout deliberately does - not bind a `GalaxyClient` for this game. + the synthetic id is not a UUID and the in-game shell deliberately + does not bind a `GalaxyClient` for this game. - The order draft is persisted into the platform `Cache` under the same `order-drafts` namespace as real games, keyed by the synthetic id, so navigating back into the same synthetic session restores the draft. The cache is cleared with `__galaxyDebug.clearOrderDraft(gameId)` (DEV debug surface). -- A page reload loses the in-memory report registry; opening the - same synthetic id afterwards redirects to /lobby. Re-load the JSON - to reseed. +- A page reload loses the in-memory report registry; a restored + synthetic game whose report is gone falls back to the lobby + (`appScreen.go("lobby")`). Re-load the JSON to reseed. The synthetic-report parity rule requires every change that extends `decodeReport` to also extend the legacy parser in lockstep, or to diff --git a/ui/frontend/src/lib/session-store.svelte.ts b/ui/frontend/src/lib/session-store.svelte.ts index db264e4..ba4ff1c 100644 --- a/ui/frontend/src/lib/session-store.svelte.ts +++ b/ui/frontend/src/lib/session-store.svelte.ts @@ -16,8 +16,8 @@ // asynchronously; the watcher in `lib/revocation-watcher.ts` calls // it without user interaction. The post-condition is the same as // `signOut("user")` — keypair regenerated, session id wiped, -// status returned to `anonymous` — so the layout's existing -// `anonymous → /login` redirect handles both reasons uniformly. +// status returned to `anonymous` — so the dispatcher's state-based +// auth gate renders the login screen for both reasons uniformly. import type { Cache, @@ -83,7 +83,7 @@ export class SessionStore { * revoked public key, and returns the status to `anonymous`. The * `reason` is recorded in console output for telemetry but does * not change the post-state — both user-driven logout and - * gateway-driven revocation land the user back on `/login`. + * gateway-driven revocation return the user to the login screen. */ async signOut(reason: "user" | "revoked"): Promise { if (this.keyStore === null || this.cache === null) { diff --git a/ui/frontend/tests/e2e/report-sections.spec.ts b/ui/frontend/tests/e2e/report-sections.spec.ts index cc533cf..b66b816 100644 --- a/ui/frontend/tests/e2e/report-sections.spec.ts +++ b/ui/frontend/tests/e2e/report-sections.spec.ts @@ -6,10 +6,8 @@ // 1. Every TOC anchor click scrolls the matching section into view // and the section is present in the DOM with at least one row // (or its empty-state copy when it is intentionally empty). -// 2. Snapshot save/restore on the active-view-host scroll -// container survives a /map navigation round-trip. -// 3. The "back to map" button navigates to the map URL. -// 4. The mobile fallback scrolls a section into view on // a narrow viewport. import { fromJson, type JsonValue } from "@bufbuild/protobuf";