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