From 182beebcd6a102e568d52a7904086de2a61579c6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 23 May 2026 19:45:27 +0200 Subject: [PATCH 1/8] feat(ui): app-nav state stores (app-shell foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `appScreen` + `activeView` rune singletons with a shared sessionStorage snapshot — the in-memory source of truth that replaces URL-based screen/view routing for the single-URL app-shell. Not wired in yet (additive). Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/lib/app-nav.svelte.ts | 193 ++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 ui/frontend/src/lib/app-nav.svelte.ts diff --git a/ui/frontend/src/lib/app-nav.svelte.ts b/ui/frontend/src/lib/app-nav.svelte.ts new file mode 100644 index 0000000..7065357 --- /dev/null +++ b/ui/frontend/src/lib/app-nav.svelte.ts @@ -0,0 +1,193 @@ +// App-shell navigation state. +// +// The game UI is a single-URL app-shell (served at `/game/`): there are no +// per-screen or per-view routes, so the address bar never changes. Two rune +// singletons hold what the URL used to encode: +// +// - `appScreen` — the top-level screen (login / lobby / lobby-create / +// game) and the active game id. It replaces the `goto`-based redirects +// and the `[id]` route param. +// - `activeView` — the in-game view (map / table / report / battle / mail / +// designer-science) and its sub-parameters. It replaces the URL params the +// old route wrappers read. +// +// Both live in this one module so they can share a single `sessionStorage` +// snapshot (persisted here) without a circular import. The snapshot is read +// once at construction to seed the initial render and rewritten on every +// mutation; `restoredGameId` lets the boot path validate a restored game +// before loading it (a cancelled/removed game falls back to lobby — see the +// dispatcher). Screen-level browser history (Back → lobby) is layered on top +// in the shell via SvelteKit shallow routing; this module is the source of +// truth, history only mirrors it. + +export type AppScreen = "login" | "lobby" | "lobby-create" | "game"; + +export type GameView = + | "map" + | "table" + | "report" + | "battle" + | "mail" + | "designer-science"; + +/** In-game view plus the sub-parameters the old route segments carried. */ +export interface GameViewState { + view: GameView; + /** Table entity slug when `view === "table"` (e.g. `planets`, `sciences`). */ + tableEntity?: string; + /** Selected battle when `view === "battle"`; empty string = list/none. */ + battleId?: string; + /** Viewed turn for the battle view; 0 = current. */ + turn?: number; + /** Science id when `view === "designer-science"`; absent = new-science form. */ + scienceId?: string; +} + +const STORAGE_KEY = "galaxy-app-nav"; + +const APP_SCREENS: readonly AppScreen[] = [ + "login", + "lobby", + "lobby-create", + "game", +]; +const GAME_VIEWS: readonly GameView[] = [ + "map", + "table", + "report", + "battle", + "mail", + "designer-science", +]; + +const DEFAULT_VIEW: GameViewState = { view: "map" }; + +interface NavSnapshot { + screen: AppScreen; + gameId: string | null; + game: GameViewState; +} + +function readSnapshot(): NavSnapshot | null { + if (typeof sessionStorage === "undefined") return null; + let raw: string | null; + try { + raw = sessionStorage.getItem(STORAGE_KEY); + } catch { + return null; + } + if (raw === null) return null; + try { + const parsed = JSON.parse(raw) as Partial | null; + if (parsed === null || typeof parsed !== "object") return null; + const screen = APP_SCREENS.includes(parsed.screen as AppScreen) + ? (parsed.screen as AppScreen) + : "lobby"; + const gameId = + typeof parsed.gameId === "string" && parsed.gameId.length > 0 + ? parsed.gameId + : null; + return { screen, gameId, game: sanitizeView(parsed.game) }; + } catch { + return null; + } +} + +function sanitizeView(value: unknown): GameViewState { + if (value === null || typeof value !== "object") return { ...DEFAULT_VIEW }; + const v = value as Partial; + const view = GAME_VIEWS.includes(v.view as GameView) + ? (v.view as GameView) + : "map"; + const out: GameViewState = { view }; + if (typeof v.tableEntity === "string") out.tableEntity = v.tableEntity; + if (typeof v.battleId === "string") out.battleId = v.battleId; + if (typeof v.turn === "number" && Number.isFinite(v.turn) && v.turn >= 0) { + out.turn = Math.trunc(v.turn); + } + if (typeof v.scienceId === "string") out.scienceId = v.scienceId; + return out; +} + +function persist(): void { + if (typeof sessionStorage === "undefined") return; + const snapshot: NavSnapshot = { + screen: appScreen.screen, + gameId: appScreen.gameId, + game: activeView.state, + }; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); + } catch { + // Storage full / disabled / private-mode quota — navigation still + // works in memory; only refresh-restore is lost. + } +} + +const initial = readSnapshot(); + +/** + * AppScreenStore owns the top-level screen and the active game id. Anonymous + * vs authenticated gating is applied by the dispatcher on top of `screen`. + */ +class AppScreenStore { + #screen = $state(initial?.screen ?? "lobby"); + #gameId = $state(initial?.gameId ?? null); + + /** The game id captured from a restored snapshot, for boot-time validation. */ + readonly restoredGameId: string | null = initial?.gameId ?? null; + + get screen(): AppScreen { + return this.#screen; + } + + get gameId(): string | null { + return this.#gameId; + } + + /** + * go switches the top-level screen. Entering a game requires a `gameId`; + * leaving a game clears it. Persists the snapshot. History wiring (Back → + * lobby) is added by the shell, which observes `screen`. + */ + go(screen: AppScreen, options: { gameId?: string } = {}): void { + this.#screen = screen; + if (screen === "game") { + if (options.gameId !== undefined) this.#gameId = options.gameId; + } else { + this.#gameId = null; + } + persist(); + } +} + +/** + * ActiveViewStore owns the in-game view and its sub-parameters. It is only + * meaningful while `appScreen.screen === "game"`. + */ +class ActiveViewStore { + #state = $state(initial?.game ?? { ...DEFAULT_VIEW }); + + get state(): GameViewState { + return this.#state; + } + + get view(): GameView { + return this.#state.view; + } + + /** Replace the active in-game view and its sub-parameters. Persists. */ + select(view: GameView, params: Omit = {}): void { + this.#state = { view, ...params }; + persist(); + } + + /** Reset to the default view (map). Used when entering a fresh game. */ + reset(): void { + this.#state = { ...DEFAULT_VIEW }; + persist(); + } +} + +export const appScreen = new AppScreenStore(); +export const activeView = new ActiveViewStore(); -- 2.52.0 From b6770d394c7df94a219281c467cea6556586953d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 23 May 2026 20:04:04 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat(ui):=20app-shell=20core=20=E2=80=94=20?= =?UTF-8?q?single-route=20dispatcher,=20route=20collapse,=20nav=E2=86=92st?= =?UTF-8?q?ate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the game UI to one route (`/`): a screen dispatcher renders login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of URL routes. Move screen components to lib/screens & lib/game; the game shell reads the game id from `appScreen.gameId` and re-inits per-game stores via an $effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now state-based. WIP: browser-history (Back→lobby), restore-validation, the return-to-lobby button, push deep-links, and the test migration are follow-ups on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/lib/active-view/battle.svelte | 7 +- .../lib/active-view/designer-science.svelte | 13 +- ui/frontend/src/lib/active-view/mail.svelte | 4 +- ui/frontend/src/lib/active-view/map.svelte | 55 ++++-- ui/frontend/src/lib/active-view/report.svelte | 5 +- .../lib/active-view/report/report-toc.svelte | 17 +- .../active-view/report/section-battles.svelte | 28 ++- .../src/lib/active-view/table-sciences.svelte | 16 +- .../game/game-shell.svelte} | 186 +++++++++++------- ui/frontend/src/lib/header/header.svelte | 5 +- ui/frontend/src/lib/header/view-menu.svelte | 32 +-- .../screens/lobby-create-screen.svelte} | 15 +- .../screens/lobby-screen.svelte} | 19 +- .../screens/login-screen.svelte} | 5 +- .../src/lib/sidebar/bottom-tabs.svelte | 48 ++--- .../src/lib/sidebar/calculator-tab.svelte | 4 +- ui/frontend/src/lib/sidebar/sidebar.svelte | 44 +---- ui/frontend/src/routes/+layout.svelte | 23 +-- ui/frontend/src/routes/+page.svelte | 44 +++-- ui/frontend/src/routes/games/[id]/+layout.ts | 8 - ui/frontend/src/routes/games/[id]/+page.ts | 12 -- .../[id]/battle/[[battleId]]/+page.svelte | 16 -- .../science/[[scienceId]]/+page.svelte | 5 - .../src/routes/games/[id]/mail/+page.svelte | 5 - .../src/routes/games/[id]/map/+page.svelte | 5 - .../src/routes/games/[id]/report/+page.svelte | 47 ----- .../games/[id]/table/[entity]/+page.svelte | 6 - ui/frontend/src/routes/lobby/+page.ts | 6 - ui/frontend/src/routes/lobby/create/+page.ts | 2 - ui/frontend/src/routes/login/+page.ts | 6 - 30 files changed, 294 insertions(+), 394 deletions(-) rename ui/frontend/src/{routes/games/[id]/+layout.svelte => lib/game/game-shell.svelte} (77%) rename ui/frontend/src/{routes/lobby/create/+page.svelte => lib/screens/lobby-create-screen.svelte} (94%) rename ui/frontend/src/{routes/lobby/+page.svelte => lib/screens/lobby-screen.svelte} (96%) rename ui/frontend/src/{routes/login/+page.svelte => lib/screens/login-screen.svelte} (98%) delete mode 100644 ui/frontend/src/routes/games/[id]/+layout.ts delete mode 100644 ui/frontend/src/routes/games/[id]/+page.ts delete mode 100644 ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte delete mode 100644 ui/frontend/src/routes/games/[id]/designer/science/[[scienceId]]/+page.svelte delete mode 100644 ui/frontend/src/routes/games/[id]/mail/+page.svelte delete mode 100644 ui/frontend/src/routes/games/[id]/map/+page.svelte delete mode 100644 ui/frontend/src/routes/games/[id]/report/+page.svelte delete mode 100644 ui/frontend/src/routes/games/[id]/table/[entity]/+page.svelte delete mode 100644 ui/frontend/src/routes/lobby/+page.ts delete mode 100644 ui/frontend/src/routes/lobby/create/+page.ts delete mode 100644 ui/frontend/src/routes/login/+page.ts diff --git a/ui/frontend/src/lib/active-view/battle.svelte b/ui/frontend/src/lib/active-view/battle.svelte index befaa40..a0eec71 100644 --- a/ui/frontend/src/lib/active-view/battle.svelte +++ b/ui/frontend/src/lib/active-view/battle.svelte @@ -12,9 +12,8 @@ header now — we just hand the routes down as callbacks so the viewer keeps its prop-driven contract. --> diff --git a/ui/frontend/src/lib/active-view/designer-science.svelte b/ui/frontend/src/lib/active-view/designer-science.svelte index dfd3753..e89e9cb 100644 --- a/ui/frontend/src/lib/active-view/designer-science.svelte +++ b/ui/frontend/src/lib/active-view/designer-science.svelte @@ -25,10 +25,8 @@ fractions is a Phase 21 decision documented in `ui/docs/science-designer-ux.md`. -->
- +
diff --git a/ui/frontend/src/lib/active-view/report/report-toc.svelte b/ui/frontend/src/lib/active-view/report/report-toc.svelte index aa7a6df..c8ab49a 100644 --- a/ui/frontend/src/lib/active-view/report/report-toc.svelte +++ b/ui/frontend/src/lib/active-view/report/report-toc.svelte @@ -3,9 +3,10 @@ Phase 23 Report View table of contents. Responsibilities: - "Back to map" button at the top — visible on both desktop sidebar - and mobile sticky toolbar. Navigates via `$app/navigation.goto` so - active-view-host scroll restoration plays through SvelteKit's - history machinery and the layout's `mobileTool` resets naturally. + and mobile sticky toolbar. Switches the active view to the map + through `activeView.select("map")`; the shell's tool gate resets + the `mobileTool` overlay naturally once the map is no longer the + active view. - Desktop / tablet sidebar: a vertical list of anchor links, one per section. The active link gets `aria-current="location"` and a `.active` style. Click scrolls the active-view-host (not the @@ -20,8 +21,7 @@ The active section is computed by the orchestrator `activeSlug` prop. The TOC itself owns no observers. --> diff --git a/ui/frontend/src/lib/active-view/report/section-battles.svelte b/ui/frontend/src/lib/active-view/report/section-battles.svelte index d071c7f..fb0b373 100644 --- a/ui/frontend/src/lib/active-view/report/section-battles.svelte +++ b/ui/frontend/src/lib/active-view/report/section-battles.svelte @@ -1,15 +1,14 @@
{i18n.t("game.report.section.battles.id_label")} - openBattle(b.id)} data-testid="report-battle-row" data-id={b.id} - >{b.id} + >{b.id} {/each} @@ -90,10 +93,15 @@ decision log called out. font-size: 0.7rem; } .uuid { + padding: 0; + border: 0; + background: transparent; + font: inherit; color: var(--color-accent); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-decoration: underline; text-underline-offset: 2px; + cursor: pointer; } .uuid:hover { color: var(--color-text); diff --git a/ui/frontend/src/lib/active-view/table-sciences.svelte b/ui/frontend/src/lib/active-view/table-sciences.svelte index 0b7bbf2..fffc86d 100644 --- a/ui/frontend/src/lib/active-view/table-sciences.svelte +++ b/ui/frontend/src/lib/active-view/table-sciences.svelte @@ -11,16 +11,14 @@ The four tech proportions are stored on the wire as fractions in `[0, 1]` and surfaced here as percentages with one decimal so the table matches the designer's input units. -The component sits inside the active-view slot owned by -`routes/games/[id]/+layout.svelte`, so it inherits the per-game +The component sits inside the active-view area owned by +`lib/game/game-shell.svelte`, so it inherits the per-game `OrderDraftStore` and `RenderedReportSource` through context. No -data fetching is performed here — the layout is responsible. +data fetching is performed here — the shell is responsible. -->