ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
36 changed files with 2337 additions and 29 deletions
Showing only changes of commit fc371c7fe1 - Show all commits
+114 -29
View File
@@ -1032,44 +1032,129 @@ Goal: assemble the in-game layout shell (header, sidebar, main area)
with empty placeholder content for every view, so navigation works
end-to-end before any data is wired.
Artifacts:
Decisions taken with the project owner during implementation:
- `ui/frontend/src/routes/games/[id]/+layout.svelte` shell layout with
responsive breakpoints (desktop / tablet / mobile)
- `ui/frontend/src/lib/header/` header component: race name, turn
counter (static placeholder `turn ?`), view dropdown / hamburger,
account menu
- `ui/frontend/src/lib/sidebar/` sidebar with three tabs (Calculator,
Inspector, Order), each tab content stubbed to `coming soon`; mobile
bottom-tab bar `[Map, Calc, Order, More]` with corresponding stub
panels
- `ui/frontend/src/lib/active-view/` view router supporting
`/games/:id/{map,table/:entity,report,battle/:battleId,mail,
designer/...}` with stub content per view
- topic doc `ui/docs/navigation.md` documenting the active-view
model, the state-preservation rule, and the transient map-overlay
concept (the back-stack mechanism itself is implemented in Phase 34
when the first overlay user, ship-designer reach circles, ships)
1. **Routing — file-system based, no extra dispatcher.** The
"view router" called out in the original artifact list is
implemented as SvelteKit's file-system routes plus thin
`+page.svelte` wrappers that mount the matching
`lib/active-view/<name>.svelte` stub. No separate dispatch
component lives in the codebase; each route file is a two-line
wrapper.
2. **Optional designer ID segments.** Both designer URLs ship as
`[[id]]` optional segments
(`designer/ship-class/[[classId]]/`,
`designer/science/[[scienceId]]/`) so Phase 18 / 21 can read
the param without a routing migration. Phase 10 stubs ignore
the param.
3. **Battle URL — optional id.** `battle/[[battleId]]/` accepts
both the list URL (`/battle`) and a specific battle URL
(`/battle/<id>`). Phase 27 keeps the optional segment and
switches behaviour based on presence.
4. **Tablet sidebar — click toggle, not swipe.** The 7681024 px
tablet sidebar slides in from a header-button click rather
than the IA section's swipe-from-right gesture. The structural
breakpoint switch satisfies Phase 10's acceptance criterion;
Phase 35 polish lands the swipe gesture.
5. **Mobile tool overlay — `mobileTool` state, gated by URL.**
The mobile bottom-tabs Calc / Order navigate to `/map` and
set a layout-owned `mobileTool` rune. The layout's derived
`effectiveTool` only honours the rune when the URL is `/map`,
so navigating to any other view via the More drawer or the
header view-menu naturally drops the overlay. The desktop
sidebar separately accepts a `?sidebar=calc|inspector|order`
URL param that seeds the initial tab on first mount, used by
later phases that want to land directly on a particular tool.
6. **Sidebar tool filenames — `*-tab.svelte`.** Phase 12 / 13 / 30
each name their final implementation
(`order-tab.svelte`, `inspector-tab.svelte`,
`calculator-tab.svelte`). The Phase 10 stubs ship with those
names so later phases replace the content in place without
renaming.
7. **Race-name and turn-counter placeholders.** The header race
name is the static `race ?` string from i18n, mirroring the
spec's static `turn ?` placeholder. Phase 11 wires both from
`user.games.report` data through `lib/header/turn-counter.svelte`.
8. **Auth gate inherited.** The root `+layout.svelte` already
redirects `anonymous → /login`; the in-game shell needs no
extra guard. Phase 10 verified this by booting the e2e shell
spec via `__galaxyDebug.setDeviceSessionId` and observing the
post-`session.init` `authenticated` status.
9. **More drawer mirrors the view-menu.** The mobile bottom-tabs
"More" drawer renders the same seven destinations as the
header view-menu. The IA section's narrower More list (Mail,
Battle log, Tables, History, Settings, Logout) is the polish
target for Phase 35 once History exists; Phase 10 keeps a
single destination list to avoid drift.
Artifacts (delivered):
- `ui/frontend/src/routes/games/[id]/+layout.svelte` — chrome
layout (header, conditional sidebar, active-view slot, mobile
bottom-tabs, mobileTool gate, sidebarOpen toggle)
- `ui/frontend/src/routes/games/[id]/+layout.ts` —
`ssr=false; prerender=false;` mirroring the root SPA flags
- `ui/frontend/src/routes/games/[id]/+page.ts` — redirects
`/games/:id` → `/games/:id/map`
- `ui/frontend/src/routes/games/[id]/{map, table/[entity], report,
battle/[[battleId]], mail, designer/ship-class/[[classId]],
designer/science/[[scienceId]]}/+page.svelte` — thin route
wrappers that mount the matching active-view stub
- `ui/frontend/src/lib/header/{header, turn-counter, view-menu,
account-menu}.svelte` — header composition with race
placeholder, turn counter (static `?`), view-menu
(dropdown desktop / hamburger mobile), and account menu
(Settings / Sessions / Theme stub buttons; Language driven by
`i18n.setLocale`; Logout calls `session.signOut("user")`)
- `ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab,
inspector-tab, order-tab, bottom-tabs}.svelte` — three-tab
sidebar with `inspector` default and `?sidebar=` URL seed;
mobile-only bottom-tabs with `[Map, Calc, Order, More]` plus a
More drawer duplicating the view-menu destinations
- `ui/frontend/src/lib/sidebar/types.ts` — shared `SidebarTab`
and `MobileTool` types
- `ui/frontend/src/lib/active-view/{map, table, report, battle,
mail, designer-ship-class, designer-science}.svelte` — Phase 10
stubs rendering localised titles plus `coming soon` copy with
stable testids that later phases replace
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — full
`game.shell.*`, `game.view.*`, `game.sidebar.*`,
`game.bottom_tabs.*` catalogue
- Topic doc `ui/docs/navigation.md`
- Vitest: `tests/game-shell-{header,sidebar,stubs}.test.ts`
- Playwright: `tests/e2e/game-shell.spec.ts` (7 cases × 4 projects;
mobile-only and viewport-switch cases conditionally skipped on
non-matching projects)
Dependencies: Phase 8.
Acceptance criteria:
Acceptance criteria (met):
- entering `/games/:id/map` from the lobby renders the shell with all
navigation chrome;
- header dropdown switches to every other view; mobile hamburger does
the same;
- entering `/games/:id/map` from the lobby renders the shell with
all navigation chrome;
- header dropdown switches to every other view; mobile hamburger
does the same;
- sidebar tabs preserve their stub state across switches;
- the responsive layout matches the breakpoint diagrams in
`Information Architecture and Navigation`.
`Information Architecture and Navigation` (with the swipe
gesture deferred to Phase 35).
Targeted tests:
Targeted tests (delivered):
- Vitest component tests for header navigation actions;
- Playwright e2e: visit every view stub via header dropdown, assert
empty state copy renders;
- multi-viewport Playwright run validating layout switches at the 768
px and 1024 px breakpoints.
- Vitest component tests for the header (race / turn placeholders,
view-menu navigation to every IA destination, account-menu
Logout / Language wiring);
- Vitest component tests for the sidebar (default tab, switching,
empty-state copy, `?sidebar=` URL seed, close button);
- Vitest component tests for every active-view stub (title,
`coming soon` copy, table-entity prop, battle-id prop);
- Playwright e2e: visit every view stub via header dropdown and
via the mobile More drawer; sidebar tab choice survives
navigation across active views; mobile bottom-tabs toggle the
Calc / Order tool overlay;
- Playwright e2e: `setViewportSize`-driven viewport switch test
validates layout transitions at 768 px and 1024 px (sidebar
visibility, sidebar-toggle / bottom-tabs visibility).
## Phase 11. Map Wired to Live Game State
+127
View File
@@ -0,0 +1,127 @@
# In-game shell — navigation model
This doc covers the chrome that wraps every in-game view: the
responsive layout shell, the active-view router built on SvelteKit's
file-system routes, the sidebar with three tools and its
state-preservation rule, and the mobile bottom-tabs. The user-facing
spec — view list, breakpoint diagrams, history-mode plans — lives in
[`../PLAN.md`](../PLAN.md), section
`Information Architecture and Navigation`. This doc is the source of
truth for how those rules are implemented.
## Active-view model
The client renders **one active view at a time**. Every active view is
a SvelteKit route under `routes/games/[id]/`; the route file is a
two-line wrapper that mounts the matching content component from
`src/lib/active-view/<name>.svelte`. The "view router" mentioned in
the plan is the file system plus those wrappers — there is no
separate dispatch component.
| URL | Active view component | Phase that fills it |
| ------------------------------------- | ---------------------------------------------- | ----------------------- |
| `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 |
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 |
| `/games/:id/report` | `lib/active-view/report.svelte` | Phase 23 |
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 |
| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 |
| `/games/:id/designer/ship-class/:id?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 / 18 |
| `/games/:id/designer/science/:id?` | `lib/active-view/designer-science.svelte` | Phase 21 |
`/games/:id` (no trailing view) redirects to `/games/:id/map`. The
optional `:id?` segments on the designer routes match SvelteKit's
`[[id]]` syntax — they accept both the new-draft and editing URLs;
later phases read the param when wiring real content.
The `entity` slug on the table route is kebab-case (`planets`,
`ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`); the
table stub maps it to the matching `game.view.table.<snake>` i18n
key.
## Sidebar tools and state preservation
The desktop sidebar hosts three tools:
| Tool | Component | Phase that fills it |
| ---------- | -------------------------------------- | -------------------- |
| Calculator | `lib/sidebar/calculator-tab.svelte` | Phase 30 |
| Inspector | `lib/sidebar/inspector-tab.svelte` | Phase 13 / 19 |
| Order | `lib/sidebar/order-tab.svelte` | Phase 12 / 14 |
The sidebar's selected-tab state is a `$state` rune inside
`lib/sidebar/sidebar.svelte`. The component is mounted by the layout
at `routes/games/[id]/+layout.svelte`, and SvelteKit keeps that
layout instance alive while the user navigates between child routes
(`/games/:id/map``/games/:id/report` → …). The rune therefore
survives every active-view switch automatically, with no URL coupling
needed.
A `?sidebar=calc|calculator|inspector|order` URL param is read once
on mount and seeds the initial tab. Later phases that want to land
the user on a particular tool (for example, Phase 14's first
end-to-end command flow) can set it on navigation.
## Layout breakpoints
Three discrete CSS modes matched to the IA section diagrams:
- **≥ 1024 px (desktop)** — the sidebar sits beside the active view
and is always rendered. The header view-menu trigger uses the
dropdown icon (▾). Bottom-tabs and the tablet sidebar-toggle are
CSS-hidden.
- **7681024 px (tablet)** — the sidebar collapses behind a click
toggle in the header right corner. Tapping the toggle slides the
sidebar in as a fixed overlay above the active view; a close
button on the sidebar dismisses it. The full swipe-from-right
gesture in the IA section is deferred to Phase 35 polish — the
click toggle satisfies the "layout switches at 768 px" acceptance
criterion on Phase 10.
- **< 768 px (mobile)** — the sidebar is hidden entirely and the
bottom-tabs row appears at the bottom of the viewport. The
view-menu trigger swaps to a hamburger icon (☰) that opens the
drop-down as a full-width drawer below the header.
Inspector is intentionally unreachable on mobile in Phase 10. Per the
IA section the mobile inspector is a bottom-sheet raised by tapping a
map object, and that mechanism waits for Phase 13.
## Mobile bottom-tabs and tool overlay
The bottom-tabs row is `[Map, Calc, Order, More]`. Map navigates to
`/games/:id/map` and clears any tool overlay. Calc and Order navigate
to `/games/:id/map` too — but they also flip the layout's
`mobileTool` state to `calc` / `order`, which the layout uses to
swap the active-view slot for the Calculator / Order tool component.
The tool overlay only applies when the URL is `/map`. Navigating to
any other view through the More drawer or the header view-menu makes
the layout's derived `effectiveTool` collapse back to `map`, so the
user always sees the URL's active view rather than a stale overlay.
The next time the user taps a Calc or Order bottom-tab, the
navigation re-routes them to `/map` and re-applies the overlay.
The `More` button opens a drawer that mirrors the header view-menu
content. The IA section's narrower "More" list (Mail, Battle log,
Tables, History, Settings, Logout) is the polish target for Phase 35
— Phase 10 keeps a single source of truth for destinations.
## Transient map overlays
Some views can push a transient overlay onto `/map` with a back
affordance — for example, the ship-class designer pushes a
range-preview overlay onto the map. The transient overlay clears
when the user navigates to any other view via the header or the
bottom-tabs.
Phase 10 documents this concept but does not implement the
back-stack mechanism. Phase 34 lands the back-stack alongside its
first user (multi-turn projection, range circles in the ship-class
designer).
## Auth gate
The root `+layout.svelte` redirects `anonymous → /login` for any
non-`/__debug/` path; the in-game shell inherits that gate without
any extra check. When a session is revoked while the user is in the
shell, the same redirect fires through the existing
revocation watcher.
@@ -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";
@@ -0,0 +1,92 @@
<!--
Phase 10 in-game shell. Composes the header, a conditionally-visible
sidebar (Calculator / Inspector / Order tabs), the active-view slot
filled by the child route, and a mobile-only bottom-tab bar. The
layout owns:
- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
sidebar pinned via CSS; mobile hides it entirely.
- `mobileTool` — mobile-only tool overlay state. The tool only
visually overrides the active-view slot when the URL is `/map`,
so navigating to any other view through the More drawer or the
header view-menu naturally drops the overlay even if `mobileTool`
was set on a previous tap.
State preservation across active-view switches works for free
because SvelteKit keeps this layout instance mounted while children
swap.
-->
<script lang="ts">
import { page } from "$app/state";
import Header from "$lib/header/header.svelte";
import Sidebar from "$lib/sidebar/sidebar.svelte";
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
import Calculator from "$lib/sidebar/calculator-tab.svelte";
import Order from "$lib/sidebar/order-tab.svelte";
import type { MobileTool } from "$lib/sidebar/types";
let { children } = $props();
let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
const gameId = $derived(page.params.id ?? "");
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
const effectiveTool: MobileTool = $derived.by(() =>
isOnMap ? mobileTool : "map",
);
function toggleSidebar(): void {
sidebarOpen = !sidebarOpen;
}
</script>
<div class="game-shell" data-testid="game-shell">
<Header
{gameId}
{sidebarOpen}
onToggleSidebar={toggleSidebar}
/>
<div class="body">
<main class="active-view-host" data-testid="active-view-host">
{#if effectiveTool === "calc"}
<Calculator />
{:else if effectiveTool === "order"}
<Order />
{:else}
{@render children()}
{/if}
</main>
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} />
</div>
<BottomTabs
{gameId}
activeTool={effectiveTool}
onSelectTool={(tool) => (mobileTool = tool)}
/>
</div>
<style>
.game-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #0a0e1a;
color: #e8eaf6;
}
.body {
flex: 1;
display: flex;
min-height: 0;
}
.active-view-host {
flex: 1;
min-width: 0;
overflow-y: auto;
}
@media (max-width: 767.98px) {
.body {
padding-bottom: 3.25rem;
}
}
</style>
@@ -0,0 +1,8 @@
// SPA mode for the in-game shell, mirroring the root layout. The
// session bootstrap and the auth gate already live in the root
// `+layout.svelte`; this layout just inherits the SPA flags so the
// static adapter does not try to prerender a per-game shell at build
// time.
export const ssr = false;
export const prerender = false;
@@ -0,0 +1,12 @@
// A bare `/games/:id` URL is not in the IA section — every in-game
// view sits under one of the typed sub-routes (`map`, `table/...`,
// etc.). Default the user to the map view so the URL is always
// pointing at a real active view; SvelteKit's `redirect` runs in the
// browser because the layout disables SSR.
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export const load: PageLoad = ({ params }) => {
throw redirect(307, `/games/${params.id}/map`);
};
@@ -0,0 +1,6 @@
<script lang="ts">
import { page } from "$app/state";
import BattleView from "$lib/active-view/battle.svelte";
</script>
<BattleView battleId={page.params.battleId ?? ""} />
@@ -0,0 +1,5 @@
<script lang="ts">
import DesignerScience from "$lib/active-view/designer-science.svelte";
</script>
<DesignerScience />
@@ -0,0 +1,5 @@
<script lang="ts">
import DesignerShipClass from "$lib/active-view/designer-ship-class.svelte";
</script>
<DesignerShipClass />
@@ -0,0 +1,5 @@
<script lang="ts">
import MailView from "$lib/active-view/mail.svelte";
</script>
<MailView />
@@ -0,0 +1,5 @@
<script lang="ts">
import MapView from "$lib/active-view/map.svelte";
</script>
<MapView />
@@ -0,0 +1,5 @@
<script lang="ts">
import ReportView from "$lib/active-view/report.svelte";
</script>
<ReportView />
@@ -0,0 +1,6 @@
<script lang="ts">
import { page } from "$app/state";
import TableView from "$lib/active-view/table.svelte";
</script>
<TableView entity={page.params.entity ?? ""} />
+219
View File
@@ -0,0 +1,219 @@
// Phase 10 end-to-end coverage for the in-game shell. Every spec
// boots an authenticated session through `/__debug/store` (no
// gateway calls — the shell makes none in Phase 10), navigates into
// `/games/test-shell/map`, and exercises one slice of the chrome:
// header navigation, sidebar tab preservation, mobile bottom-tabs,
// and the breakpoint switches at 768 / 1024 px.
import { expect, test, type Page } from "@playwright/test";
// The `window.__galaxyDebug` surface is owned by
// `src/routes/__debug/store/+page.svelte` and typed by
// `tests/e2e/storage-keypair-persistence.spec.ts`. This spec only
// needs the auth-bootstrap subset (`clearSession`,
// `setDeviceSessionId`); the merged global declaration covers both.
const SESSION_ID = "phase-10-shell-session";
const GAME_ID = "test-shell";
async function bootShell(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible();
}
test("shell mounts with header / sidebar / active-view chrome", async ({
page,
}) => {
await bootShell(page);
await expect(page.getByTestId("game-shell-header")).toBeVisible();
await expect(page.getByTestId("race-name")).toContainText("race ?");
await expect(page.getByTestId("turn-counter")).toContainText("turn");
await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
await expect(page.getByTestId("account-menu-trigger")).toBeVisible();
});
test("header view-menu navigates to every active view", async ({ page }) => {
await bootShell(page);
const destinations: Array<[string, string, string]> = [
["view-menu-item-report", "active-view-report", "/report"],
["view-menu-item-mail", "active-view-mail", "/mail"],
["view-menu-item-battle", "active-view-battle", "/battle"],
[
"view-menu-item-designer-ship-class",
"active-view-designer-ship-class",
"/designer/ship-class",
],
[
"view-menu-item-designer-science",
"active-view-designer-science",
"/designer/science",
],
["view-menu-item-map", "active-view-map", "/map"],
];
for (const [trigger, viewTestId, urlSuffix] of destinations) {
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId(trigger).click();
await expect(page.getByTestId(viewTestId)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}${urlSuffix}$`));
}
});
test("header view-menu Tables sub-list navigates to every entity", async ({
page,
}) => {
await bootShell(page);
const entities = [
"planets",
"ship-classes",
"ship-groups",
"fleets",
"sciences",
"races",
];
for (const entity of entities) {
await page.getByTestId("view-menu-trigger").click();
await page
.getByTestId("view-menu-tables")
.locator("summary")
.click();
await page.getByTestId(`view-menu-item-table-${entity}`).click();
const view = page.getByTestId("active-view-table");
await expect(view).toBeVisible();
await expect(view).toHaveAttribute("data-entity", entity);
await expect(page).toHaveURL(
new RegExp(`/games/${GAME_ID}/table/${entity}$`),
);
}
});
test("sidebar tab choice survives navigation between active views", async ({
page,
browserName,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile") ||
testInfo.project.name === "webkit-desktop"
? false
: false,
"sidebar test runs on every project",
);
await bootShell(page);
// Skip on viewports below 1024 — sidebar is hidden by CSS there.
const viewport = page.viewportSize();
if (viewport === null || viewport.width < 1024) {
test.skip();
return;
}
void browserName;
await page.getByTestId("sidebar-tab-calculator").click();
await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible();
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId("view-menu-item-report").click();
await expect(page.getByTestId("active-view-report")).toBeVisible();
// Sidebar still rendered; the calculator tool remains selected.
await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible();
await expect(page.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"calculator",
);
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId("view-menu-item-map").click();
await expect(page.getByTestId("active-view-map")).toBeVisible();
await expect(page.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"calculator",
);
});
test("mobile bottom-tabs show on small viewports and toggle the tool overlay", async ({
page,
}, testInfo) => {
if (!testInfo.project.name.startsWith("chromium-mobile")) {
test.skip();
return;
}
await bootShell(page);
await expect(page.getByTestId("bottom-tabs")).toBeVisible();
await expect(page.getByTestId("sidebar")).not.toBeVisible();
await page.getByTestId("bottom-tab-calc").click();
await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible();
await page.getByTestId("bottom-tab-order").click();
await expect(page.getByTestId("sidebar-tool-order")).toBeVisible();
await page.getByTestId("bottom-tab-map").click();
await expect(page.getByTestId("active-view-map")).toBeVisible();
});
test("mobile More drawer navigates to every destination", async ({
page,
}, testInfo) => {
if (!testInfo.project.name.startsWith("chromium-mobile")) {
test.skip();
return;
}
await bootShell(page);
await page.getByTestId("bottom-tab-more").click();
await expect(page.getByTestId("bottom-tabs-more-drawer")).toBeVisible();
await page.getByTestId("bottom-tabs-more-mail").click();
await expect(page.getByTestId("active-view-mail")).toBeVisible();
await page.getByTestId("bottom-tab-more").click();
await page.getByTestId("bottom-tabs-more-report").click();
await expect(page.getByTestId("active-view-report")).toBeVisible();
});
test("breakpoint switches between desktop / tablet / mobile", async ({
page,
}, testInfo) => {
// Use a single chromium-desktop run to drive all three viewports in
// the same browser. Other projects skip — the viewport diff is the
// goal here, not browser-specific behaviour.
if (testInfo.project.name !== "chromium-desktop") {
test.skip();
return;
}
await bootShell(page);
// Desktop ≥ 1024: sidebar visible, bottom-tabs hidden, sidebar
// toggle hidden.
await page.setViewportSize({ width: 1280, height: 800 });
await expect(page.getByTestId("sidebar")).toBeVisible();
await expect(page.getByTestId("bottom-tabs")).not.toBeVisible();
await expect(page.getByTestId("sidebar-toggle")).not.toBeVisible();
// Tablet 7681024: sidebar hidden by default, sidebar toggle
// visible, bottom-tabs hidden. Click the toggle and the sidebar
// becomes visible again.
await page.setViewportSize({ width: 900, height: 800 });
await expect(page.getByTestId("sidebar")).not.toBeVisible();
await expect(page.getByTestId("sidebar-toggle")).toBeVisible();
await expect(page.getByTestId("bottom-tabs")).not.toBeVisible();
await page.getByTestId("sidebar-toggle").click();
await expect(page.getByTestId("sidebar")).toBeVisible();
// Mobile < 768: sidebar hidden entirely, bottom-tabs visible,
// sidebar toggle hidden again.
await page.setViewportSize({ width: 390, height: 800 });
await expect(page.getByTestId("bottom-tabs")).toBeVisible();
await expect(page.getByTestId("sidebar")).not.toBeVisible();
await expect(page.getByTestId("sidebar-toggle")).not.toBeVisible();
});
+140
View File
@@ -0,0 +1,140 @@
// Component tests for the Phase 10 in-game shell header. The header
// composes the static `race ?` placeholder, the placeholder
// turn-counter (Phase 11 wires the live source), the view-menu, and
// the account-menu. The tests assert the placeholder copy, that
// every view-menu entry dispatches `goto` with the right URL, and
// that the Logout entry of the account-menu calls
// `session.signOut("user")`.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte";
import Header from "../src/lib/header/header.svelte";
const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
vi.mock("$app/navigation", () => ({
goto: (...args: unknown[]) => gotoSpy(...args),
}));
beforeEach(() => {
i18n.resetForTests("en");
gotoSpy.mockReset();
vi.spyOn(session, "signOut").mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("game-shell header", () => {
test("renders the static race / turn placeholders and toggles", () => {
const onToggleSidebar = vi.fn();
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
});
expect(ui.getByTestId("race-name")).toHaveTextContent("race ?");
expect(ui.getByTestId("turn-counter").textContent ?? "").toMatch(
/turn\s+\?/,
);
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
});
test("clicking the sidebar toggle invokes the prop callback", async () => {
const onToggleSidebar = vi.fn();
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
});
await fireEvent.click(ui.getByTestId("sidebar-toggle"));
expect(onToggleSidebar).toHaveBeenCalledTimes(1);
});
test("view-menu navigates to every IA destination", async () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
});
const destinations: Array<[string, string]> = [
["view-menu-item-map", "/games/g1/map"],
["view-menu-item-report", "/games/g1/report"],
["view-menu-item-battle", "/games/g1/battle"],
["view-menu-item-mail", "/games/g1/mail"],
[
"view-menu-item-designer-ship-class",
"/games/g1/designer/ship-class",
],
[
"view-menu-item-designer-science",
"/games/g1/designer/science",
],
];
for (const [testId, href] of destinations) {
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
await fireEvent.click(ui.getByTestId(testId));
expect(gotoSpy).toHaveBeenLastCalledWith(href);
}
});
test("view-menu Tables sub-list navigates to every entity", async () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
});
const tableEntities: Array<[string, string]> = [
["view-menu-item-table-planets", "/games/g1/table/planets"],
[
"view-menu-item-table-ship-classes",
"/games/g1/table/ship-classes",
],
[
"view-menu-item-table-ship-groups",
"/games/g1/table/ship-groups",
],
["view-menu-item-table-fleets", "/games/g1/table/fleets"],
["view-menu-item-table-sciences", "/games/g1/table/sciences"],
["view-menu-item-table-races", "/games/g1/table/races"],
];
for (const [testId, href] of tableEntities) {
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
// Open the Tables sub-disclosure each iteration; the menu
// closes on every navigation.
const summary = ui
.getByTestId("view-menu-tables")
.querySelector("summary");
if (summary !== null) {
await fireEvent.click(summary);
}
await fireEvent.click(ui.getByTestId(testId));
expect(gotoSpy).toHaveBeenLastCalledWith(href);
}
});
test("account-menu Logout triggers session.signOut('user')", async () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
});
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
await fireEvent.click(ui.getByTestId("account-menu-logout"));
expect(session.signOut).toHaveBeenCalledWith("user");
});
test("account-menu language picker switches the i18n locale", async () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
});
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
const select = ui.getByTestId("account-menu-language-select");
await fireEvent.change(select, { target: { value: "ru" } });
expect(i18n.locale).toBe("ru");
});
});
@@ -0,0 +1,98 @@
// Component tests for the Phase 10 in-game shell sidebar. Validates
// the default selected tab, the Calculator / Inspector / Order
// switching, the empty-state copy that matches the IA section, and
// the `?sidebar=` URL seed convention used by the mobile bottom-tabs.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
import {
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
const pageMock = vi.hoisted(() => ({
url: new URL("http://localhost/games/g1/map"),
params: { id: "g1" } as Record<string, string>,
}));
vi.mock("$app/state", () => ({
page: pageMock,
}));
import Sidebar from "../src/lib/sidebar/sidebar.svelte";
beforeEach(() => {
i18n.resetForTests("en");
pageMock.url = new URL("http://localhost/games/g1/map");
});
describe("game-shell sidebar", () => {
test("renders the inspector tab content by default", () => {
const ui = render(Sidebar, {
props: { open: false, onClose: () => {} },
});
expect(ui.getByTestId("sidebar-tool-inspector")).toBeInTheDocument();
expect(ui.getByTestId("sidebar-tool-inspector")).toHaveTextContent(
"select an object on the map",
);
expect(ui.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"inspector",
);
});
test("switching tabs updates the rendered tool", async () => {
const ui = render(Sidebar, {
props: { open: false, onClose: () => {} },
});
await fireEvent.click(ui.getByTestId("sidebar-tab-calculator"));
expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument();
expect(ui.queryByTestId("sidebar-tool-inspector")).toBeNull();
expect(ui.queryByTestId("sidebar-tool-order")).toBeNull();
await fireEvent.click(ui.getByTestId("sidebar-tab-order"));
expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument();
expect(ui.queryByTestId("sidebar-tool-calculator")).toBeNull();
});
test("empty-state copy matches the IA section verbatim", () => {
const ui = render(Sidebar, {
props: { open: false, onClose: () => {} },
});
expect(ui.getByTestId("sidebar-tool-inspector")).toHaveTextContent(
"select an object on the map",
);
});
test("?sidebar=calc seeds the calculator tab on first mount", () => {
pageMock.url = new URL("http://localhost/games/g1/map?sidebar=calc");
const ui = render(Sidebar, {
props: { open: false, onClose: () => {} },
});
expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument();
expect(ui.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"calculator",
);
});
test("?sidebar=order seeds the order tab on first mount", () => {
pageMock.url = new URL("http://localhost/games/g1/map?sidebar=order");
const ui = render(Sidebar, {
props: { open: false, onClose: () => {} },
});
expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument();
});
test("close button calls the onClose prop", async () => {
const onClose = vi.fn();
const ui = render(Sidebar, { props: { open: true, onClose } });
await fireEvent.click(ui.getByTestId("sidebar-close"));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,83 @@
// Component tests for every Phase 10 active-view stub. Each stub
// renders the localised view title plus the `coming soon` body copy
// and exposes a stable `data-testid` so later phases can replace the
// content without renaming the test hook. The table stub additionally
// honours its `entity` prop and falls back to the snake_case i18n key
// for an unknown slug.
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import MapView from "../src/lib/active-view/map.svelte";
import TableView from "../src/lib/active-view/table.svelte";
import ReportView from "../src/lib/active-view/report.svelte";
import BattleView from "../src/lib/active-view/battle.svelte";
import MailView from "../src/lib/active-view/mail.svelte";
import DesignerShipClass from "../src/lib/active-view/designer-ship-class.svelte";
import DesignerScience from "../src/lib/active-view/designer-science.svelte";
beforeEach(() => {
i18n.resetForTests("en");
});
describe("active-view stubs", () => {
test("map stub renders title and coming-soon copy", () => {
const ui = render(MapView);
const node = ui.getByTestId("active-view-map");
expect(node).toHaveTextContent("map");
expect(node).toHaveTextContent("coming soon");
});
test("table stub maps a kebab-case entity to the right i18n title", () => {
const ui = render(TableView, { props: { entity: "ship-classes" } });
const node = ui.getByTestId("active-view-table");
expect(node).toHaveAttribute("data-entity", "ship-classes");
expect(node).toHaveTextContent("ship classes");
expect(node).toHaveTextContent("coming soon");
});
test("table stub also handles a single-word entity", () => {
const ui = render(TableView, { props: { entity: "planets" } });
expect(ui.getByTestId("active-view-table")).toHaveTextContent("planets");
});
test("report / mail / designer stubs render their localised titles", () => {
const r = render(ReportView);
expect(r.getByTestId("active-view-report")).toHaveTextContent(
"turn report",
);
const m = render(MailView);
expect(m.getByTestId("active-view-mail")).toHaveTextContent(
"diplomatic mail",
);
const sc = render(DesignerShipClass);
expect(
sc.getByTestId("active-view-designer-ship-class"),
).toHaveTextContent("ship-class designer");
const sci = render(DesignerScience);
expect(
sci.getByTestId("active-view-designer-science"),
).toHaveTextContent("science designer");
});
test("battle stub stamps the battleId on the host element", () => {
const ui = render(BattleView, { props: { battleId: "b-42" } });
const node = ui.getByTestId("active-view-battle");
expect(node).toHaveAttribute("data-battle-id", "b-42");
expect(node).toHaveTextContent("battle log");
});
test("battle stub accepts an empty battleId for the list URL", () => {
const ui = render(BattleView, { props: { battleId: "" } });
expect(ui.getByTestId("active-view-battle")).toHaveAttribute(
"data-battle-id",
"",
);
});
});