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.
-->
<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> {