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