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:
+114
-29
@@ -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
|
with empty placeholder content for every view, so navigation works
|
||||||
end-to-end before any data is wired.
|
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
|
1. **Routing — file-system based, no extra dispatcher.** The
|
||||||
responsive breakpoints (desktop / tablet / mobile)
|
"view router" called out in the original artifact list is
|
||||||
- `ui/frontend/src/lib/header/` header component: race name, turn
|
implemented as SvelteKit's file-system routes plus thin
|
||||||
counter (static placeholder `turn ?`), view dropdown / hamburger,
|
`+page.svelte` wrappers that mount the matching
|
||||||
account menu
|
`lib/active-view/<name>.svelte` stub. No separate dispatch
|
||||||
- `ui/frontend/src/lib/sidebar/` sidebar with three tabs (Calculator,
|
component lives in the codebase; each route file is a two-line
|
||||||
Inspector, Order), each tab content stubbed to `coming soon`; mobile
|
wrapper.
|
||||||
bottom-tab bar `[Map, Calc, Order, More]` with corresponding stub
|
2. **Optional designer ID segments.** Both designer URLs ship as
|
||||||
panels
|
`[[id]]` optional segments
|
||||||
- `ui/frontend/src/lib/active-view/` view router supporting
|
(`designer/ship-class/[[classId]]/`,
|
||||||
`/games/:id/{map,table/:entity,report,battle/:battleId,mail,
|
`designer/science/[[scienceId]]/`) so Phase 18 / 21 can read
|
||||||
designer/...}` with stub content per view
|
the param without a routing migration. Phase 10 stubs ignore
|
||||||
- topic doc `ui/docs/navigation.md` documenting the active-view
|
the param.
|
||||||
model, the state-preservation rule, and the transient map-overlay
|
3. **Battle URL — optional id.** `battle/[[battleId]]/` accepts
|
||||||
concept (the back-stack mechanism itself is implemented in Phase 34
|
both the list URL (`/battle`) and a specific battle URL
|
||||||
when the first overlay user, ship-designer reach circles, ships)
|
(`/battle/<id>`). Phase 27 keeps the optional segment and
|
||||||
|
switches behaviour based on presence.
|
||||||
|
4. **Tablet sidebar — click toggle, not swipe.** The 768–1024 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.
|
Dependencies: Phase 8.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria (met):
|
||||||
|
|
||||||
- entering `/games/:id/map` from the lobby renders the shell with all
|
- entering `/games/:id/map` from the lobby renders the shell with
|
||||||
navigation chrome;
|
all navigation chrome;
|
||||||
- header dropdown switches to every other view; mobile hamburger does
|
- header dropdown switches to every other view; mobile hamburger
|
||||||
the same;
|
does the same;
|
||||||
- sidebar tabs preserve their stub state across switches;
|
- sidebar tabs preserve their stub state across switches;
|
||||||
- the responsive layout matches the breakpoint diagrams in
|
- 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;
|
- Vitest component tests for the header (race / turn placeholders,
|
||||||
- Playwright e2e: visit every view stub via header dropdown, assert
|
view-menu navigation to every IA destination, account-menu
|
||||||
empty state copy renders;
|
Logout / Language wiring);
|
||||||
- multi-viewport Playwright run validating layout switches at the 768
|
- Vitest component tests for the sidebar (default tab, switching,
|
||||||
px and 1024 px breakpoints.
|
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
|
## Phase 11. Map Wired to Live Game State
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
- **768–1024 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>
|
||||||
@@ -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.conflict": "request conflicts with current state",
|
||||||
"lobby.error.internal_error": "internal server error",
|
"lobby.error.internal_error": "internal server error",
|
||||||
"lobby.error.unknown": "{message}",
|
"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;
|
} as const;
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -83,6 +83,47 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"lobby.error.conflict": "запрос конфликтует с текущим состоянием",
|
"lobby.error.conflict": "запрос конфликтует с текущим состоянием",
|
||||||
"lobby.error.internal_error": "внутренняя ошибка сервера",
|
"lobby.error.internal_error": "внутренняя ошибка сервера",
|
||||||
"lobby.error.unknown": "{message}",
|
"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;
|
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";
|
||||||
@@ -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 ?? ""} />
|
||||||
@@ -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 768–1024: 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();
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user