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:
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user