feat(ui): app-shell core — single-route dispatcher, route collapse, nav→state

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) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-23 20:04:04 +02:00
parent 182beebcd6
commit b6770d394c
30 changed files with 294 additions and 394 deletions
@@ -12,9 +12,8 @@ header now — we just hand the routes down as callbacks so the
viewer keeps its prop-driven contract. viewer keeps its prop-driven contract.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { activeView } from "$lib/app-nav.svelte";
import { import {
BattleFetchError, BattleFetchError,
@@ -127,10 +126,10 @@ viewer keeps its prop-driven contract.
}); });
function backToReport() { function backToReport() {
goto(withBase(`/games/${gameId}/report`)); activeView.select("report");
} }
function backToMap() { function backToMap() {
goto(withBase(`/games/${gameId}/map`)); activeView.select("map");
} }
</script> </script>
@@ -25,10 +25,8 @@ fractions is a Phase 21 decision documented in
`ui/docs/science-designer-ux.md`. `ui/docs/science-designer-ux.md`.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { getContext, tick } from "svelte"; import { getContext, tick } from "svelte";
import { goto } from "$app/navigation"; import { activeView } from "$lib/app-nav.svelte";
import { page } from "$app/state";
import type { ScienceSummary } from "../../api/game-state"; import type { ScienceSummary } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -53,8 +51,11 @@ fractions is a Phase 21 decision documented in
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
); );
const gameId = $derived(page.params.id ?? ""); // `scienceId` is the only sub-parameter the science designer needs;
const scienceId = $derived(page.params.scienceId ?? ""); // the active game id is implicit (the shell only mounts this view
// for the active game) and is read from `appScreen` where required.
let { scienceId = "" }: { scienceId?: string } = $props();
const isViewMode = $derived(scienceId !== ""); const isViewMode = $derived(scienceId !== "");
const localScience = $derived<ScienceSummary[]>( const localScience = $derived<ScienceSummary[]>(
@@ -126,7 +127,7 @@ fractions is a Phase 21 decision documented in
} }
function backToTable(): void { function backToTable(): void {
void goto(withBase(`/games/${gameId}/table/sciences`)); activeView.select("table", { tableEntity: "sciences" });
} }
async function save(): Promise<void> { async function save(): Promise<void> {
+2 -2
View File
@@ -6,7 +6,7 @@ pane, system-item pane, compose form) live under
`./mail/*.svelte`. `./mail/*.svelte`.
--> -->
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { mailStore, type MailListEntry } from "$lib/mail-store.svelte"; import { mailStore, type MailListEntry } from "$lib/mail-store.svelte";
@@ -19,7 +19,7 @@ pane, system-item pane, compose form) live under
let selectedKey = $state<string | null>(null); let selectedKey = $state<string | null>(null);
let composeOpen = $state(false); let composeOpen = $state(false);
const gameId = $derived(page.params.id ?? ""); const gameId = $derived(appScreen.gameId ?? "");
const entries = $derived(mailStore.entries); const entries = $derived(mailStore.entries);
+35 -20
View File
@@ -1,7 +1,7 @@
<!-- <!--
Phase 11 map active view: integrates the Phase 9 renderer with the Phase 11 map active view: integrates the Phase 9 renderer with the
per-game `GameStateStore` provided through context by per-game `GameStateStore` provided through context by
`routes/games/[id]/+layout.svelte`. The view mounts the renderer `lib/game/game-shell.svelte`. The view mounts the renderer
once the store has produced a report and re-mounts when the once the store has produced a report and re-mounts when the
report's turn changes (a refresh that returns the same turn keeps report's turn changes (a refresh that returns the same turn keeps
the existing renderer instance alive). Empty-planet reports render the existing renderer instance alive). Empty-planet reports render
@@ -20,10 +20,8 @@ Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
preference the store already manages. preference the store already manages.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { getContext, onDestroy, onMount, untrack } from "svelte"; import { getContext, onDestroy, onMount, untrack } from "svelte";
import { goto } from "$app/navigation"; import { activeView } from "$lib/app-nav.svelte";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { import {
createRenderer, createRenderer,
@@ -615,6 +613,29 @@ preference the store already manages.
// through the same `hit-test` plumbing — the hitLookup map keyed // through the same `hit-test` plumbing — the hitLookup map keyed
// by primitive id resolves a hit back to either a planet or a // by primitive id resolves a hit back to either a planet or a
// ship-group selection variant. // ship-group selection variant.
// scrollToBombingRow waits for the report's bombing row for the
// given planet to mount, then scrolls it into view. The map context
// menu switches to the report view through a store mutation, so the
// section renders on a later frame; a short bounded poll bridges
// that gap without coupling the map to the report's render timing.
function scrollToBombingRow(planet: number): void {
if (typeof document === "undefined") return;
let attempts = 60;
const tick = (): void => {
const row = document.querySelector(
`[data-testid="report-bombing-row"][data-planet="${planet}"]`,
);
if (row instanceof HTMLElement) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
attempts -= 1;
if (attempts <= 0) return;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
function handleMapClick(cursorPx: { x: number; y: number }): void { function handleMapClick(cursorPx: { x: number; y: number }): void {
if (handle === null || store?.report === undefined || store.report === null) { if (handle === null || store?.report === undefined || store.report === null) {
return; return;
@@ -634,26 +655,20 @@ preference the store already manages.
selection.selectShipGroup(target.ref); selection.selectShipGroup(target.ref);
break; break;
case "battle": { case "battle": {
const gameId = page.params.id ?? "";
const turn = store?.report?.turn ?? 0; const turn = store?.report?.turn ?? 0;
void goto( activeView.select("battle", {
withBase(`/games/${gameId}/battle/${target.battleId}?turn=${turn}`), battleId: target.battleId,
); turn,
});
break; break;
} }
case "bombing": { case "bombing": {
const gameId = page.params.id ?? ""; activeView.select("report");
void goto( // The report sections render reactively after the view
withBase(`/games/${gameId}/report#report-bombings`), // switches above, so there is no navigation promise to
).then(() => { // await; poll a bounded number of animation frames for
if (typeof document === "undefined") return; // the bombing row, then scroll it into view.
const row = document.querySelector( scrollToBombingRow(target.planet);
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
);
if (row && row.scrollIntoView) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
break; break;
} }
} }
@@ -22,7 +22,6 @@ TOC and the body iterate the same data.
--> -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/state";
import ReportToc, { import ReportToc, {
type TocEntry, type TocEntry,
@@ -71,8 +70,6 @@ TOC and the body iterate the same data.
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" }, { slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
]; ];
const gameId = $derived(page.params.id ?? "");
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? ""); let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
let bodyEl: HTMLDivElement | null = $state(null); let bodyEl: HTMLDivElement | null = $state(null);
@@ -116,7 +113,7 @@ TOC and the body iterate the same data.
</script> </script>
<div class="report-view" data-testid="active-view-report"> <div class="report-view" data-testid="active-view-report">
<ReportToc entries={ENTRIES} {activeSlug} {gameId} /> <ReportToc entries={ENTRIES} {activeSlug} />
<div class="report-body" bind:this={bodyEl}> <div class="report-body" bind:this={bodyEl}>
<SectionGalaxySummary /> <SectionGalaxySummary />
@@ -3,9 +3,10 @@ Phase 23 Report View table of contents.
Responsibilities: Responsibilities:
- "Back to map" button at the top — visible on both desktop sidebar - "Back to map" button at the top — visible on both desktop sidebar
and mobile sticky toolbar. Navigates via `$app/navigation.goto` so and mobile sticky toolbar. Switches the active view to the map
active-view-host scroll restoration plays through SvelteKit's through `activeView.select("map")`; the shell's tool gate resets
history machinery and the layout's `mobileTool` resets naturally. the `mobileTool` overlay naturally once the map is no longer the
active view.
- Desktop / tablet sidebar: a vertical list of anchor links, one per - Desktop / tablet sidebar: a vertical list of anchor links, one per
section. The active link gets `aria-current="location"` and a section. The active link gets `aria-current="location"` and a
`.active` style. Click scrolls the active-view-host (not the `.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. `activeSlug` prop. The TOC itself owns no observers.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths"; import { activeView } from "$lib/app-nav.svelte";
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -33,10 +33,9 @@ The active section is computed by the orchestrator
type Props = { type Props = {
entries: readonly TocEntry[]; entries: readonly TocEntry[];
activeSlug: string; activeSlug: string;
gameId: string;
}; };
let { entries, activeSlug, gameId }: Props = $props(); let { entries, activeSlug }: Props = $props();
function scrollToSlug(slug: string): void { function scrollToSlug(slug: string): void {
const target = document.getElementById(`report-${slug}`); const target = document.getElementById(`report-${slug}`);
@@ -62,8 +61,8 @@ The active section is computed by the orchestrator
scrollToSlug(slug); scrollToSlug(slug);
} }
async function backToMap(): Promise<void> { function backToMap(): void {
await goto(withBase(`/games/${gameId}/map`)); activeView.select("map");
} }
</script> </script>
@@ -1,15 +1,14 @@
<!-- <!--
Phase 27 Report View — battles section. Each row is a link into the Phase 27 Report View — battles section. Each row opens the Battle
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where Viewer through `activeView.select("battle", { battleId, turn })`
`turn` follows the current report's turn so history-mode views land where `turn` follows the current report's turn so history-mode views
on the right battle. Phase 23 rendered the same rows as inactive land on the right battle. Phase 23 rendered the same rows as inactive
monospace `<span>`; the rewire here is the one-liner the Phase 23 monospace `<span>`; the rewire here is the one-liner the Phase 23
decision log called out. decision log called out.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { page } from "$app/state"; import { activeView } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { import {
@@ -22,8 +21,11 @@ decision log called out.
); );
const report = $derived(rendered?.report ?? null); const report = $derived(rendered?.report ?? null);
const battles = $derived(report?.battles ?? []); const battles = $derived(report?.battles ?? []);
const gameId = $derived(page.params.id ?? "");
const turn = $derived(report?.turn ?? 0); const turn = $derived(report?.turn ?? 0);
function openBattle(battleId: string): void {
activeView.select("battle", { battleId, turn });
}
</script> </script>
<section <section
@@ -46,12 +48,13 @@ decision log called out.
<span class="label"> <span class="label">
{i18n.t("game.report.section.battles.id_label")} {i18n.t("game.report.section.battles.id_label")}
</span> </span>
<a <button
type="button"
class="uuid" class="uuid"
href={withBase(`/games/${gameId}/battle/${b.id}?turn=${turn}`)} onclick={() => openBattle(b.id)}
data-testid="report-battle-row" data-testid="report-battle-row"
data-id={b.id} data-id={b.id}
>{b.id}</a> >{b.id}</button>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -90,10 +93,15 @@ decision log called out.
font-size: 0.7rem; font-size: 0.7rem;
} }
.uuid { .uuid {
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: var(--color-accent); color: var(--color-accent);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
cursor: pointer;
} }
.uuid:hover { .uuid:hover {
color: var(--color-text); color: var(--color-text);
@@ -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 `[0, 1]` and surfaced here as percentages with one decimal so the
table matches the designer's input units. table matches the designer's input units.
The component sits inside the active-view slot owned by The component sits inside the active-view area owned by
`routes/games/[id]/+layout.svelte`, so it inherits the per-game `lib/game/game-shell.svelte`, so it inherits the per-game
`OrderDraftStore` and `RenderedReportSource` through context. No `OrderDraftStore` and `RenderedReportSource` through context. No
data fetching is performed here — the layout is responsible. data fetching is performed here — the shell is responsible.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { activeView } from "$lib/app-nav.svelte";
import { page } from "$app/state";
import type { ScienceSummary } from "../../api/game-state"; import type { ScienceSummary } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -60,8 +58,6 @@ data fetching is performed here — the layout is responsible.
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
); );
const gameId = $derived(page.params.id ?? "");
let sortColumn: SortColumn = $state("name"); let sortColumn: SortColumn = $state("name");
let sortDirection: SortDirection = $state("asc"); let sortDirection: SortDirection = $state("asc");
let filter: string = $state(""); let filter: string = $state("");
@@ -118,11 +114,11 @@ data fetching is performed here — the layout is responsible.
} }
function openDesigner(name: string): void { function openDesigner(name: string): void {
void goto(withBase(`/games/${gameId}/designer/science/${encodeURIComponent(name)}`)); activeView.select("designer-science", { scienceId: name });
} }
function newScience(): void { function newScience(): void {
void goto(withBase(`/games/${gameId}/designer/science`)); activeView.select("designer-science");
} }
async function deleteScience(name: string): Promise<void> { async function deleteScience(name: string): Promise<void> {
@@ -1,59 +1,63 @@
<!-- <!--
Phase 10 in-game shell. Composes the header, a conditionally-visible In-game shell. Composes the header, a conditionally-visible sidebar
sidebar (Calculator / Inspector / Order tabs), the active-view slot (Calculator / Inspector / Order tabs), the active-view area selected
filled by the child route, and a mobile-only bottom-tab bar. The by `activeView`, and a mobile-only bottom-tab bar. In the single-URL
layout owns: app-shell there are no per-view routes: the active game id comes from
`appScreen.gameId` and the visible view from `activeView`, both held
in `$lib/app-nav.svelte`. The shell owns:
- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the - `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
sidebar pinned via CSS; mobile hides it entirely. sidebar pinned via CSS; mobile hides it entirely.
- `mobileTool` — mobile-only tool overlay state. The tool only - `mobileTool` — mobile-only tool overlay state. The tool only
visually overrides the active-view slot when the URL is `/map`, visually overrides the active-view area when the active view is the
so navigating to any other view through the More drawer or the map, so switching to any other view through the More drawer or the
header view-menu naturally drops the overlay even if `mobileTool` header view-menu naturally drops the overlay even if `mobileTool`
was set on a previous tap. was set on a previous tap.
- `activeTab` — current sidebar tool (`calculator` / `inspector` / - `activeTab` — current sidebar tool (`calculator` / `inspector` /
`order`). Held here, bound into the sidebar so a planet click on `order`). Held here, bound into the sidebar so a planet click on
the map can flip it to `inspector` from the outside (Phase 13). the map can flip it to `inspector` from the outside.
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the - Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
Phase 13 `SelectionStore`. All three are exposed to descendants `SelectionStore`. All three are exposed to descendants via Svelte
via Svelte context; their lifetimes match the layout instance, context; their lifetimes match the shell instance.
which itself stays mounted across active-view switches inside
`/games/:id/*`.
Phase 11 added the per-game `GameStateStore` instance owned by this The per-game `GameStateStore` constructs the `GalaxyClient`, fetches
layout: it constructs the `GalaxyClient`, fetches the matching lobby the matching lobby record to discover `current_turn`, then loads the
record to discover `current_turn`, then loads the report. The store report. The store is shared with descendants via
is shared with descendants via `setContext("gameState", ...)` so the `setContext(GAME_STATE_CONTEXT_KEY, ...)` so the header turn counter,
header turn counter, the map view, and the inspector tab all read the map view, and the inspector tab all read from the same snapshot.
from the same snapshot.
Phase 13 adds the planet inspector. The layout watches the selection The planet inspector: the shell watches the selection store and, on
store and, on the null → planet transition, flips `activeTab` to the null → planet transition, flips `activeTab` to `inspector` and
`inspector` and `sidebarOpen` to `true` so the inspector becomes `sidebarOpen` to `true` so the inspector becomes visible regardless
visible regardless of breakpoint (desktop already has the sidebar of breakpoint (desktop already has the sidebar pinned; tablet needs
pinned; tablet needs the drawer to surface). On mobile the the drawer to surface). On mobile the `<PlanetSheet />` overlay reads
`<PlanetSheet />` overlay reads the same selection and displays a the same selection and displays a read-only sheet over the map;
read-only sheet over the map; closing the sheet clears the closing the sheet clears the selection.
selection.
State preservation across active-view switches works for free The per-game bootstrap (client construction, store init, push-event
because SvelteKit keeps this layout instance mounted while children subscriptions) runs from an `$effect` keyed on `appScreen.gameId`:
swap; navigating between games unmounts and remounts the layout, so the cleanup tears the previous game's subscriptions down and the body
the next game's snapshot — and the next game's selection — start re-initialises the shared stores for the new id, so a direct
fresh. game → game switch (without leaving the shell) rebinds cleanly. The
shell unmounts when the dispatcher leaves the `game` screen, so a
return to the lobby still disposes the stores via `onDestroy`.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths"; import { onDestroy, setContext, untrack } from "svelte";
import { onDestroy, onMount, setContext, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import Header from "$lib/header/header.svelte"; import Header from "$lib/header/header.svelte";
import HistoryBanner from "$lib/header/history-banner.svelte"; import HistoryBanner from "$lib/header/history-banner.svelte";
import Sidebar from "$lib/sidebar/sidebar.svelte"; import Sidebar from "$lib/sidebar/sidebar.svelte";
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte"; import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
import Calculator from "$lib/sidebar/calculator-tab.svelte"; import Calculator from "$lib/sidebar/calculator-tab.svelte";
import Order from "$lib/sidebar/order-tab.svelte"; import Order from "$lib/sidebar/order-tab.svelte";
import MapView from "$lib/active-view/map.svelte";
import TableView from "$lib/active-view/table.svelte";
import ReportView from "$lib/active-view/report.svelte";
import BattleView from "$lib/active-view/battle.svelte";
import MailView from "$lib/active-view/mail.svelte";
import DesignerScience from "$lib/active-view/designer-science.svelte";
import PlanetSheet from "$lib/inspectors/planet-sheet.svelte"; import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte"; import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte"; import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
@@ -71,7 +75,7 @@ fresh.
import { import {
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore, OrderDraftStore,
} from "../../../sync/order-draft.svelte"; } from "../../sync/order-draft.svelte";
import { import {
MAP_PICK_CONTEXT_KEY, MAP_PICK_CONTEXT_KEY,
MapPickService, MapPickService,
@@ -85,30 +89,30 @@ fresh.
CoreHolder, CoreHolder,
} from "$lib/core-context.svelte"; } from "$lib/core-context.svelte";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
import { loadStore } from "../../../platform/store/index"; import { loadStore } from "../../platform/store/index";
import { loadCore } from "../../../platform/core/index"; import { loadCore } from "../../platform/core/index";
import { createGatewayClient } from "../../../api/connect"; import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client"; import { GalaxyClient } from "../../api/galaxy-client";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env"; import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { import {
getSyntheticReport, getSyntheticReport,
isSyntheticGameId, isSyntheticGameId,
} from "../../../api/synthetic-report"; } from "../../api/synthetic-report";
import { import {
eventStream, eventStream,
type VerifiedEvent, type VerifiedEvent,
} from "../../../api/events.svelte"; } from "../../api/events.svelte";
import { toast } from "$lib/toast.svelte"; import { toast } from "$lib/toast.svelte";
import { mailStore } from "$lib/mail-store.svelte"; import { mailStore } from "$lib/mail-store.svelte";
let { children } = $props();
let sidebarOpen = $state(false); let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map"); let mobileTool: MobileTool = $state("map");
let activeTab: SidebarTab = $state("inspector"); let activeTab: SidebarTab = $state("inspector");
const gameId = $derived(page.params.id ?? ""); // The tool overlay (Calculator / Order) only replaces the active
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname)); // view while the map is showing; switching to any other view drops
// it, matching the previous URL-driven behaviour.
const isOnMap = $derived(activeView.view === "map");
const effectiveTool: MobileTool = $derived.by(() => const effectiveTool: MobileTool = $derived.by(() =>
isOnMap ? mobileTool : "map", isOnMap ? mobileTool : "map",
); );
@@ -363,18 +367,45 @@ fresh.
}); });
}); });
onMount(() => { function teardownSubscriptions(): void {
if (unsubTurnReady !== null) {
unsubTurnReady();
unsubTurnReady = null;
}
if (unsubGamePaused !== null) {
unsubGamePaused();
unsubGamePaused = null;
}
if (unsubMailReceived !== null) {
unsubMailReceived();
unsubMailReceived = null;
}
}
// Per-game bootstrap. The effect re-runs whenever `appScreen.gameId`
// changes: its cleanup tears the previous game's push-event
// subscriptions down, then the body rebinds the shared stores to the
// new id. The shared store instances persist across the switch
// (descendants captured them through context at construction), so a
// game → game switch re-initialises them in place rather than
// recreating them; `onDestroy` performs the terminal `dispose()`
// when the dispatcher leaves the `game` screen and unmounts the
// shell. A null id (no active game) is a no-op.
$effect(() => {
const activeGameId = appScreen.gameId;
if (activeGameId === null || activeGameId === "") return;
(async (): Promise<void> => { (async (): Promise<void> => {
// DEV-only synthetic-report path. The lobby's "Load // DEV-only synthetic-report path. The lobby's "Load
// synthetic report" affordance navigates here with a // synthetic report" affordance enters the game with a
// `synthetic-<uuid>` id and the matching report // `synthetic-<uuid>` id and the matching report
// pre-registered in an in-memory map. A page reload // pre-registered in an in-memory map. A page reload
// loses the map entry; that case redirects to /lobby // loses the map entry; that case returns to the lobby
// so the user reloads the JSON. // so the user reloads the JSON.
if (isSyntheticGameId(gameId)) { if (isSyntheticGameId(activeGameId)) {
const report = getSyntheticReport(gameId); const report = getSyntheticReport(activeGameId);
if (report === undefined) { if (report === undefined) {
await goto(withBase("/lobby")); appScreen.go("lobby");
return; return;
} }
try { try {
@@ -392,8 +423,8 @@ fresh.
]); ]);
coreHolder.set(core); coreHolder.set(core);
await Promise.all([ await Promise.all([
gameState.initSynthetic({ cache, gameId, report }), gameState.initSynthetic({ cache, gameId: activeGameId, report }),
orderDraft.init({ cache, gameId }), orderDraft.init({ cache, gameId: activeGameId }),
]); ]);
// Deliberately no `galaxyClient.set` and no // Deliberately no `galaxyClient.set` and no
// `orderDraft.bindClient`: synthetic mode never // `orderDraft.bindClient`: synthetic mode never
@@ -439,7 +470,7 @@ fresh.
// became history at the cutoff. // became history at the cutoff.
unsubTurnReady = eventStream.on("game.turn.ready", (event) => { unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
const parsed = parseTurnReadyPayload(event); const parsed = parseTurnReadyPayload(event);
if (parsed === null || parsed.gameId !== gameId) { if (parsed === null || parsed.gameId !== activeGameId) {
return; return;
} }
gameState.markPendingTurn(parsed.turn); gameState.markPendingTurn(parsed.turn);
@@ -455,7 +486,7 @@ fresh.
}); });
unsubGamePaused = eventStream.on("game.paused", (event) => { unsubGamePaused = eventStream.on("game.paused", (event) => {
const parsed = parseGamePausedPayload(event); const parsed = parseGamePausedPayload(event);
if (parsed === null || parsed.gameId !== gameId) { if (parsed === null || parsed.gameId !== activeGameId) {
return; return;
} }
orderDraft.markPaused({ reason: parsed.reason }); orderDraft.markPaused({ reason: parsed.reason });
@@ -464,7 +495,7 @@ fresh.
"diplomail.message.received", "diplomail.message.received",
(event) => { (event) => {
const parsed = parseMailReceivedPayload(event); const parsed = parseMailReceivedPayload(event);
if (parsed === null || parsed.gameId !== gameId) { if (parsed === null || parsed.gameId !== activeGameId) {
return; return;
} }
void mailStore.applyPushEvent(parsed.gameId); void mailStore.applyPushEvent(parsed.gameId);
@@ -473,16 +504,16 @@ fresh.
messageParams: { from: parsed.from }, messageParams: { from: parsed.from },
actionLabelKey: "game.events.mail_new.action", actionLabelKey: "game.events.mail_new.action",
onAction: () => { onAction: () => {
void goto(withBase(`/games/${gameId}/mail`)); activeView.select("mail");
}, },
durationMs: 8000, durationMs: 8000,
}); });
}, },
); );
await Promise.all([ await Promise.all([
gameState.init({ client, cache, gameId }), gameState.init({ client, cache, gameId: activeGameId }),
orderDraft.init({ cache, gameId }), orderDraft.init({ cache, gameId: activeGameId }),
mailStore.init({ client, cache, gameId }), mailStore.init({ client, cache, gameId: activeGameId }),
]); ]);
galaxyClient.set(client); galaxyClient.set(client);
orderDraft.bindClient(client, { orderDraft.bindClient(client, {
@@ -503,21 +534,12 @@ fresh.
gameState.failBootstrap(describeBootstrapError(err)); gameState.failBootstrap(describeBootstrapError(err));
} }
})(); })();
return teardownSubscriptions;
}); });
onDestroy(() => { onDestroy(() => {
if (unsubTurnReady !== null) { teardownSubscriptions();
unsubTurnReady();
unsubTurnReady = null;
}
if (unsubGamePaused !== null) {
unsubGamePaused();
unsubGamePaused = null;
}
if (unsubMailReceived !== null) {
unsubMailReceived();
unsubMailReceived = null;
}
gameState.dispose(); gameState.dispose();
orderDraft.dispose(); orderDraft.dispose();
selection.dispose(); selection.dispose();
@@ -534,7 +556,6 @@ fresh.
{i18n.t("common.skip_to_content")} {i18n.t("common.skip_to_content")}
</a> </a>
<Header <Header
{gameId}
{sidebarOpen} {sidebarOpen}
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
/> />
@@ -550,8 +571,22 @@ fresh.
<Calculator /> <Calculator />
{:else if effectiveTool === "order"} {:else if effectiveTool === "order"}
<Order /> <Order />
{:else} {:else if activeView.view === "map"}
{@render children()} <MapView />
{:else if activeView.view === "table"}
<TableView entity={activeView.state.tableEntity ?? ""} />
{:else if activeView.view === "report"}
<ReportView />
{:else if activeView.view === "battle"}
<BattleView
gameId={appScreen.gameId ?? ""}
turn={activeView.state.turn ?? 0}
battleId={activeView.state.battleId ?? ""}
/>
{:else if activeView.view === "mail"}
<MailView />
{:else if activeView.view === "designer-science"}
<DesignerScience scienceId={activeView.state.scienceId} />
{/if} {/if}
</main> </main>
<Sidebar <Sidebar
@@ -562,7 +597,6 @@ fresh.
/> />
</div> </div>
<BottomTabs <BottomTabs
{gameId}
activeTool={effectiveTool} activeTool={effectiveTool}
onSelectTool={(tool) => (mobileTool = tool)} onSelectTool={(tool) => (mobileTool = tool)}
hideOrder={historyMode} hideOrder={historyMode}
+2 -3
View File
@@ -27,11 +27,10 @@ absent until Phase 24 wires push-event state.
import TurnNavigator from "./turn-navigator.svelte"; import TurnNavigator from "./turn-navigator.svelte";
type Props = { type Props = {
gameId: string;
sidebarOpen: boolean; sidebarOpen: boolean;
onToggleSidebar: () => void; onToggleSidebar: () => void;
}; };
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props(); let { sidebarOpen, onToggleSidebar }: Props = $props();
const gameState = getContext<GameStateStore | undefined>( const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY, GAME_STATE_CONTEXT_KEY,
@@ -69,7 +68,7 @@ absent until Phase 24 wires push-event state.
> >
</button> </button>
<ViewMenu {gameId} /> <ViewMenu />
<AccountMenu /> <AccountMenu />
</div> </div>
</header> </header>
+16 -16
View File
@@ -7,21 +7,18 @@ itself is identical. The same component is reused for the mobile
Lists the seven IA destinations: map, tables (sub-list of six Lists the seven IA destinations: map, tables (sub-list of six
entities), report, battle, mail, ship-class designer, science entities), report, battle, mail, ship-class designer, science
designer. Closes on Escape, on outside click, and after a designer. Each entry mutates `activeView` (the single-URL app-shell
navigation. Phase 26 introduces the history-mode entry; Phase 35 has no per-view routes) and closes the menu. Closes on Escape, on
polishes microcopy. outside click, and after a selection. Phase 26 introduces the
history-mode entry; Phase 35 polishes microcopy.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { activeView, type GameView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { mailStore } from "$lib/mail-store.svelte"; import { mailStore } from "$lib/mail-store.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus"; import { restoreFocus } from "$lib/a11y/restore-focus";
type Props = { gameId: string };
let { gameId }: Props = $props();
const mailUnread = $derived(mailStore.unreadCount); const mailUnread = $derived(mailStore.unreadCount);
let open = $state(false); let open = $state(false);
@@ -40,9 +37,12 @@ polishes microcopy.
open = !open; open = !open;
} }
function go(path: string): void { function select(
view: GameView,
params: { tableEntity?: string } = {},
): void {
open = false; open = false;
void goto(withBase(path)); activeView.select(view, params);
} }
function onKeyDown(event: KeyboardEvent): void { function onKeyDown(event: KeyboardEvent): void {
@@ -93,7 +93,7 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-map" data-testid="view-menu-item-map"
onclick={() => go(`/games/${gameId}/map`)} onclick={() => select("map")}
> >
{i18n.t("game.view.map")} {i18n.t("game.view.map")}
</button> </button>
@@ -105,7 +105,7 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-table-{entry.slug}" data-testid="view-menu-item-table-{entry.slug}"
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)} onclick={() => select("table", { tableEntity: entry.slug })}
> >
{i18n.t(entry.key)} {i18n.t(entry.key)}
</button> </button>
@@ -116,7 +116,7 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-report" data-testid="view-menu-item-report"
onclick={() => go(`/games/${gameId}/report`)} onclick={() => select("report")}
> >
{i18n.t("game.view.report")} {i18n.t("game.view.report")}
</button> </button>
@@ -124,7 +124,7 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-battle" data-testid="view-menu-item-battle"
onclick={() => go(`/games/${gameId}/battle`)} onclick={() => select("battle")}
> >
{i18n.t("game.view.battle")} {i18n.t("game.view.battle")}
</button> </button>
@@ -133,7 +133,7 @@ polishes microcopy.
role="menuitem" role="menuitem"
data-testid="view-menu-item-mail" data-testid="view-menu-item-mail"
class="with-badge" class="with-badge"
onclick={() => go(`/games/${gameId}/mail`)} onclick={() => select("mail")}
> >
<span>{i18n.t("game.view.mail")}</span> <span>{i18n.t("game.view.mail")}</span>
{#if mailUnread > 0} {#if mailUnread > 0}
@@ -146,7 +146,7 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-designer-science" data-testid="view-menu-item-designer-science"
onclick={() => go(`/games/${gameId}/designer/science`)} onclick={() => select("designer-science")}
> >
{i18n.t("game.view.designer.science")} {i18n.t("game.view.designer.science")}
</button> </button>
@@ -1,14 +1,13 @@
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../../api/connect"; import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client"; import { GalaxyClient } from "../../api/galaxy-client";
import { LobbyError, createGame } from "../../../api/lobby"; import { LobbyError, createGame } from "../../api/lobby";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env"; import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../../platform/core/index"; import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
const DEFAULT_MIN_PLAYERS = 2; const DEFAULT_MIN_PLAYERS = 2;
@@ -52,7 +51,7 @@
} }
function cancel(): void { function cancel(): void {
goto(withBase("/lobby")); appScreen.go("lobby");
} }
async function submit(): Promise<void> { async function submit(): Promise<void> {
@@ -94,7 +93,7 @@
turnSchedule: trimmedSchedule, turnSchedule: trimmedSchedule,
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION, targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
}); });
goto(withBase("/lobby")); appScreen.go("lobby");
} catch (err) { } catch (err) {
formError = describeLobbyError(err); formError = describeLobbyError(err);
} finally { } finally {
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect"; import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client"; import { GalaxyClient } from "../../api/galaxy-client";
@@ -185,11 +184,15 @@
} }
function gotoCreate(): void { function gotoCreate(): void {
goto(withBase("/lobby/create")); appScreen.go("lobby-create");
} }
function gotoGame(gameId: string): void { function gotoGame(gameId: string): void {
goto(withBase(`/games/${gameId}/map`)); // Enter a fresh game on the map view: reset the in-game view
// state first so a stale snapshot from a previous game does not
// leak into the new one, then switch the top-level screen.
activeView.reset();
appScreen.go("game", { gameId });
} }
async function onSyntheticFileChange( async function onSyntheticFileChange(
@@ -208,7 +211,8 @@
const text = await file.text(); const text = await file.text();
const json: unknown = JSON.parse(text); const json: unknown = JSON.parse(text);
const { gameId } = loadSyntheticReportFromJSON(json); const { gameId } = loadSyntheticReportFromJSON(json);
await goto(withBase(`/games/${gameId}/map`)); activeView.reset();
appScreen.go("game", { gameId });
} catch (err) { } catch (err) {
if (err instanceof SyntheticReportError) { if (err instanceof SyntheticReportError) {
syntheticError = err.message; syntheticError = err.message;
@@ -227,9 +231,8 @@
// Statuses for which the game has a navigable in-game view. // Statuses for which the game has a navigable in-game view.
// Lobby-internal statuses (draft, enrollment_open, ready_to_start, // Lobby-internal statuses (draft, enrollment_open, ready_to_start,
// starting, start_failed) and terminal ones (cancelled) stay // starting, start_failed) and terminal ones (cancelled) stay
// non-clickable; clicking them otherwise lands on a 404 because // non-clickable; entering them otherwise opens the game shell on a
// /games/:id/map only meaningfully exists once the runtime has // game whose runtime state does not exist yet.
// produced game state.
function isPlayableStatus(status: string): boolean { function isPlayableStatus(status: string): boolean {
return status === "running" || status === "paused" || status === "finished"; return status === "running" || status === "paused" || status === "finished";
} }
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths"; import { appScreen } from "$lib/app-nav.svelte";
import { goto } from "$app/navigation";
import { import {
AuthError, AuthError,
confirmEmailCode, confirmEmailCode,
@@ -89,7 +88,7 @@
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}); });
await session.signIn(result.deviceSessionId); await session.signIn(result.deviceSessionId);
void goto(withBase("/lobby"), { replaceState: true }); appScreen.go("lobby");
} catch (err) { } catch (err) {
if (err instanceof AuthError && err.code === "invalid_request") { if (err instanceof AuthError && err.code === "invalid_request") {
challengeId = null; challengeId = null;
+24 -24
View File
@@ -1,33 +1,31 @@
<!-- <!--
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map navigates Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map switches
to `/games/:id/map` and resets the tool overlay. Calc and Order also the active view to the map and resets the tool overlay. Calc and
navigate to `/games/:id/map` — the layout's tool gate replaces the Order also switch to the map view — the shell's tool gate replaces
active view with the matching sidebar tool only when the URL is the active view with the matching sidebar tool only while the map is
`/map`, so navigating to any other view via the More drawer or the the active view, so navigating to any other view via the More drawer
header view-menu naturally drops the overlay. or the header view-menu naturally drops the overlay.
More opens a drawer with the same destination list as the header More opens a drawer with the same destination list as the header
view-menu. Phase 35 polish narrows it to the IA-spec subset view-menu, each entry mutating `activeView` directly (the single-URL
(Mail, Battle log, Tables, History, Settings, Logout) once History app-shell has no per-view routes). Phase 35 polish narrows it to the
exists; until then the convenience of one source of truth for IA-spec subset (Mail, Battle log, Tables, History, Settings, Logout)
destinations beats the duplication. once History exists; until then the convenience of one source of
truth for destinations beats the duplication.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { activeView, type GameView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus"; import { restoreFocus } from "$lib/a11y/restore-focus";
import type { MobileTool } from "./types"; import type { MobileTool } from "./types";
type Props = { type Props = {
gameId: string;
activeTool: MobileTool; activeTool: MobileTool;
onSelectTool: (tool: MobileTool) => void; onSelectTool: (tool: MobileTool) => void;
hideOrder?: boolean; hideOrder?: boolean;
}; };
let { let {
gameId,
activeTool, activeTool,
onSelectTool, onSelectTool,
hideOrder = false, hideOrder = false,
@@ -45,16 +43,18 @@ destinations beats the duplication.
{ slug: "races", key: "game.view.table.races" }, { slug: "races", key: "game.view.table.races" },
]; ];
async function selectTool(tool: MobileTool): Promise<void> { function selectTool(tool: MobileTool): void {
moreOpen = false; moreOpen = false;
onSelectTool(tool); onSelectTool(tool);
await goto(withBase(`/games/${gameId}/map`)); // Calc / Order surface only over the map; selecting Map simply
// drops the overlay. Either way the map must be the active view.
activeView.select("map");
} }
async function go(path: string): Promise<void> { function go(view: GameView, params: { tableEntity?: string } = {}): void {
moreOpen = false; moreOpen = false;
onSelectTool("map"); onSelectTool("map");
await goto(withBase(path)); activeView.select(view, params);
} }
function toggleMore(): void { function toggleMore(): void {
@@ -143,7 +143,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-map" data-testid="bottom-tabs-more-map"
onclick={() => go(`/games/${gameId}/map`)} onclick={() => go("map")}
> >
{i18n.t("game.view.map")} {i18n.t("game.view.map")}
</button> </button>
@@ -155,7 +155,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-table-{entry.slug}" data-testid="bottom-tabs-more-table-{entry.slug}"
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)} onclick={() => go("table", { tableEntity: entry.slug })}
> >
{i18n.t(entry.key)} {i18n.t(entry.key)}
</button> </button>
@@ -166,7 +166,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-report" data-testid="bottom-tabs-more-report"
onclick={() => go(`/games/${gameId}/report`)} onclick={() => go("report")}
> >
{i18n.t("game.view.report")} {i18n.t("game.view.report")}
</button> </button>
@@ -174,7 +174,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-battle" data-testid="bottom-tabs-more-battle"
onclick={() => go(`/games/${gameId}/battle`)} onclick={() => go("battle")}
> >
{i18n.t("game.view.battle")} {i18n.t("game.view.battle")}
</button> </button>
@@ -182,7 +182,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-mail" data-testid="bottom-tabs-more-mail"
onclick={() => go(`/games/${gameId}/mail`)} onclick={() => go("mail")}
> >
{i18n.t("game.view.mail")} {i18n.t("game.view.mail")}
</button> </button>
@@ -190,7 +190,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-designer-science" data-testid="bottom-tabs-more-designer-science"
onclick={() => go(`/games/${gameId}/designer/science`)} onclick={() => go("designer-science")}
> >
{i18n.t("game.view.designer.science")} {i18n.t("game.view.designer.science")}
</button> </button>
@@ -15,7 +15,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import { page } from "$app/state"; import { appScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { import {
@@ -67,7 +67,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
// Reset the design when the active game changes; a no-op otherwise, so // Reset the design when the active game changes; a no-op otherwise, so
// the design persists across tab switches within a game. // the design persists across tab switches within a game.
$effect(() => { $effect(() => {
cs.ensureGame(page.params.id ?? ""); cs.ensureGame(appScreen.gameId ?? "");
}); });
const core = $derived(coreHandle?.core ?? null); const core = $derived(coreHandle?.core ?? null);
+10 -34
View File
@@ -1,29 +1,23 @@
<!-- <!--
Sidebar with three tabs (Calculator, Inspector, Order). The parent Sidebar with three tabs (Calculator, Inspector, Order). The parent
layout decides whether the sidebar is rendered at all (mobile hides shell decides whether the sidebar is rendered at all (mobile hides
it, tablet collapses it behind the header toggle, desktop keeps it it, tablet collapses it behind the header toggle, desktop keeps it
always visible). State preservation across active-view switches always visible). State preservation across active-view switches
works for free because the layout never remounts when the user works for free because the shell never remounts when the user
navigates within `/games/:id/*`. switches the active view within a game.
The optional `?sidebar=calc|calculator|inspector|order` URL param
seeds the initial tab on first mount — used by the lobby card path
when later phases want to land directly on a particular tool.
The `historyMode` prop hides the Order tab when true: the tab-bar The `historyMode` prop hides the Order tab when true: the tab-bar
filters it out and any URL seed targeting `order` falls back to filters it out and the history-mode reset falls back to `inspector`.
`inspector`. Phase 12 wires the prop through the layout as a Phase 12 wires the prop through the shell as a constant `false`;
constant `false`; Phase 26 flips it on for past-turn snapshots. Phase 26 flips it on for past-turn snapshots.
`activeTab` is a `$bindable` prop so the layout can drive it from `activeTab` is a `$bindable` prop so the shell can drive it from
external events (Phase 13 reveals the inspector tab when a planet external events (Phase 13 reveals the inspector tab when a planet
is clicked on the map). The URL seed and the history-mode reset is clicked on the map). The history-mode reset mutates the bindable
both mutate the bindable in place; the layout sees the change in place; the shell sees the change through the binding without
through the binding without extra plumbing. extra plumbing.
--> -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import TabBar from "./tab-bar.svelte"; import TabBar from "./tab-bar.svelte";
import Calculator from "./calculator-tab.svelte"; import Calculator from "./calculator-tab.svelte";
import Inspector from "./inspector-tab.svelte"; import Inspector from "./inspector-tab.svelte";
@@ -44,29 +38,11 @@ through the binding without extra plumbing.
activeTab = $bindable<SidebarTab>("inspector"), activeTab = $bindable<SidebarTab>("inspector"),
}: Props = $props(); }: Props = $props();
function readUrlSeed(): SidebarTab | null {
const v = page.url.searchParams.get("sidebar");
if (v === "calc" || v === "calculator") return "calculator";
if (v === "inspector") return "inspector";
if (v === "order") return "order";
return null;
}
$effect(() => { $effect(() => {
if (historyMode && activeTab === "order") { if (historyMode && activeTab === "order") {
activeTab = "inspector"; activeTab = "inspector";
} }
}); });
onMount(() => {
const seed = readUrlSeed();
if (seed === null) return;
if (seed === "order" && historyMode) {
activeTab = "inspector";
return;
}
activeTab = seed;
});
</script> </script>
<aside <aside
+1 -22
View File
@@ -2,10 +2,8 @@
import "$lib/theme/tokens.css"; import "$lib/theme/tokens.css";
import "$lib/theme/base.css"; import "$lib/theme/base.css";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { dev } from "$app/environment"; import { dev } from "$app/environment";
import { appBase, withBase } from "$lib/paths"; import { withBase } from "$lib/paths";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
import { eventStream } from "../api/events.svelte"; import { eventStream } from "../api/events.svelte";
@@ -77,25 +75,6 @@
eventStream.stop(); eventStream.stop();
streamSessionId = null; streamSessionId = null;
} }
// page.url.pathname includes the configured base path; strip it so
// the route comparisons below stay base-agnostic.
const pathname = page.url.pathname.slice(appBase.length);
// Debug-only routes under /__debug/* run their own bootstrap
// path against the storage primitives and must bypass the
// auth guard so Phase 6's Playwright spec can drive the
// keystore directly.
if (pathname.startsWith("/__debug/")) {
return;
}
if (session.status === "anonymous" && pathname !== "/login") {
void goto(withBase("/login"), { replaceState: true });
} else if (
session.status === "authenticated" &&
(pathname === "/login" || pathname === "/")
) {
void goto(withBase("/lobby"), { replaceState: true });
}
}); });
</script> </script>
+29 -15
View File
@@ -1,18 +1,32 @@
<script lang="ts"> <script lang="ts">
// The app root renders no content of its own. The root layout's auth // Single-route screen dispatcher for the app-shell. There are no
// guard redirects "/" to /lobby (authenticated) or /login // per-screen routes: the visible screen is selected from in-memory
// (anonymous); this placeholder only shows for the brief moment // state (`session.status` for the auth gate, `appScreen.screen` for
// before that client-side redirect resolves. // the authenticated screen) rather than from the URL. The root
import { i18n } from "$lib/i18n/index.svelte"; // layout intercepts the `loading` and `unsupported` session states
// before this component renders, so here `session.status` is either
// `anonymous` (login) or `authenticated` (lobby / create / game).
import { session } from "$lib/session-store.svelte";
import { appScreen } from "$lib/app-nav.svelte";
import LoginScreen from "$lib/screens/login-screen.svelte";
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
import GameShell from "$lib/game/game-shell.svelte";
</script> </script>
<main class="status"> {#if session.status === "authenticated"}
<p>{i18n.t("common.loading")}</p> {#if appScreen.screen === "lobby-create"}
</main> <LobbyCreateScreen />
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
<style> <GameShell />
.status { {:else}
padding: var(--space-6); <!--
font-family: var(--font-sans); Default authenticated screen. Covers `lobby`, a stale `login`
} screen restored from a previous anonymous session, and a `game`
</style> screen with no active game id (a snapshot that lost its id).
-->
<LobbyScreen />
{/if}
{:else}
<LoginScreen />
{/if}
@@ -1,8 +0,0 @@
// SPA mode for the in-game shell, mirroring the root layout. The
// session bootstrap and the auth gate already live in the root
// `+layout.svelte`; this layout just inherits the SPA flags so the
// static adapter does not try to prerender a per-game shell at build
// time.
export const ssr = false;
export const prerender = false;
@@ -1,12 +0,0 @@
// A bare `/games/:id` URL is not in the IA section — every in-game
// view sits under one of the typed sub-routes (`map`, `table/...`,
// etc.). Default the user to the map view so the URL is always
// pointing at a real active view; SvelteKit's `redirect` runs in the
// browser because the layout disables SSR.
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export const load: PageLoad = ({ params }) => {
throw redirect(307, `/games/${params.id}/map`);
};
@@ -1,16 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
import BattleView from "$lib/active-view/battle.svelte";
const turn = $derived.by(() => {
const raw = page.url.searchParams.get("turn");
const n = raw === null ? NaN : Number(raw);
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0;
});
</script>
<BattleView
gameId={page.params.id ?? ""}
{turn}
battleId={page.params.battleId ?? ""}
/>
@@ -1,5 +0,0 @@
<script lang="ts">
import DesignerScience from "$lib/active-view/designer-science.svelte";
</script>
<DesignerScience />
@@ -1,5 +0,0 @@
<script lang="ts">
import MailView from "$lib/active-view/mail.svelte";
</script>
<MailView />
@@ -1,5 +0,0 @@
<script lang="ts">
import MapView from "$lib/active-view/map.svelte";
</script>
<MapView />
@@ -1,47 +0,0 @@
<!--
Phase 23 turn-report route. The orchestrator renders the table of
contents and the twenty sections; scroll save/restore is wired
through SvelteKit's `Snapshot` API on this route file.
`window.scrollY` is captured before navigating away and restored
after `afterNavigate` re-mounts the route. The in-game shell
layout expands the active-view-host to fit content rather than
constraining its own height, so the document body is what scrolls
— hence `window.scroll` rather than a host-element scrollTop.
A short `requestAnimationFrame` poll waits for the body to grow
tall enough to honour the saved offset, because the captured
position usually exceeds the viewport height before the sections
mount on return navigation.
-->
<script lang="ts">
import type { Snapshot } from "@sveltejs/kit";
import ReportView from "$lib/active-view/report.svelte";
function restoreScroll(target: number): void {
if (target <= 0) return;
let attempts = 60;
const tick = (): void => {
const need = target + window.innerHeight;
const have = document.documentElement.scrollHeight;
if (have >= need || attempts === 0) {
window.scrollTo(0, target);
return;
}
attempts -= 1;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
export const snapshot: Snapshot<{ scrollY: number }> = {
capture() {
return { scrollY: window.scrollY };
},
restore(value) {
restoreScroll(value.scrollY);
},
};
</script>
<ReportView />
@@ -1,6 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
import TableView from "$lib/active-view/table.svelte";
</script>
<TableView entity={page.params.entity ?? ""} />
-6
View File
@@ -1,6 +0,0 @@
// Lobby is the first authenticated screen and depends on the
// session keypair plus the WASM core loaded at runtime; SSR and
// prerendering stay disabled.
export const ssr = false;
export const prerender = false;
@@ -1,2 +0,0 @@
export const ssr = false;
export const prerender = false;
-6
View File
@@ -1,6 +0,0 @@
// Login depends on browser-only WebCrypto and IndexedDB through the
// session store; SSR and prerendering are disabled to keep the
// component out of the server-render pipeline.
export const ssr = false;
export const prerender = false;