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,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>
|
||||
@@ -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 (768–1024 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")} {i18n.t("game.shell.turn_unknown")}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.turn {
|
||||
font-size: 0.95rem;
|
||||
color: #ddd;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user