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
@@ -0,0 +1,297 @@
<!--
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.
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.
-->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import type { MobileTool } from "./types";
type Props = {
gameId: string;
activeTool: MobileTool;
onSelectTool: (tool: MobileTool) => void;
};
let { gameId, activeTool, onSelectTool }: Props = $props();
let moreOpen = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
const tableEntities: ReadonlyArray<{ slug: string; key: TranslationKey }> = [
{ slug: "planets", key: "game.view.table.planets" },
{ slug: "ship-classes", key: "game.view.table.ship_classes" },
{ slug: "ship-groups", key: "game.view.table.ship_groups" },
{ slug: "fleets", key: "game.view.table.fleets" },
{ slug: "sciences", key: "game.view.table.sciences" },
{ slug: "races", key: "game.view.table.races" },
];
async function selectTool(tool: MobileTool): Promise<void> {
moreOpen = false;
onSelectTool(tool);
await goto(`/games/${gameId}/map`);
}
async function go(path: string): Promise<void> {
moreOpen = false;
onSelectTool("map");
await goto(path);
}
function toggleMore(): void {
moreOpen = !moreOpen;
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && moreOpen) {
moreOpen = false;
}
}
onMount(() => {
const handleClick = (event: MouseEvent): void => {
if (!moreOpen || rootEl === null) return;
const target = event.target;
if (target instanceof Node && rootEl.contains(target)) return;
moreOpen = false;
};
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", onKeyDown);
};
});
</script>
<div class="bottom-tabs" data-testid="bottom-tabs" bind:this={rootEl}>
<div class="tabs" role="tablist" aria-label={i18n.t("game.bottom_tabs.map")}>
<button
type="button"
role="tab"
data-testid="bottom-tab-map"
aria-selected={activeTool === "map"}
class:active={activeTool === "map"}
onclick={() => selectTool("map")}
>
<span class="icon" aria-hidden="true"></span>
<span class="label">{i18n.t("game.bottom_tabs.map")}</span>
</button>
<button
type="button"
role="tab"
data-testid="bottom-tab-calc"
aria-selected={activeTool === "calc"}
class:active={activeTool === "calc"}
onclick={() => selectTool("calc")}
>
<span class="icon" aria-hidden="true">🧮</span>
<span class="label">{i18n.t("game.bottom_tabs.calc")}</span>
</button>
<button
type="button"
role="tab"
data-testid="bottom-tab-order"
aria-selected={activeTool === "order"}
class:active={activeTool === "order"}
onclick={() => selectTool("order")}
>
<span class="icon" aria-hidden="true">📝</span>
<span class="label">{i18n.t("game.bottom_tabs.order")}</span>
</button>
<button
type="button"
data-testid="bottom-tab-more"
aria-haspopup="menu"
aria-expanded={moreOpen}
class:active={moreOpen}
onclick={toggleMore}
>
<span class="icon" aria-hidden="true"></span>
<span class="label">{i18n.t("game.bottom_tabs.more")}</span>
</button>
</div>
{#if moreOpen}
<div class="drawer" role="menu" data-testid="bottom-tabs-more-drawer">
<button
type="button"
role="menuitem"
data-testid="bottom-tabs-more-map"
onclick={() => go(`/games/${gameId}/map`)}
>
{i18n.t("game.view.map")}
</button>
<details data-testid="bottom-tabs-more-tables">
<summary>{i18n.t("game.view.table")}</summary>
<div class="sub">
{#each tableEntities as entry (entry.slug)}
<button
type="button"
role="menuitem"
data-testid="bottom-tabs-more-table-{entry.slug}"
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)}
>
{i18n.t(entry.key)}
</button>
{/each}
</div>
</details>
<button
type="button"
role="menuitem"
data-testid="bottom-tabs-more-report"
onclick={() => go(`/games/${gameId}/report`)}
>
{i18n.t("game.view.report")}
</button>
<button
type="button"
role="menuitem"
data-testid="bottom-tabs-more-battle"
onclick={() => go(`/games/${gameId}/battle`)}
>
{i18n.t("game.view.battle")}
</button>
<button
type="button"
role="menuitem"
data-testid="bottom-tabs-more-mail"
onclick={() => go(`/games/${gameId}/mail`)}
>
{i18n.t("game.view.mail")}
</button>
<button
type="button"
role="menuitem"
data-testid="bottom-tabs-more-designer-ship-class"
onclick={() => go(`/games/${gameId}/designer/ship-class`)}
>
{i18n.t("game.view.designer.ship_class")}
</button>
<button
type="button"
role="menuitem"
data-testid="bottom-tabs-more-designer-science"
onclick={() => go(`/games/${gameId}/designer/science`)}
>
{i18n.t("game.view.designer.science")}
</button>
</div>
{/if}
</div>
<style>
.bottom-tabs {
display: none;
position: relative;
}
@media (max-width: 767.98px) {
.bottom-tabs {
display: block;
}
}
.tabs {
display: flex;
justify-content: space-around;
align-items: stretch;
background: #0a0e1a;
color: #e8eaf6;
border-top: 1px solid #20253a;
font-family: system-ui, sans-serif;
}
.tabs button {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.4rem 0.25rem;
font: inherit;
font-size: 0.75rem;
background: transparent;
color: #aab;
border: 0;
cursor: pointer;
}
.tabs button.active {
color: #e8eaf6;
background: #1c2238;
}
.tabs .icon {
font-size: 1.25rem;
}
.drawer {
position: fixed;
left: 0;
right: 0;
bottom: 3.25rem;
max-height: calc(100vh - 6rem);
overflow-y: auto;
display: flex;
flex-direction: column;
background: #14182a;
color: #e8eaf6;
border-top: 1px solid #2a3150;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
z-index: 50;
font-family: system-ui, sans-serif;
}
.drawer > button,
.drawer > details > summary {
text-align: left;
font: inherit;
padding: 0.55rem 0.9rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.drawer > button:hover,
.drawer > details > summary:hover {
background: #1c2238;
}
.drawer > details > summary {
list-style: none;
}
.drawer > details > summary::-webkit-details-marker {
display: none;
}
.drawer > details > summary::after {
content: "▸";
float: right;
opacity: 0.7;
}
.drawer > details[open] > summary::after {
content: "▾";
}
.sub {
display: flex;
flex-direction: column;
padding-left: 0.5rem;
border-left: 1px solid #2a3150;
margin: 0 0.5rem 0.25rem;
}
.sub > button {
text-align: left;
font: inherit;
padding: 0.4rem 0.5rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.sub > button:hover {
background: #1c2238;
}
</style>
@@ -0,0 +1,29 @@
<!--
Phase 10 stub for the Calculator sidebar tool. Phase 30 wires the
real ship/path calculator. Until then the stub renders a localised
`coming soon` paragraph with a stable testid that later phases can
replace without touching navigation.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
</script>
<section class="tool" data-testid="sidebar-tool-calculator">
<h3>{i18n.t("game.sidebar.tab.calculator")}</h3>
<p>{i18n.t("game.sidebar.empty.calculator")}</p>
</section>
<style>
.tool {
padding: 1rem;
font-family: system-ui, sans-serif;
}
.tool h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.tool p {
margin: 0;
color: #888;
}
</style>
@@ -0,0 +1,29 @@
<!--
Phase 10 stub for the Inspector sidebar tool. The empty-state copy
matches the IA section verbatim — `select an object on the map` —
so the user understands the intended interaction before Phase 13
wires real planet selection.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
</script>
<section class="tool" data-testid="sidebar-tool-inspector">
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
<p>{i18n.t("game.sidebar.empty.inspector")}</p>
</section>
<style>
.tool {
padding: 1rem;
font-family: system-ui, sans-serif;
}
.tool h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.tool p {
margin: 0;
color: #888;
}
</style>
@@ -0,0 +1,27 @@
<!--
Phase 10 stub for the Order composer sidebar tool. Phase 12 ships
the composer skeleton; Phase 14 lands the first end-to-end command.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
</script>
<section class="tool" data-testid="sidebar-tool-order">
<h3>{i18n.t("game.sidebar.tab.order")}</h3>
<p>{i18n.t("game.sidebar.empty.order")}</p>
</section>
<style>
.tool {
padding: 1rem;
font-family: system-ui, sans-serif;
}
.tool h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.tool p {
margin: 0;
color: #888;
}
</style>
+130
View File
@@ -0,0 +1,130 @@
<!--
Phase 10 sidebar with three tabs (Calculator, Inspector, Order). The
parent layout 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.
-->
<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";
import Order from "./order-tab.svelte";
import type { SidebarTab } from "./types";
import { i18n } from "$lib/i18n/index.svelte";
type Props = {
open: boolean;
onClose: () => void;
};
let { open, onClose }: Props = $props();
let activeTab: SidebarTab = $state("inspector");
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;
}
onMount(() => {
const seed = readUrlSeed();
if (seed !== null) {
activeTab = seed;
}
});
</script>
<aside
class="sidebar"
data-testid="sidebar"
data-active-tab={activeTab}
data-open={open}
>
<div class="head">
<TabBar {activeTab} onSelect={(tab) => (activeTab = tab)} />
<button
type="button"
class="close"
data-testid="sidebar-close"
aria-label={i18n.t("game.shell.menu.close_sidebar")}
onclick={onClose}
>
</button>
</div>
<div class="content" data-testid="sidebar-content">
{#if activeTab === "calculator"}
<Calculator />
{:else if activeTab === "inspector"}
<Inspector />
{:else}
<Order />
{/if}
</div>
</aside>
<style>
.sidebar {
display: flex;
flex-direction: column;
width: 18rem;
min-width: 18rem;
background: #0e1322;
color: #e8eaf6;
border-left: 1px solid #20253a;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.close {
display: none;
font: inherit;
font-size: 1rem;
padding: 0.25rem 0.5rem;
margin-right: 0.5rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.close:hover {
color: #6d8cff;
}
.content {
flex: 1;
overflow-y: auto;
}
@media (max-width: 1023.98px) {
.sidebar {
display: none;
}
}
@media (min-width: 768px) and (max-width: 1023.98px) {
.sidebar[data-open="true"] {
display: flex;
position: fixed;
top: 3rem;
right: 0;
bottom: 0;
width: min(20rem, 80vw);
z-index: 30;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.4);
}
.sidebar[data-open="true"] .close {
display: inline-flex;
}
}
</style>
@@ -0,0 +1,64 @@
<!--
Three-button tab switcher for the Phase 10 sidebar. Each button is
labelled and tagged so component tests can target it; the parent
sidebar component owns the selected-tab state and re-renders the
matching tool panel.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import type { SidebarTab } from "./types";
type Props = {
activeTab: SidebarTab;
onSelect: (tab: SidebarTab) => void;
};
let { activeTab, onSelect }: Props = $props();
const tabs: ReadonlyArray<{ id: SidebarTab; key: TranslationKey }> = [
{ id: "calculator", key: "game.sidebar.tab.calculator" },
{ id: "inspector", key: "game.sidebar.tab.inspector" },
{ id: "order", key: "game.sidebar.tab.order" },
];
</script>
<div class="tab-bar" role="tablist" data-testid="sidebar-tab-bar">
{#each tabs as tab (tab.id)}
<button
type="button"
role="tab"
data-testid="sidebar-tab-{tab.id}"
aria-selected={tab.id === activeTab}
class:active={tab.id === activeTab}
onclick={() => onSelect(tab.id)}
>
{i18n.t(tab.key)}
</button>
{/each}
</div>
<style>
.tab-bar {
display: flex;
gap: 0.25rem;
padding: 0.5rem 0.5rem 0;
border-bottom: 1px solid #20253a;
font-family: system-ui, sans-serif;
}
.tab-bar button {
font: inherit;
font-size: 0.9rem;
padding: 0.4rem 0.75rem;
background: transparent;
color: #aab;
border: 0;
border-bottom: 2px solid transparent;
cursor: pointer;
}
.tab-bar button.active {
color: #e8eaf6;
border-bottom-color: #6d8cff;
}
.tab-bar button:hover:not(.active) {
color: #e8eaf6;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
// Shared types for the in-game sidebar and the mobile bottom-tabs.
// Kept as plain TypeScript (instead of a Svelte module export) so
// every consumer — components, layout, and tests — imports them
// through the same path without relying on Svelte tooling for
// type-only re-exports.
export type SidebarTab = "calculator" | "inspector" | "order";
export type MobileTool = "map" | "calc" | "order";