ui/phase-10: in-game shell with view-replacement skeleton

Wraps every in-game route under `/games/:id/*` in a responsive shell
with a header (race / turn placeholders, view-menu dropdown or mobile
hamburger, account menu), a three-tab sidebar (Calculator, Inspector,
Order), an active-view slot, and a mobile-only bottom-tabs row
`[Map, Calc, Order, More]`. Every view in the IA section
(`map`, `table/:entity`, `report`, `battle/:battleId?`, `mail`,
`designer/{ship-class,science}/:id?`) ships as a thin SvelteKit route
that mounts a `lib/active-view/<name>.svelte` stub rendering a
localised `coming soon` body. The lobby's `gotoGame` path now actually
lands on a rendered shell instead of a 404.

The "view router" mentioned in the plan is implemented as the file
system plus two-line route wrappers — no separate dispatch component.
Sidebar tab state lives as a `$state` rune inside `sidebar.svelte`,
which sits in the layout that SvelteKit keeps mounted across child
route swaps, so tab choice survives every active-view navigation for
free. A `?sidebar=calc|inspector|order` URL param seeds the initial
tab on first mount; the mobile bottom-tabs use a layout-owned
`mobileTool` rune with a URL-gated `effectiveTool` derivation so the
Calc / Order tool overlay only applies on `/map` and naturally drops
when the user navigates elsewhere.

Tablet ships with a click-toggle drawer for the sidebar rather than
the IA section's swipe-from-right gesture; the structural breakpoint
satisfies Phase 10's acceptance criterion and Phase 35 polish lands
the swipe. The mobile More drawer mirrors the header view-menu
content; the IA's narrower More list (Mail, Battle, Tables, History,
Settings, Logout) is also a Phase 35 polish target once History
exists.

Topic doc `ui/docs/navigation.md` captures the active-view model, the
sidebar state-preservation rule, the `?sidebar=` and `mobileTool`
conventions, and the transient map-overlay back-stack concept (with
the implementation deferred to Phase 34 alongside its first user).
i18n catalogues for `en` and `ru` add the full `game.shell.*`,
`game.view.*`, `game.sidebar.*`, `game.bottom_tabs.*` namespaces.

Tests: Vitest covers the header view-menu (every IA destination
including the Tables sub-list), the account-menu Logout / Language
wiring, the sidebar default tab / switching / `?sidebar=` seed /
close button, and every active-view stub. Playwright e2e boots an
authenticated session via `__galaxyDebug.setDeviceSessionId` (no
gateway calls — the shell makes none in Phase 10), exercises every
view through both the desktop dropdown and the mobile More drawer,
verifies sidebar tab survival across navigation, and uses
`setViewportSize` to validate the breakpoint switches at 768 px and
1024 px.

Phase 10 status stays `pending` in `ui/PLAN.md` until the local-ci
run lands green; flipping to `done` follows in the next commit per
the per-stage CI gate in `CLAUDE.md`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 20:15:49 +02:00
parent 0f8f8698bd
commit fc371c7fe1
36 changed files with 2337 additions and 29 deletions
+219
View File
@@ -0,0 +1,219 @@
// Phase 10 end-to-end coverage for the in-game shell. Every spec
// boots an authenticated session through `/__debug/store` (no
// gateway calls — the shell makes none in Phase 10), navigates into
// `/games/test-shell/map`, and exercises one slice of the chrome:
// header navigation, sidebar tab preservation, mobile bottom-tabs,
// and the breakpoint switches at 768 / 1024 px.
import { expect, test, type Page } from "@playwright/test";
// The `window.__galaxyDebug` surface is owned by
// `src/routes/__debug/store/+page.svelte` and typed by
// `tests/e2e/storage-keypair-persistence.spec.ts`. This spec only
// needs the auth-bootstrap subset (`clearSession`,
// `setDeviceSessionId`); the merged global declaration covers both.
const SESSION_ID = "phase-10-shell-session";
const GAME_ID = "test-shell";
async function bootShell(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible();
}
test("shell mounts with header / sidebar / active-view chrome", async ({
page,
}) => {
await bootShell(page);
await expect(page.getByTestId("game-shell-header")).toBeVisible();
await expect(page.getByTestId("race-name")).toContainText("race ?");
await expect(page.getByTestId("turn-counter")).toContainText("turn");
await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
await expect(page.getByTestId("account-menu-trigger")).toBeVisible();
});
test("header view-menu navigates to every active view", async ({ page }) => {
await bootShell(page);
const destinations: Array<[string, string, string]> = [
["view-menu-item-report", "active-view-report", "/report"],
["view-menu-item-mail", "active-view-mail", "/mail"],
["view-menu-item-battle", "active-view-battle", "/battle"],
[
"view-menu-item-designer-ship-class",
"active-view-designer-ship-class",
"/designer/ship-class",
],
[
"view-menu-item-designer-science",
"active-view-designer-science",
"/designer/science",
],
["view-menu-item-map", "active-view-map", "/map"],
];
for (const [trigger, viewTestId, urlSuffix] of destinations) {
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId(trigger).click();
await expect(page.getByTestId(viewTestId)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}${urlSuffix}$`));
}
});
test("header view-menu Tables sub-list navigates to every entity", async ({
page,
}) => {
await bootShell(page);
const entities = [
"planets",
"ship-classes",
"ship-groups",
"fleets",
"sciences",
"races",
];
for (const entity of entities) {
await page.getByTestId("view-menu-trigger").click();
await page
.getByTestId("view-menu-tables")
.locator("summary")
.click();
await page.getByTestId(`view-menu-item-table-${entity}`).click();
const view = page.getByTestId("active-view-table");
await expect(view).toBeVisible();
await expect(view).toHaveAttribute("data-entity", entity);
await expect(page).toHaveURL(
new RegExp(`/games/${GAME_ID}/table/${entity}$`),
);
}
});
test("sidebar tab choice survives navigation between active views", async ({
page,
browserName,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile") ||
testInfo.project.name === "webkit-desktop"
? false
: false,
"sidebar test runs on every project",
);
await bootShell(page);
// Skip on viewports below 1024 — sidebar is hidden by CSS there.
const viewport = page.viewportSize();
if (viewport === null || viewport.width < 1024) {
test.skip();
return;
}
void browserName;
await page.getByTestId("sidebar-tab-calculator").click();
await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible();
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId("view-menu-item-report").click();
await expect(page.getByTestId("active-view-report")).toBeVisible();
// Sidebar still rendered; the calculator tool remains selected.
await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible();
await expect(page.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"calculator",
);
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId("view-menu-item-map").click();
await expect(page.getByTestId("active-view-map")).toBeVisible();
await expect(page.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"calculator",
);
});
test("mobile bottom-tabs show on small viewports and toggle the tool overlay", async ({
page,
}, testInfo) => {
if (!testInfo.project.name.startsWith("chromium-mobile")) {
test.skip();
return;
}
await bootShell(page);
await expect(page.getByTestId("bottom-tabs")).toBeVisible();
await expect(page.getByTestId("sidebar")).not.toBeVisible();
await page.getByTestId("bottom-tab-calc").click();
await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible();
await page.getByTestId("bottom-tab-order").click();
await expect(page.getByTestId("sidebar-tool-order")).toBeVisible();
await page.getByTestId("bottom-tab-map").click();
await expect(page.getByTestId("active-view-map")).toBeVisible();
});
test("mobile More drawer navigates to every destination", async ({
page,
}, testInfo) => {
if (!testInfo.project.name.startsWith("chromium-mobile")) {
test.skip();
return;
}
await bootShell(page);
await page.getByTestId("bottom-tab-more").click();
await expect(page.getByTestId("bottom-tabs-more-drawer")).toBeVisible();
await page.getByTestId("bottom-tabs-more-mail").click();
await expect(page.getByTestId("active-view-mail")).toBeVisible();
await page.getByTestId("bottom-tab-more").click();
await page.getByTestId("bottom-tabs-more-report").click();
await expect(page.getByTestId("active-view-report")).toBeVisible();
});
test("breakpoint switches between desktop / tablet / mobile", async ({
page,
}, testInfo) => {
// Use a single chromium-desktop run to drive all three viewports in
// the same browser. Other projects skip — the viewport diff is the
// goal here, not browser-specific behaviour.
if (testInfo.project.name !== "chromium-desktop") {
test.skip();
return;
}
await bootShell(page);
// Desktop ≥ 1024: sidebar visible, bottom-tabs hidden, sidebar
// toggle hidden.
await page.setViewportSize({ width: 1280, height: 800 });
await expect(page.getByTestId("sidebar")).toBeVisible();
await expect(page.getByTestId("bottom-tabs")).not.toBeVisible();
await expect(page.getByTestId("sidebar-toggle")).not.toBeVisible();
// Tablet 7681024: sidebar hidden by default, sidebar toggle
// visible, bottom-tabs hidden. Click the toggle and the sidebar
// becomes visible again.
await page.setViewportSize({ width: 900, height: 800 });
await expect(page.getByTestId("sidebar")).not.toBeVisible();
await expect(page.getByTestId("sidebar-toggle")).toBeVisible();
await expect(page.getByTestId("bottom-tabs")).not.toBeVisible();
await page.getByTestId("sidebar-toggle").click();
await expect(page.getByTestId("sidebar")).toBeVisible();
// Mobile < 768: sidebar hidden entirely, bottom-tabs visible,
// sidebar toggle hidden again.
await page.setViewportSize({ width: 390, height: 800 });
await expect(page.getByTestId("bottom-tabs")).toBeVisible();
await expect(page.getByTestId("sidebar")).not.toBeVisible();
await expect(page.getByTestId("sidebar-toggle")).not.toBeVisible();
});
+140
View File
@@ -0,0 +1,140 @@
// Component tests for the Phase 10 in-game shell header. The header
// composes the static `race ?` placeholder, the placeholder
// turn-counter (Phase 11 wires the live source), the view-menu, and
// the account-menu. The tests assert the placeholder copy, that
// every view-menu entry dispatches `goto` with the right URL, and
// that the Logout entry of the account-menu calls
// `session.signOut("user")`.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte";
import Header from "../src/lib/header/header.svelte";
const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
vi.mock("$app/navigation", () => ({
goto: (...args: unknown[]) => gotoSpy(...args),
}));
beforeEach(() => {
i18n.resetForTests("en");
gotoSpy.mockReset();
vi.spyOn(session, "signOut").mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("game-shell header", () => {
test("renders the static race / turn placeholders and toggles", () => {
const onToggleSidebar = vi.fn();
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
});
expect(ui.getByTestId("race-name")).toHaveTextContent("race ?");
expect(ui.getByTestId("turn-counter").textContent ?? "").toMatch(
/turn\s+\?/,
);
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
});
test("clicking the sidebar toggle invokes the prop callback", async () => {
const onToggleSidebar = vi.fn();
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
});
await fireEvent.click(ui.getByTestId("sidebar-toggle"));
expect(onToggleSidebar).toHaveBeenCalledTimes(1);
});
test("view-menu navigates to every IA destination", async () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
});
const destinations: Array<[string, string]> = [
["view-menu-item-map", "/games/g1/map"],
["view-menu-item-report", "/games/g1/report"],
["view-menu-item-battle", "/games/g1/battle"],
["view-menu-item-mail", "/games/g1/mail"],
[
"view-menu-item-designer-ship-class",
"/games/g1/designer/ship-class",
],
[
"view-menu-item-designer-science",
"/games/g1/designer/science",
],
];
for (const [testId, href] of destinations) {
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
await fireEvent.click(ui.getByTestId(testId));
expect(gotoSpy).toHaveBeenLastCalledWith(href);
}
});
test("view-menu Tables sub-list navigates to every entity", async () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
});
const tableEntities: Array<[string, string]> = [
["view-menu-item-table-planets", "/games/g1/table/planets"],
[
"view-menu-item-table-ship-classes",
"/games/g1/table/ship-classes",
],
[
"view-menu-item-table-ship-groups",
"/games/g1/table/ship-groups",
],
["view-menu-item-table-fleets", "/games/g1/table/fleets"],
["view-menu-item-table-sciences", "/games/g1/table/sciences"],
["view-menu-item-table-races", "/games/g1/table/races"],
];
for (const [testId, href] of tableEntities) {
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
// Open the Tables sub-disclosure each iteration; the menu
// closes on every navigation.
const summary = ui
.getByTestId("view-menu-tables")
.querySelector("summary");
if (summary !== null) {
await fireEvent.click(summary);
}
await fireEvent.click(ui.getByTestId(testId));
expect(gotoSpy).toHaveBeenLastCalledWith(href);
}
});
test("account-menu Logout triggers session.signOut('user')", async () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
});
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
await fireEvent.click(ui.getByTestId("account-menu-logout"));
expect(session.signOut).toHaveBeenCalledWith("user");
});
test("account-menu language picker switches the i18n locale", async () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
});
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
const select = ui.getByTestId("account-menu-language-select");
await fireEvent.change(select, { target: { value: "ru" } });
expect(i18n.locale).toBe("ru");
});
});
@@ -0,0 +1,98 @@
// Component tests for the Phase 10 in-game shell sidebar. Validates
// the default selected tab, the Calculator / Inspector / Order
// switching, the empty-state copy that matches the IA section, and
// the `?sidebar=` URL seed convention used by the mobile bottom-tabs.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
import {
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
const pageMock = vi.hoisted(() => ({
url: new URL("http://localhost/games/g1/map"),
params: { id: "g1" } as Record<string, string>,
}));
vi.mock("$app/state", () => ({
page: pageMock,
}));
import Sidebar from "../src/lib/sidebar/sidebar.svelte";
beforeEach(() => {
i18n.resetForTests("en");
pageMock.url = new URL("http://localhost/games/g1/map");
});
describe("game-shell sidebar", () => {
test("renders the inspector tab content by default", () => {
const ui = render(Sidebar, {
props: { open: false, onClose: () => {} },
});
expect(ui.getByTestId("sidebar-tool-inspector")).toBeInTheDocument();
expect(ui.getByTestId("sidebar-tool-inspector")).toHaveTextContent(
"select an object on the map",
);
expect(ui.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"inspector",
);
});
test("switching tabs updates the rendered tool", async () => {
const ui = render(Sidebar, {
props: { open: false, onClose: () => {} },
});
await fireEvent.click(ui.getByTestId("sidebar-tab-calculator"));
expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument();
expect(ui.queryByTestId("sidebar-tool-inspector")).toBeNull();
expect(ui.queryByTestId("sidebar-tool-order")).toBeNull();
await fireEvent.click(ui.getByTestId("sidebar-tab-order"));
expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument();
expect(ui.queryByTestId("sidebar-tool-calculator")).toBeNull();
});
test("empty-state copy matches the IA section verbatim", () => {
const ui = render(Sidebar, {
props: { open: false, onClose: () => {} },
});
expect(ui.getByTestId("sidebar-tool-inspector")).toHaveTextContent(
"select an object on the map",
);
});
test("?sidebar=calc seeds the calculator tab on first mount", () => {
pageMock.url = new URL("http://localhost/games/g1/map?sidebar=calc");
const ui = render(Sidebar, {
props: { open: false, onClose: () => {} },
});
expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument();
expect(ui.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"calculator",
);
});
test("?sidebar=order seeds the order tab on first mount", () => {
pageMock.url = new URL("http://localhost/games/g1/map?sidebar=order");
const ui = render(Sidebar, {
props: { open: false, onClose: () => {} },
});
expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument();
});
test("close button calls the onClose prop", async () => {
const onClose = vi.fn();
const ui = render(Sidebar, { props: { open: true, onClose } });
await fireEvent.click(ui.getByTestId("sidebar-close"));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,83 @@
// Component tests for every Phase 10 active-view stub. Each stub
// renders the localised view title plus the `coming soon` body copy
// and exposes a stable `data-testid` so later phases can replace the
// content without renaming the test hook. The table stub additionally
// honours its `entity` prop and falls back to the snake_case i18n key
// for an unknown slug.
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import MapView from "../src/lib/active-view/map.svelte";
import TableView from "../src/lib/active-view/table.svelte";
import ReportView from "../src/lib/active-view/report.svelte";
import BattleView from "../src/lib/active-view/battle.svelte";
import MailView from "../src/lib/active-view/mail.svelte";
import DesignerShipClass from "../src/lib/active-view/designer-ship-class.svelte";
import DesignerScience from "../src/lib/active-view/designer-science.svelte";
beforeEach(() => {
i18n.resetForTests("en");
});
describe("active-view stubs", () => {
test("map stub renders title and coming-soon copy", () => {
const ui = render(MapView);
const node = ui.getByTestId("active-view-map");
expect(node).toHaveTextContent("map");
expect(node).toHaveTextContent("coming soon");
});
test("table stub maps a kebab-case entity to the right i18n title", () => {
const ui = render(TableView, { props: { entity: "ship-classes" } });
const node = ui.getByTestId("active-view-table");
expect(node).toHaveAttribute("data-entity", "ship-classes");
expect(node).toHaveTextContent("ship classes");
expect(node).toHaveTextContent("coming soon");
});
test("table stub also handles a single-word entity", () => {
const ui = render(TableView, { props: { entity: "planets" } });
expect(ui.getByTestId("active-view-table")).toHaveTextContent("planets");
});
test("report / mail / designer stubs render their localised titles", () => {
const r = render(ReportView);
expect(r.getByTestId("active-view-report")).toHaveTextContent(
"turn report",
);
const m = render(MailView);
expect(m.getByTestId("active-view-mail")).toHaveTextContent(
"diplomatic mail",
);
const sc = render(DesignerShipClass);
expect(
sc.getByTestId("active-view-designer-ship-class"),
).toHaveTextContent("ship-class designer");
const sci = render(DesignerScience);
expect(
sci.getByTestId("active-view-designer-science"),
).toHaveTextContent("science designer");
});
test("battle stub stamps the battleId on the host element", () => {
const ui = render(BattleView, { props: { battleId: "b-42" } });
const node = ui.getByTestId("active-view-battle");
expect(node).toHaveAttribute("data-battle-id", "b-42");
expect(node).toHaveTextContent("battle log");
});
test("battle stub accepts an empty battleId for the list URL", () => {
const ui = render(BattleView, { props: { battleId: "" } });
expect(ui.getByTestId("active-view-battle")).toHaveAttribute(
"data-battle-id",
"",
);
});
});