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,30 @@
<!--
Phase 10 stub for the battle-log active view. Phase 27 wires the real
battle viewer.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
type Props = { battleId: string };
let { battleId }: Props = $props();
</script>
<section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}>
<h2>{i18n.t("game.view.battle")}</h2>
<p>{i18n.t("game.shell.coming_soon")}</p>
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.active-view p {
margin: 0;
color: #555;
}
</style>
@@ -0,0 +1,28 @@
<!--
Phase 10 stub for the science designer active view. Phase 21 wires
the CRUD list and designer. The optional `scienceId` URL segment is
accepted but ignored at this point.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
</script>
<section class="active-view" data-testid="active-view-designer-science">
<h2>{i18n.t("game.view.designer.science")}</h2>
<p>{i18n.t("game.shell.coming_soon")}</p>
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.active-view p {
margin: 0;
color: #555;
}
</style>
@@ -0,0 +1,28 @@
<!--
Phase 10 stub for the ship-class designer active view. Phase 17 wires
the CRUD list and Phase 18 the calc bridge. The optional `classId`
URL segment is accepted but ignored at this point.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
</script>
<section class="active-view" data-testid="active-view-designer-ship-class">
<h2>{i18n.t("game.view.designer.ship_class")}</h2>
<p>{i18n.t("game.shell.coming_soon")}</p>
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.active-view p {
margin: 0;
color: #555;
}
</style>
@@ -0,0 +1,27 @@
<!--
Phase 10 stub for the diplomatic-mail active view. Phase 28 wires the
real mail listing.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
</script>
<section class="active-view" data-testid="active-view-mail">
<h2>{i18n.t("game.view.mail")}</h2>
<p>{i18n.t("game.shell.coming_soon")}</p>
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.active-view p {
margin: 0;
color: #555;
}
</style>
@@ -0,0 +1,29 @@
<!--
Phase 10 stub for the map active view. Phase 11 swaps this for the
live renderer integration described in `ui/PLAN.md` Phase 11. The
stub keeps the same `data-testid` so Phase 11's spec replaces the
copy assertion without touching navigation.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
</script>
<section class="active-view" data-testid="active-view-map">
<h2>{i18n.t("game.view.map")}</h2>
<p>{i18n.t("game.shell.coming_soon")}</p>
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.active-view p {
margin: 0;
color: #555;
}
</style>
@@ -0,0 +1,28 @@
<!--
Phase 10 stub for the turn-report active view. Phase 23 replaces the
body with the per-turn sections (cargo deliveries, completed sciences,
mail, etc.).
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
</script>
<section class="active-view" data-testid="active-view-report">
<h2>{i18n.t("game.view.report")}</h2>
<p>{i18n.t("game.shell.coming_soon")}</p>
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.active-view p {
margin: 0;
color: #555;
}
</style>
@@ -0,0 +1,39 @@
<!--
Phase 10 stub for the entity-table active view. Phase 11+ wires real
list data per entity (planets in Phase 11, ship-classes in Phase 17,
etc.). Until then, the stub renders the localised entity title plus a
`coming soon` body so navigation can be exercised end-to-end.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
type Props = { entity: string };
let { entity }: Props = $props();
function entityKey(slug: string): TranslationKey {
const normalised = slug.replace(/-/g, "_");
return `game.view.table.${normalised}` as TranslationKey;
}
</script>
<section class="active-view" data-testid="active-view-table" data-entity={entity}>
<h2>
{i18n.t("game.view.table")}: {i18n.t(entityKey(entity))}
</h2>
<p>{i18n.t("game.shell.coming_soon")}</p>
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.active-view p {
margin: 0;
color: #555;
}
</style>
@@ -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>
+97
View File
@@ -0,0 +1,97 @@
<!--
Top header for the in-game shell. Composes the four artifacts called
out by `ui/PLAN.md` Phase 10: race name (static placeholder), turn
counter (static placeholder), view dropdown / hamburger, account
menu. The sidebar-toggle slot to its left appears only on tablet
viewports (7681024 px) and is wired by `+layout.svelte`.
The connection-state indicator from the IA section is intentionally
absent until Phase 24 wires push-event state.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import TurnCounter from "./turn-counter.svelte";
import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte";
type Props = {
gameId: string;
sidebarOpen: boolean;
onToggleSidebar: () => void;
};
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
</script>
<header class="game-shell-header" data-testid="game-shell-header">
<div class="left">
<span class="race" data-testid="race-name">
{i18n.t("game.shell.race_placeholder")}
</span>
<TurnCounter />
</div>
<div class="right">
<button
type="button"
class="sidebar-toggle"
data-testid="sidebar-toggle"
aria-expanded={sidebarOpen}
aria-label={sidebarOpen
? i18n.t("game.shell.menu.close_sidebar")
: i18n.t("game.shell.menu.toggle_sidebar")}
onclick={onToggleSidebar}
>
</button>
<ViewMenu {gameId} />
<AccountMenu />
</div>
</header>
<style>
.game-shell-header {
position: sticky;
top: 0;
z-index: 40;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.5rem 0.75rem;
background: #0a0e1a;
color: #e8eaf6;
border-bottom: 1px solid #20253a;
font-family: system-ui, sans-serif;
}
.left,
.right {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.race {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-toggle {
font: inherit;
font-size: 1.1rem;
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid #2a3150;
border-radius: 4px;
cursor: pointer;
display: none;
}
.sidebar-toggle:hover {
background: #1c2238;
}
@media (min-width: 768px) and (max-width: 1023.98px) {
.sidebar-toggle {
display: inline-flex;
}
}
</style>
@@ -0,0 +1,21 @@
<!--
Phase 10 placeholder turn counter. The displayed value is the static
`?` glyph from `game.shell.turn_unknown`; Phase 11 swaps the source
to the live game state. The wrapping span is kept as the public
shape so Phase 11 only needs to replace the inner text.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
</script>
<span class="turn" data-testid="turn-counter">
{i18n.t("game.shell.turn_label")}&nbsp;{i18n.t("game.shell.turn_unknown")}
</span>
<style>
.turn {
font-size: 0.95rem;
color: #ddd;
white-space: nowrap;
}
</style>
+254
View File
@@ -0,0 +1,254 @@
<!--
Active-view picker that drives navigation between every in-game view.
The trigger swaps icon between desktop dropdown (▾) and mobile
hamburger (☰) at the 1024 px breakpoint via CSS only; the surface
itself is identical. The same component is reused for the mobile
"More" drawer entry of `bottom-tabs.svelte`.
Lists the seven IA destinations: map, tables (sub-list of six
entities), report, battle, mail, ship-class designer, science
designer. Closes on Escape, on outside click, and after a
navigation. Phase 26 introduces the history-mode entry; Phase 35
polishes microcopy.
-->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
type Props = { gameId: string };
let { gameId }: Props = $props();
let open = $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" },
];
function toggleOpen(): void {
open = !open;
}
function go(path: string): void {
open = false;
void goto(path);
}
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="view-menu" bind:this={rootEl}>
<button
type="button"
class="trigger"
data-testid="view-menu-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={open
? i18n.t("game.shell.menu.close_views")
: i18n.t("game.shell.menu.open_views")}
onclick={toggleOpen}
>
<span class="icon-dropdown" aria-hidden="true"></span>
<span class="icon-hamburger" aria-hidden="true"></span>
</button>
{#if open}
<div class="surface" role="menu" data-testid="view-menu-list">
<button
type="button"
role="menuitem"
data-testid="view-menu-item-map"
onclick={() => go(`/games/${gameId}/map`)}
>
{i18n.t("game.view.map")}
</button>
<details data-testid="view-menu-tables">
<summary>{i18n.t("game.view.table")}</summary>
<div class="sub">
{#each tableEntities as entry (entry.slug)}
<button
type="button"
role="menuitem"
data-testid="view-menu-item-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="view-menu-item-report"
onclick={() => go(`/games/${gameId}/report`)}
>
{i18n.t("game.view.report")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-battle"
onclick={() => go(`/games/${gameId}/battle`)}
>
{i18n.t("game.view.battle")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-mail"
onclick={() => go(`/games/${gameId}/mail`)}
>
{i18n.t("game.view.mail")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-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="view-menu-item-designer-science"
onclick={() => go(`/games/${gameId}/designer/science`)}
>
{i18n.t("game.view.designer.science")}
</button>
</div>
{/if}
</div>
<style>
.view-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;
}
.icon-dropdown {
display: inline;
}
.icon-hamburger {
display: none;
}
@media (max-width: 767.98px) {
.icon-dropdown {
display: none;
}
.icon-hamburger {
display: inline;
}
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 14rem;
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 > details > summary {
text-align: left;
font: inherit;
padding: 0.45rem 0.75rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.surface > button:hover,
.surface > details > summary:hover {
background: #1c2238;
}
.surface > details > summary {
list-style: none;
}
.surface > details > summary::-webkit-details-marker {
display: none;
}
.surface > details > summary::after {
content: "▸";
float: right;
opacity: 0.7;
}
.surface > 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.35rem 0.5rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.sub > button:hover {
background: #1c2238;
}
@media (max-width: 767.98px) {
.surface {
position: fixed;
top: 3rem;
right: 0;
left: 0;
min-width: 0;
border-radius: 0;
border-left: 0;
border-right: 0;
max-height: calc(100vh - 3rem);
overflow-y: auto;
}
}
</style>
+41
View File
@@ -82,6 +82,47 @@ const en = {
"lobby.error.conflict": "request conflicts with current state",
"lobby.error.internal_error": "internal server error",
"lobby.error.unknown": "{message}",
"game.shell.race_placeholder": "race ?",
"game.shell.turn_label": "turn",
"game.shell.turn_unknown": "?",
"game.shell.connection.online": "online",
"game.shell.connection.reconnecting": "reconnecting…",
"game.shell.connection.offline": "offline",
"game.shell.menu.toggle_sidebar": "open sidebar",
"game.shell.menu.close_sidebar": "close sidebar",
"game.shell.menu.open_views": "open views menu",
"game.shell.menu.close_views": "close views menu",
"game.shell.menu.account": "account",
"game.shell.menu.settings": "settings",
"game.shell.menu.sessions": "sessions",
"game.shell.menu.theme": "theme",
"game.shell.menu.language": "language",
"game.shell.menu.logout": "logout",
"game.shell.coming_soon": "coming soon",
"game.view.map": "map",
"game.view.table": "table",
"game.view.table.planets": "planets",
"game.view.table.ship_classes": "ship classes",
"game.view.table.ship_groups": "ship groups",
"game.view.table.fleets": "fleets",
"game.view.table.sciences": "sciences",
"game.view.table.races": "races",
"game.view.report": "turn report",
"game.view.battle": "battle log",
"game.view.mail": "diplomatic mail",
"game.view.designer.ship_class": "ship-class designer",
"game.view.designer.science": "science designer",
"game.sidebar.tab.calculator": "calculator",
"game.sidebar.tab.inspector": "inspector",
"game.sidebar.tab.order": "order",
"game.sidebar.empty.calculator": "coming soon",
"game.sidebar.empty.inspector": "select an object on the map",
"game.sidebar.empty.order": "coming soon",
"game.bottom_tabs.map": "map",
"game.bottom_tabs.calc": "calc",
"game.bottom_tabs.order": "order",
"game.bottom_tabs.more": "more",
} as const;
export default en;
+41
View File
@@ -83,6 +83,47 @@ const ru: Record<keyof typeof en, string> = {
"lobby.error.conflict": "запрос конфликтует с текущим состоянием",
"lobby.error.internal_error": "внутренняя ошибка сервера",
"lobby.error.unknown": "{message}",
"game.shell.race_placeholder": "раса ?",
"game.shell.turn_label": "ход",
"game.shell.turn_unknown": "?",
"game.shell.connection.online": "онлайн",
"game.shell.connection.reconnecting": "переподключение…",
"game.shell.connection.offline": "офлайн",
"game.shell.menu.toggle_sidebar": "открыть боковую панель",
"game.shell.menu.close_sidebar": "закрыть боковую панель",
"game.shell.menu.open_views": "открыть меню видов",
"game.shell.menu.close_views": "закрыть меню видов",
"game.shell.menu.account": "аккаунт",
"game.shell.menu.settings": "настройки",
"game.shell.menu.sessions": "сессии",
"game.shell.menu.theme": "тема",
"game.shell.menu.language": "язык",
"game.shell.menu.logout": "выйти",
"game.shell.coming_soon": "скоро будет",
"game.view.map": "карта",
"game.view.table": "таблица",
"game.view.table.planets": "планеты",
"game.view.table.ship_classes": "классы кораблей",
"game.view.table.ship_groups": "группы кораблей",
"game.view.table.fleets": "флоты",
"game.view.table.sciences": "науки",
"game.view.table.races": "расы",
"game.view.report": "отчёт хода",
"game.view.battle": "журнал боёв",
"game.view.mail": "дипломатическая почта",
"game.view.designer.ship_class": "конструктор класса кораблей",
"game.view.designer.science": "редактор наук",
"game.sidebar.tab.calculator": "калькулятор",
"game.sidebar.tab.inspector": "инспектор",
"game.sidebar.tab.order": "приказ",
"game.sidebar.empty.calculator": "скоро будет",
"game.sidebar.empty.inspector": "выберите объект на карте",
"game.sidebar.empty.order": "скоро будет",
"game.bottom_tabs.map": "карта",
"game.bottom_tabs.calc": "калк",
"game.bottom_tabs.order": "приказ",
"game.bottom_tabs.more": "ещё",
};
export default ru;
@@ -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";