feat(ui): single-URL game app-shell (in-memory screens/views) #35

Merged
developer merged 8 commits from feature/ui-app-shell into development 2026-05-23 20:18:09 +00:00
30 changed files with 294 additions and 394 deletions
Showing only changes of commit b6770d394c - Show all commits
@@ -12,9 +12,8 @@ header now — we just hand the routes down as callbacks so the
viewer keeps its prop-driven contract.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import { activeView } from "$lib/app-nav.svelte";
import {
BattleFetchError,
@@ -127,10 +126,10 @@ viewer keeps its prop-driven contract.
});
function backToReport() {
goto(withBase(`/games/${gameId}/report`));
activeView.select("report");
}
function backToMap() {
goto(withBase(`/games/${gameId}/map`));
activeView.select("map");
}
</script>
@@ -25,10 +25,8 @@ fractions is a Phase 21 decision documented in
`ui/docs/science-designer-ux.md`.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { getContext, tick } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { activeView } from "$lib/app-nav.svelte";
import type { ScienceSummary } from "../../api/game-state";
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,
);
const gameId = $derived(page.params.id ?? "");
const scienceId = $derived(page.params.scienceId ?? "");
// `scienceId` is the only sub-parameter the science designer needs;
// 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 localScience = $derived<ScienceSummary[]>(
@@ -126,7 +127,7 @@ fractions is a Phase 21 decision documented in
}
function backToTable(): void {
void goto(withBase(`/games/${gameId}/table/sciences`));
activeView.select("table", { tableEntity: "sciences" });
}
async function save(): Promise<void> {
+2 -2
View File
@@ -6,7 +6,7 @@ pane, system-item pane, compose form) live under
`./mail/*.svelte`.
-->
<script lang="ts">
import { page } from "$app/state";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.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 composeOpen = $state(false);
const gameId = $derived(page.params.id ?? "");
const gameId = $derived(appScreen.gameId ?? "");
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
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
report's turn changes (a refresh that returns the same turn keeps
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.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { getContext, onDestroy, onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { activeView } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
createRenderer,
@@ -615,6 +613,29 @@ preference the store already manages.
// through the same `hit-test` plumbing — the hitLookup map keyed
// by primitive id resolves a hit back to either a planet or a
// 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 {
if (handle === null || store?.report === undefined || store.report === null) {
return;
@@ -634,26 +655,20 @@ preference the store already manages.
selection.selectShipGroup(target.ref);
break;
case "battle": {
const gameId = page.params.id ?? "";
const turn = store?.report?.turn ?? 0;
void goto(
withBase(`/games/${gameId}/battle/${target.battleId}?turn=${turn}`),
);
activeView.select("battle", {
battleId: target.battleId,
turn,
});
break;
}
case "bombing": {
const gameId = page.params.id ?? "";
void goto(
withBase(`/games/${gameId}/report#report-bombings`),
).then(() => {
if (typeof document === "undefined") return;
const row = document.querySelector(
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
);
if (row && row.scrollIntoView) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
activeView.select("report");
// The report sections render reactively after the view
// switches above, so there is no navigation promise to
// await; poll a bounded number of animation frames for
// the bombing row, then scroll it into view.
scrollToBombingRow(target.planet);
break;
}
}
@@ -22,7 +22,6 @@ TOC and the body iterate the same data.
-->
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import ReportToc, {
type TocEntry,
@@ -71,8 +70,6 @@ TOC and the body iterate the same data.
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
];
const gameId = $derived(page.params.id ?? "");
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
let bodyEl: HTMLDivElement | null = $state(null);
@@ -116,7 +113,7 @@ TOC and the body iterate the same data.
</script>
<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}>
<SectionGalaxySummary />
@@ -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.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { activeView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -33,10 +33,9 @@ The active section is computed by the orchestrator
type Props = {
entries: readonly TocEntry[];
activeSlug: string;
gameId: string;
};
let { entries, activeSlug, gameId }: Props = $props();
let { entries, activeSlug }: Props = $props();
function scrollToSlug(slug: string): void {
const target = document.getElementById(`report-${slug}`);
@@ -62,8 +61,8 @@ The active section is computed by the orchestrator
scrollToSlug(slug);
}
async function backToMap(): Promise<void> {
await goto(withBase(`/games/${gameId}/map`));
function backToMap(): void {
activeView.select("map");
}
</script>
@@ -1,15 +1,14 @@
<!--
Phase 27 Report View — battles section. Each row is a link into the
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
`turn` follows the current report's turn so history-mode views land
on the right battle. Phase 23 rendered the same rows as inactive
Phase 27 Report View — battles section. Each row opens the Battle
Viewer through `activeView.select("battle", { battleId, turn })`
where `turn` follows the current report's turn so history-mode views
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
decision log called out.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte";
import { page } from "$app/state";
import { activeView } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
@@ -22,8 +21,11 @@ decision log called out.
);
const report = $derived(rendered?.report ?? null);
const battles = $derived(report?.battles ?? []);
const gameId = $derived(page.params.id ?? "");
const turn = $derived(report?.turn ?? 0);
function openBattle(battleId: string): void {
activeView.select("battle", { battleId, turn });
}
</script>
<section
@@ -46,12 +48,13 @@ decision log called out.
<span class="label">
{i18n.t("game.report.section.battles.id_label")}
</span>
<a
<button
type="button"
class="uuid"
href={withBase(`/games/${gameId}/battle/${b.id}?turn=${turn}`)}
onclick={() => openBattle(b.id)}
data-testid="report-battle-row"
data-id={b.id}
>{b.id}</a>
>{b.id}</button>
</li>
{/each}
</ul>
@@ -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);
@@ -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.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { activeView } from "$lib/app-nav.svelte";
import type { ScienceSummary } from "../../api/game-state";
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,
);
const gameId = $derived(page.params.id ?? "");
let sortColumn: SortColumn = $state("name");
let sortDirection: SortDirection = $state("asc");
let filter: string = $state("");
@@ -118,11 +114,11 @@ data fetching is performed here — the layout is responsible.
}
function openDesigner(name: string): void {
void goto(withBase(`/games/${gameId}/designer/science/${encodeURIComponent(name)}`));
activeView.select("designer-science", { scienceId: name });
}
function newScience(): void {
void goto(withBase(`/games/${gameId}/designer/science`));
activeView.select("designer-science");
}
async function deleteScience(name: string): Promise<void> {
@@ -1,59 +1,63 @@
<!--
Phase 10 in-game shell. Composes the header, a conditionally-visible
sidebar (Calculator / Inspector / Order tabs), the active-view slot
filled by the child route, and a mobile-only bottom-tab bar. The
layout owns:
In-game shell. Composes the header, a conditionally-visible sidebar
(Calculator / Inspector / Order tabs), the active-view area selected
by `activeView`, and a mobile-only bottom-tab bar. In the single-URL
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
sidebar pinned via CSS; mobile hides it entirely.
- `mobileTool` — mobile-only tool overlay state. The tool only
visually overrides the active-view slot when the URL is `/map`,
so navigating to any other view through the More drawer or the
visually overrides the active-view area when the active view is the
map, so switching to any other view through the More drawer or the
header view-menu naturally drops the overlay even if `mobileTool`
was set on a previous tap.
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
`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
Phase 13 `SelectionStore`. All three are exposed to descendants
via Svelte context; their lifetimes match the layout instance,
which itself stays mounted across active-view switches inside
`/games/:id/*`.
`SelectionStore`. All three are exposed to descendants via Svelte
context; their lifetimes match the shell instance.
Phase 11 added the per-game `GameStateStore` instance owned by this
layout: it constructs the `GalaxyClient`, fetches the matching lobby
record to discover `current_turn`, then loads the report. The store
is shared with descendants via `setContext("gameState", ...)` so the
header turn counter, the map view, and the inspector tab all read
from the same snapshot.
The per-game `GameStateStore` constructs the `GalaxyClient`, fetches
the matching lobby record to discover `current_turn`, then loads the
report. The store is shared with descendants via
`setContext(GAME_STATE_CONTEXT_KEY, ...)` so the header turn counter,
the map view, and the inspector tab all read from the same snapshot.
Phase 13 adds the planet inspector. The layout watches the selection
store and, on the null → planet transition, flips `activeTab` to
`inspector` and `sidebarOpen` to `true` so the inspector becomes
visible regardless of breakpoint (desktop already has the sidebar
pinned; tablet needs the drawer to surface). On mobile the
`<PlanetSheet />` overlay reads the same selection and displays a
read-only sheet over the map; closing the sheet clears the
selection.
The planet inspector: the shell watches the selection store and, on
the null → planet transition, flips `activeTab` to `inspector` and
`sidebarOpen` to `true` so the inspector becomes visible regardless
of breakpoint (desktop already has the sidebar pinned; tablet needs
the drawer to surface). On mobile the `<PlanetSheet />` overlay reads
the same selection and displays a read-only sheet over the map;
closing the sheet clears the selection.
State preservation across active-view switches works for free
because SvelteKit keeps this layout instance mounted while children
swap; navigating between games unmounts and remounts the layout, so
the next game's snapshot — and the next game's selection — start
fresh.
The per-game bootstrap (client construction, store init, push-event
subscriptions) runs from an `$effect` keyed on `appScreen.gameId`:
the cleanup tears the previous game's subscriptions down and the body
re-initialises the shared stores for the new id, so a direct
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">
import { withBase } from "$lib/paths";
import { onDestroy, onMount, setContext, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { onDestroy, setContext, untrack } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import Header from "$lib/header/header.svelte";
import HistoryBanner from "$lib/header/history-banner.svelte";
import Sidebar from "$lib/sidebar/sidebar.svelte";
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
import Calculator from "$lib/sidebar/calculator-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 ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
@@ -71,7 +75,7 @@ fresh.
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../../sync/order-draft.svelte";
} from "../../sync/order-draft.svelte";
import {
MAP_PICK_CONTEXT_KEY,
MapPickService,
@@ -85,30 +89,30 @@ fresh.
CoreHolder,
} from "$lib/core-context.svelte";
import { session } from "$lib/session-store.svelte";
import { loadStore } from "../../../platform/store/index";
import { loadCore } from "../../../platform/core/index";
import { createGatewayClient } from "../../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client";
import { loadStore } from "../../platform/store/index";
import { loadCore } from "../../platform/core/index";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
getSyntheticReport,
isSyntheticGameId,
} from "../../../api/synthetic-report";
} from "../../api/synthetic-report";
import {
eventStream,
type VerifiedEvent,
} from "../../../api/events.svelte";
} from "../../api/events.svelte";
import { toast } from "$lib/toast.svelte";
import { mailStore } from "$lib/mail-store.svelte";
let { children } = $props();
let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
let activeTab: SidebarTab = $state("inspector");
const gameId = $derived(page.params.id ?? "");
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
// The tool overlay (Calculator / Order) only replaces the active
// 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(() =>
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> => {
// 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
// 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.
if (isSyntheticGameId(gameId)) {
const report = getSyntheticReport(gameId);
if (isSyntheticGameId(activeGameId)) {
const report = getSyntheticReport(activeGameId);
if (report === undefined) {
await goto(withBase("/lobby"));
appScreen.go("lobby");
return;
}
try {
@@ -392,8 +423,8 @@ fresh.
]);
coreHolder.set(core);
await Promise.all([
gameState.initSynthetic({ cache, gameId, report }),
orderDraft.init({ cache, gameId }),
gameState.initSynthetic({ cache, gameId: activeGameId, report }),
orderDraft.init({ cache, gameId: activeGameId }),
]);
// Deliberately no `galaxyClient.set` and no
// `orderDraft.bindClient`: synthetic mode never
@@ -439,7 +470,7 @@ fresh.
// became history at the cutoff.
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
const parsed = parseTurnReadyPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
if (parsed === null || parsed.gameId !== activeGameId) {
return;
}
gameState.markPendingTurn(parsed.turn);
@@ -455,7 +486,7 @@ fresh.
});
unsubGamePaused = eventStream.on("game.paused", (event) => {
const parsed = parseGamePausedPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
if (parsed === null || parsed.gameId !== activeGameId) {
return;
}
orderDraft.markPaused({ reason: parsed.reason });
@@ -464,7 +495,7 @@ fresh.
"diplomail.message.received",
(event) => {
const parsed = parseMailReceivedPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
if (parsed === null || parsed.gameId !== activeGameId) {
return;
}
void mailStore.applyPushEvent(parsed.gameId);
@@ -473,16 +504,16 @@ fresh.
messageParams: { from: parsed.from },
actionLabelKey: "game.events.mail_new.action",
onAction: () => {
void goto(withBase(`/games/${gameId}/mail`));
activeView.select("mail");
},
durationMs: 8000,
});
},
);
await Promise.all([
gameState.init({ client, cache, gameId }),
orderDraft.init({ cache, gameId }),
mailStore.init({ client, cache, gameId }),
gameState.init({ client, cache, gameId: activeGameId }),
orderDraft.init({ cache, gameId: activeGameId }),
mailStore.init({ client, cache, gameId: activeGameId }),
]);
galaxyClient.set(client);
orderDraft.bindClient(client, {
@@ -503,21 +534,12 @@ fresh.
gameState.failBootstrap(describeBootstrapError(err));
}
})();
return teardownSubscriptions;
});
onDestroy(() => {
if (unsubTurnReady !== null) {
unsubTurnReady();
unsubTurnReady = null;
}
if (unsubGamePaused !== null) {
unsubGamePaused();
unsubGamePaused = null;
}
if (unsubMailReceived !== null) {
unsubMailReceived();
unsubMailReceived = null;
}
teardownSubscriptions();
gameState.dispose();
orderDraft.dispose();
selection.dispose();
@@ -534,7 +556,6 @@ fresh.
{i18n.t("common.skip_to_content")}
</a>
<Header
{gameId}
{sidebarOpen}
onToggleSidebar={toggleSidebar}
/>
@@ -550,8 +571,22 @@ fresh.
<Calculator />
{:else if effectiveTool === "order"}
<Order />
{:else}
{@render children()}
{:else if activeView.view === "map"}
<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}
</main>
<Sidebar
@@ -562,7 +597,6 @@ fresh.
/>
</div>
<BottomTabs
{gameId}
activeTool={effectiveTool}
onSelectTool={(tool) => (mobileTool = tool)}
hideOrder={historyMode}
+2 -3
View File
@@ -27,11 +27,10 @@ absent until Phase 24 wires push-event state.
import TurnNavigator from "./turn-navigator.svelte";
type Props = {
gameId: string;
sidebarOpen: boolean;
onToggleSidebar: () => void;
};
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
let { sidebarOpen, onToggleSidebar }: Props = $props();
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
@@ -69,7 +68,7 @@ absent until Phase 24 wires push-event state.
>
</button>
<ViewMenu {gameId} />
<ViewMenu />
<AccountMenu />
</div>
</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
entities), report, battle, mail, ship-class designer, science
designer. Closes on Escape, on outside click, and after a
navigation. Phase 26 introduces the history-mode entry; Phase 35
polishes microcopy.
designer. Each entry mutates `activeView` (the single-URL app-shell
has no per-view routes) and closes the menu. Closes on Escape, on
outside click, and after a selection. Phase 26 introduces the
history-mode entry; Phase 35 polishes microcopy.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
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 { mailStore } from "$lib/mail-store.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus";
type Props = { gameId: string };
let { gameId }: Props = $props();
const mailUnread = $derived(mailStore.unreadCount);
let open = $state(false);
@@ -40,9 +37,12 @@ polishes microcopy.
open = !open;
}
function go(path: string): void {
function select(
view: GameView,
params: { tableEntity?: string } = {},
): void {
open = false;
void goto(withBase(path));
activeView.select(view, params);
}
function onKeyDown(event: KeyboardEvent): void {
@@ -93,7 +93,7 @@ polishes microcopy.
type="button"
role="menuitem"
data-testid="view-menu-item-map"
onclick={() => go(`/games/${gameId}/map`)}
onclick={() => select("map")}
>
{i18n.t("game.view.map")}
</button>
@@ -105,7 +105,7 @@ polishes microcopy.
type="button"
role="menuitem"
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)}
</button>
@@ -116,7 +116,7 @@ polishes microcopy.
type="button"
role="menuitem"
data-testid="view-menu-item-report"
onclick={() => go(`/games/${gameId}/report`)}
onclick={() => select("report")}
>
{i18n.t("game.view.report")}
</button>
@@ -124,7 +124,7 @@ polishes microcopy.
type="button"
role="menuitem"
data-testid="view-menu-item-battle"
onclick={() => go(`/games/${gameId}/battle`)}
onclick={() => select("battle")}
>
{i18n.t("game.view.battle")}
</button>
@@ -133,7 +133,7 @@ polishes microcopy.
role="menuitem"
data-testid="view-menu-item-mail"
class="with-badge"
onclick={() => go(`/games/${gameId}/mail`)}
onclick={() => select("mail")}
>
<span>{i18n.t("game.view.mail")}</span>
{#if mailUnread > 0}
@@ -146,7 +146,7 @@ polishes microcopy.
type="button"
role="menuitem"
data-testid="view-menu-item-designer-science"
onclick={() => go(`/games/${gameId}/designer/science`)}
onclick={() => select("designer-science")}
>
{i18n.t("game.view.designer.science")}
</button>
@@ -1,14 +1,13 @@
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client";
import { LobbyError, createGame } from "../../../api/lobby";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import { LobbyError, createGame } from "../../api/lobby";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
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";
const DEFAULT_MIN_PLAYERS = 2;
@@ -52,7 +51,7 @@
}
function cancel(): void {
goto(withBase("/lobby"));
appScreen.go("lobby");
}
async function submit(): Promise<void> {
@@ -94,7 +93,7 @@
turnSchedule: trimmedSchedule,
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
});
goto(withBase("/lobby"));
appScreen.go("lobby");
} catch (err) {
formError = describeLobbyError(err);
} finally {
@@ -1,7 +1,6 @@
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
@@ -185,11 +184,15 @@
}
function gotoCreate(): void {
goto(withBase("/lobby/create"));
appScreen.go("lobby-create");
}
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(
@@ -208,7 +211,8 @@
const text = await file.text();
const json: unknown = JSON.parse(text);
const { gameId } = loadSyntheticReportFromJSON(json);
await goto(withBase(`/games/${gameId}/map`));
activeView.reset();
appScreen.go("game", { gameId });
} catch (err) {
if (err instanceof SyntheticReportError) {
syntheticError = err.message;
@@ -227,9 +231,8 @@
// Statuses for which the game has a navigable in-game view.
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
// starting, start_failed) and terminal ones (cancelled) stay
// non-clickable; clicking them otherwise lands on a 404 because
// /games/:id/map only meaningfully exists once the runtime has
// produced game state.
// non-clickable; entering them otherwise opens the game shell on a
// game whose runtime state does not exist yet.
function isPlayableStatus(status: string): boolean {
return status === "running" || status === "paused" || status === "finished";
}
@@ -1,6 +1,5 @@
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { appScreen } from "$lib/app-nav.svelte";
import {
AuthError,
confirmEmailCode,
@@ -89,7 +88,7 @@
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
await session.signIn(result.deviceSessionId);
void goto(withBase("/lobby"), { replaceState: true });
appScreen.go("lobby");
} catch (err) {
if (err instanceof AuthError && err.code === "invalid_request") {
challengeId = null;
+24 -24
View File
@@ -1,33 +1,31 @@
<!--
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map navigates
to `/games/:id/map` and resets the tool overlay. Calc and Order also
navigate to `/games/:id/map` — the layout's tool gate replaces the
active view with the matching sidebar tool only when the URL is
`/map`, so navigating to any other view via the More drawer or the
header view-menu naturally drops the overlay.
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map switches
the active view to the map and resets the tool overlay. Calc and
Order also switch to the map view — the shell's tool gate replaces
the active view with the matching sidebar tool only while the map is
the active view, so navigating to any other view via the More drawer
or the header view-menu naturally drops the overlay.
More opens a drawer with the same destination list as the header
view-menu. Phase 35 polish narrows it to the IA-spec subset
(Mail, Battle log, Tables, History, Settings, Logout) once History
exists; until then the convenience of one source of truth for
destinations beats the duplication.
view-menu, each entry mutating `activeView` directly (the single-URL
app-shell has no per-view routes). Phase 35 polish narrows it to the
IA-spec subset (Mail, Battle log, Tables, History, Settings, Logout)
once History exists; until then the convenience of one source of
truth for destinations beats the duplication.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
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 { restoreFocus } from "$lib/a11y/restore-focus";
import type { MobileTool } from "./types";
type Props = {
gameId: string;
activeTool: MobileTool;
onSelectTool: (tool: MobileTool) => void;
hideOrder?: boolean;
};
let {
gameId,
activeTool,
onSelectTool,
hideOrder = false,
@@ -45,16 +43,18 @@ destinations beats the duplication.
{ slug: "races", key: "game.view.table.races" },
];
async function selectTool(tool: MobileTool): Promise<void> {
function selectTool(tool: MobileTool): void {
moreOpen = false;
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;
onSelectTool("map");
await goto(withBase(path));
activeView.select(view, params);
}
function toggleMore(): void {
@@ -143,7 +143,7 @@ destinations beats the duplication.
type="button"
role="menuitem"
data-testid="bottom-tabs-more-map"
onclick={() => go(`/games/${gameId}/map`)}
onclick={() => go("map")}
>
{i18n.t("game.view.map")}
</button>
@@ -155,7 +155,7 @@ destinations beats the duplication.
type="button"
role="menuitem"
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)}
</button>
@@ -166,7 +166,7 @@ destinations beats the duplication.
type="button"
role="menuitem"
data-testid="bottom-tabs-more-report"
onclick={() => go(`/games/${gameId}/report`)}
onclick={() => go("report")}
>
{i18n.t("game.view.report")}
</button>
@@ -174,7 +174,7 @@ destinations beats the duplication.
type="button"
role="menuitem"
data-testid="bottom-tabs-more-battle"
onclick={() => go(`/games/${gameId}/battle`)}
onclick={() => go("battle")}
>
{i18n.t("game.view.battle")}
</button>
@@ -182,7 +182,7 @@ destinations beats the duplication.
type="button"
role="menuitem"
data-testid="bottom-tabs-more-mail"
onclick={() => go(`/games/${gameId}/mail`)}
onclick={() => go("mail")}
>
{i18n.t("game.view.mail")}
</button>
@@ -190,7 +190,7 @@ destinations beats the duplication.
type="button"
role="menuitem"
data-testid="bottom-tabs-more-designer-science"
onclick={() => go(`/games/${gameId}/designer/science`)}
onclick={() => go("designer-science")}
>
{i18n.t("game.view.designer.science")}
</button>
@@ -15,7 +15,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
-->
<script lang="ts">
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 {
@@ -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
// the design persists across tab switches within a game.
$effect(() => {
cs.ensureGame(page.params.id ?? "");
cs.ensureGame(appScreen.gameId ?? "");
});
const core = $derived(coreHandle?.core ?? null);
+10 -34
View File
@@ -1,29 +1,23 @@
<!--
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
always visible). State preservation across active-view switches
works for free because the layout never remounts when the user
navigates within `/games/:id/*`.
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.
works for free because the shell never remounts when the user
switches the active view within a game.
The `historyMode` prop hides the Order tab when true: the tab-bar
filters it out and any URL seed targeting `order` falls back to
`inspector`. Phase 12 wires the prop through the layout as a
constant `false`; Phase 26 flips it on for past-turn snapshots.
filters it out and the history-mode reset falls back to `inspector`.
Phase 12 wires the prop through the shell as a constant `false`;
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
is clicked on the map). The URL seed and the history-mode reset
both mutate the bindable in place; the layout sees the change
through the binding without extra plumbing.
is clicked on the map). The history-mode reset mutates the bindable
in place; the shell sees the change through the binding without
extra plumbing.
-->
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import TabBar from "./tab-bar.svelte";
import Calculator from "./calculator-tab.svelte";
import Inspector from "./inspector-tab.svelte";
@@ -44,29 +38,11 @@ through the binding without extra plumbing.
activeTab = $bindable<SidebarTab>("inspector"),
}: 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(() => {
if (historyMode && activeTab === "order") {
activeTab = "inspector";
}
});
onMount(() => {
const seed = readUrlSeed();
if (seed === null) return;
if (seed === "order" && historyMode) {
activeTab = "inspector";
return;
}
activeTab = seed;
});
</script>
<aside
+1 -22
View File
@@ -2,10 +2,8 @@
import "$lib/theme/tokens.css";
import "$lib/theme/base.css";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { dev } from "$app/environment";
import { appBase, withBase } from "$lib/paths";
import { withBase } from "$lib/paths";
import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { eventStream } from "../api/events.svelte";
@@ -77,25 +75,6 @@
eventStream.stop();
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>
+29 -15
View File
@@ -1,18 +1,32 @@
<script lang="ts">
// The app root renders no content of its own. The root layout's auth
// guard redirects "/" to /lobby (authenticated) or /login
// (anonymous); this placeholder only shows for the brief moment
// before that client-side redirect resolves.
import { i18n } from "$lib/i18n/index.svelte";
// Single-route screen dispatcher for the app-shell. There are no
// per-screen routes: the visible screen is selected from in-memory
// state (`session.status` for the auth gate, `appScreen.screen` for
// the authenticated screen) rather than from the URL. The root
// 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>
<main class="status">
<p>{i18n.t("common.loading")}</p>
</main>
<style>
.status {
padding: var(--space-6);
font-family: var(--font-sans);
}
</style>
{#if session.status === "authenticated"}
{#if appScreen.screen === "lobby-create"}
<LobbyCreateScreen />
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
<GameShell />
{:else}
<!--
Default authenticated screen. Covers `lobby`, a stale `login`
screen restored from a previous anonymous session, and a `game`
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;