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
+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