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,159 @@
<!--
Account-menu popover with Account / Settings / Sessions / Theme /
Language / Logout. Phase 10 only wires Language (via the existing
i18n primitive) and Logout (`session.signOut("user")`); the rest are
stub buttons that later phases (35 polish, dedicated phases for
Sessions and Theme) take over.
-->
<script lang="ts">
import { onMount } from "svelte";
import { i18n, SUPPORTED_LOCALES, type Locale } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
function toggleOpen(): void {
open = !open;
}
async function logout(): Promise<void> {
open = false;
await session.signOut("user");
}
function selectLocale(event: Event): void {
const value = (event.target as HTMLSelectElement).value as Locale;
i18n.setLocale(value);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
}
}
onMount(() => {
const handleClick = (event: MouseEvent): void => {
if (!open || rootEl === null) return;
const target = event.target;
if (target instanceof Node && rootEl.contains(target)) return;
open = false;
};
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", onKeyDown);
};
});
</script>
<div class="account-menu" bind:this={rootEl}>
<button
type="button"
class="trigger"
data-testid="account-menu-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={i18n.t("game.shell.menu.account")}
onclick={toggleOpen}
>
</button>
{#if open}
<div class="surface" role="menu" data-testid="account-menu-list">
<button type="button" role="menuitem" data-testid="account-menu-settings" disabled>
{i18n.t("game.shell.menu.settings")}
</button>
<button type="button" role="menuitem" data-testid="account-menu-sessions" disabled>
{i18n.t("game.shell.menu.sessions")}
</button>
<button type="button" role="menuitem" data-testid="account-menu-theme" disabled>
{i18n.t("game.shell.menu.theme")}
</button>
<label class="locale" data-testid="account-menu-language">
<span>{i18n.t("game.shell.menu.language")}</span>
<select
data-testid="account-menu-language-select"
value={i18n.locale}
onchange={selectLocale}
>
{#each SUPPORTED_LOCALES as entry (entry.code)}
<option value={entry.code}>{entry.nativeName}</option>
{/each}
</select>
</label>
<button
type="button"
role="menuitem"
data-testid="account-menu-logout"
onclick={logout}
>
{i18n.t("game.shell.menu.logout")}
</button>
</div>
{/if}
</div>
<style>
.account-menu {
position: relative;
}
.trigger {
font: inherit;
font-size: 1.1rem;
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid #2a3150;
border-radius: 4px;
cursor: pointer;
}
.trigger:hover {
background: #1c2238;
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 12rem;
display: flex;
flex-direction: column;
background: #14182a;
border: 1px solid #2a3150;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
z-index: 50;
}
.surface > button,
.surface > label {
text-align: left;
font: inherit;
padding: 0.45rem 0.75rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.surface > button:hover:not(:disabled) {
background: #1c2238;
}
.surface > button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.locale {
display: flex;
align-items: center;
gap: 0.5rem;
}
.locale select {
font: inherit;
background: #1c2238;
color: inherit;
border: 1px solid #2a3150;
border-radius: 4px;
padding: 0.15rem 0.35rem;
}
</style>