feat(ui): F8-05 — game-mode chrome cleanup + inspector compact rows (#48)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Waiting to run

Drains six F8 polish items (parent #43) in one feature:

а) Chrome cleanup
- п.6 — remove the AccountMenu (settings/sessions/theme/language/logout
  ∼ rudimentary in-game) and replace it with a single icon-button
  light/dark theme toggle. The toggle flips an in-memory `theme.override`;
  game-shell unmount calls `theme.clearOverride()` so the lobby (and
  any re-entry) re-projects the persisted lobby choice.
- п.8 — remove the wrap-scrolling radio from the map gear popover. The
  per-game `wrapMode` store and the renderer's no-wrap path stay in
  place for a future engine-side topology feature; only the UI surface
  is dropped (wrap is a server-side concept, not a per-session UI
  affordance).

б) Inspector compact rows (single idiom: select + ✓ apply / ✗ cancel,
or contextual edit/remove/add)
- п.13 — planet name is now click-to-edit: clicking the name opens an
  inline `<input>` + ✓ confirm icon; Escape cancels; the explicit
  Rename action button and Cancel button are gone.
- п.14 — production becomes one row: primary `<select>` picks
  industry/materials/research/ship, conditional secondary `<select>`
  picks the target (tech / science / ship class) for research and
  ship contexts. Apply is gated until row state differs from the
  planet's current effective production; auto-submit-on-click is
  replaced by the apply-gate.
- п.16 — cargo routes collapse to one row: a single dropdown
  (COL/CAP/MAT/EMP plus a placeholder that absorbs the old section
  title) and contextual action buttons (add / edit + remove) to the
  right. After a successful pick or remove the dropdown stays on the
  type the user just acted on.
- п.32 — stationed ship groups hoist the race column into a dropdown
  above the table. The dropdown seeds with the player's own race when
  local groups are stationed here, otherwise the first race
  alphabetically; rendered only when more than one race is in orbit.
  The race column is dropped in both single- and multi-race modes —
  the dropdown's value already names the active race.

Tests: unit and Playwright e2e updated for every changed test-id and
flow; new coverage added for `theme.override`, the in-game toggle, the
apply-gate behaviour, and the stationed-race dropdown. i18n keys for
the removed menu items, the wrap radios, the cargo title, and the
explicit `rename.cancel` are dropped from both locales; new
`game.shell.theme_toggle.*`, `production.main/target.*`,
`production.apply/cancel`, `cargo.placeholder`, and
`ship_groups.race_filter.aria` keys land.

Docs synced: `docs/FUNCTIONAL.md` §6.7 + `docs/FUNCTIONAL_ru.md`
mirror drop the torus / no-wrap radio mention; `ui/docs/design-system.md`
documents the lobby-owned persisted picker + the in-game ephemeral
override channel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-27 13:38:42 +02:00
parent 2901ecb21b
commit 4a23c357e5
30 changed files with 1173 additions and 1032 deletions
+6 -2
View File
@@ -820,8 +820,12 @@ every change applies within one frame (no Pixi remount):
`VisibilityDistance(localPlayerDrive)` circles around LOCAL `VisibilityDistance(localPlayerDrive)` circles around LOCAL
planets; LOCAL planets are always exempt — the toggle is planets; LOCAL planets are always exempt — the toggle is
named after the visible part of the map rather than the named after the visible part of the map rather than the
obscured one) plus the torus / no-wrap radio that switches obscured one). The renderer always runs in torus mode; the
the renderer mode while preserving the camera centre. earlier torus / no-wrap radio was removed in F8 polish
(issue #48 п.8) because the topology is a server-side concept
rather than a per-session UI affordance. The renderer-side
no-wrap path is retained for the day the engine surfaces a
bounded-plane mode.
LOCAL planets are always rendered — they have no toggle. Every LOCAL planets are always rendered — they have no toggle. Every
other toggle defaults to ON. Hiding a planet cascades onto every other toggle defaults to ON. Hiding a planet cascades onto every
+6 -3
View File
@@ -840,9 +840,12 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
объединения окружностей объединения окружностей
`VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет; `VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
LOCAL-планеты всегда вне фильтра — тоггл назван по видимой LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
области карты, а не по затемнённой) плюс радиогруппа области карты, а не по затемнённой). Рендерер всегда работает
«торус / без переноса», переключающая режим рендерера с в торическом режиме; прежняя радиогруппа «торус / без
сохранением центра камеры. переноса» была удалена в полишинге F8 (issue #48 п.8),
поскольку топология карты — серверная сущность, а не
per-session UI-настройка. Код-путь без переноса в рендерере
оставлен на день, когда движок выставит режим bounded plane.
LOCAL-планеты отрисовываются всегда — для них тоггла нет. LOCAL-планеты отрисовываются всегда — для них тоггла нет.
Остальные тогглы по умолчанию включены. Скрытие планеты Остальные тогглы по умолчанию включены. Скрытие планеты
+17 -5
View File
@@ -71,13 +71,24 @@ the colour block in `tokens.css`.
`theme.resolved` (`light` | `dark`), and `theme.setChoice(…)`. It `theme.resolved` (`light` | `dark`), and `theme.setChoice(…)`. It
persists the choice, applies `data-theme`, and — while the choice is persists the choice, applies `data-theme`, and — while the choice is
`system` — follows OS theme changes via `matchMedia`. `system` — follows OS theme changes via `matchMedia`.
- The account menu (`account-menu.svelte`) exposes the picker. The - The persisted picker lives in the lobby profile screen
default is `system` (it follows the OS preference); `light` / `dark` ([`screens/profile-screen.svelte`](../frontend/src/lib/screens/profile-screen.svelte)) —
pin a theme. the in-game header is intentionally light on chrome and only carries
the volatile light/dark toggle described below. The default is
`system` (it follows the OS preference); `light` / `dark` pin a theme.
- On top of the persisted choice the store carries an ephemeral
`theme.override` (`null` | `light` | `dark`). `setOverride(…)`
short-circuits `resolved` so the in-game toggle
([`header/game-mode-theme-toggle.svelte`](../frontend/src/lib/header/game-mode-theme-toggle.svelte))
can flip the document theme without touching the lobby preference.
The override lives in memory only; the game shell calls
`theme.clearOverride()` on unmount, so leaving the game and re-entering
it re-projects the persisted choice from lobby.
The `app.html` guard and the store deliberately duplicate the The `app.html` guard and the store deliberately duplicate the
resolution logic (one runs before modules load, the other after) — keep resolution logic (one runs before modules load, the other after) — keep
them in sync. them in sync. The ephemeral override is intentionally absent from the
pre-paint guard: it cannot survive a reload, only an in-tab session.
## Conventions ## Conventions
@@ -126,4 +137,5 @@ battle-scene palette, both defined in code rather than as tokens), the
overlay scrims, and the directional / deliberate drop shadows. overlay scrims, and the directional / deliberate drop shadows.
The default theme is **`system`** — it follows the OS light/dark The default theme is **`system`** — it follows the OS light/dark
preference; users can pin light or dark via the account-menu picker. preference; users pin light or dark via the lobby profile screen, and
flip the in-game appearance volatilely through the header theme toggle.
@@ -1,10 +1,15 @@
<!-- <!--
Phase 29 gear popover. Sits in the top-right corner of the map Phase 29 gear popover. Sits in the top-right corner of the map
canvas and exposes the per-game visibility / wrap toggles that the canvas and exposes the per-game visibility toggles that the
`GameStateStore` already owns. The component is a thin view of the `GameStateStore` already owns. The component is a thin view of the
store — every checkbox / radio fires `store.setMapToggle(...)` or store — every checkbox fires `store.setMapToggle(...)` and reads
`store.setWrapMode(...)` and reads back the current state through back the current state through the rune.
the rune.
The wrap-scrolling toggle that used to live alongside the visibility
flags was dropped in F8-05 (issue #48 п.8): wrap is a game-server
feature, not a per-session UI affordance, so the renderer always
runs in torus mode for now. The renderer-side `wrapMode` plumbing
stays put for when the engine surfaces non-torus topologies.
Outside-click + Escape close the popover, matching the Outside-click + Escape close the popover, matching the
`header/view-menu.svelte` precedent. On mobile (<768 px) the `header/view-menu.svelte` precedent. On mobile (<768 px) the
@@ -16,7 +21,6 @@ bottom-tabs bar.
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus"; import { restoreFocus } from "$lib/a11y/restore-focus";
import type { MapToggles, GameStateStore } from "$lib/game-state.svelte"; import type { MapToggles, GameStateStore } from "$lib/game-state.svelte";
import type { WrapMode } from "../../map/world";
type Props = { store: GameStateStore }; type Props = { store: GameStateStore };
let { store }: Props = $props(); let { store }: Props = $props();
@@ -35,18 +39,6 @@ bottom-tabs bar.
void store.setMapToggle(key, event.currentTarget.checked as MapToggles[K]); void store.setMapToggle(key, event.currentTarget.checked as MapToggles[K]);
} }
/**
* setWrap is wired to the radios' `onclick`, not `onchange`, so the
* Playwright `.click()` action on the input fires the callback even
* when the input is already checked (the `change` event suppresses
* the second activation, which made the wrap-mode e2e flake).
* `onclick` also fires reliably on touch / pointer activation.
*/
function setWrap(mode: WrapMode): void {
if (store.wrapMode === mode) return;
void store.setWrapMode(mode);
}
function onKeyDown(event: KeyboardEvent): void { function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) { if (event.key === "Escape" && open) {
open = false; open = false;
@@ -197,31 +189,6 @@ bottom-tabs bar.
/> />
<span>{i18n.t("game.map.toggles.visible_hyperspace")}</span> <span>{i18n.t("game.map.toggles.visible_hyperspace")}</span>
</label> </label>
<div class="wrap-row">
<span class="wrap-label">{i18n.t("game.map.toggles.wrap.label")}</span>
<label class="radio">
<input
type="radio"
name="map-toggles-wrap"
data-testid="map-toggles-wrap-torus"
value="torus"
checked={store.wrapMode === "torus"}
onclick={() => setWrap("torus")}
/>
<span>{i18n.t("game.map.toggles.wrap.torus")}</span>
</label>
<label class="radio">
<input
type="radio"
name="map-toggles-wrap"
data-testid="map-toggles-wrap-no-wrap"
value="no-wrap"
checked={store.wrapMode === "no-wrap"}
onclick={() => setWrap("no-wrap")}
/>
<span>{i18n.t("game.map.toggles.wrap.no_wrap")}</span>
</label>
</div>
</fieldset> </fieldset>
</div> </div>
{/if} {/if}
@@ -295,27 +262,9 @@ bottom-tabs bar.
label:hover { label:hover {
background: var(--color-surface-hover); background: var(--color-surface-hover);
} }
input[type="checkbox"], input[type="checkbox"] {
input[type="radio"] {
accent-color: var(--color-accent); accent-color: var(--color-accent);
} }
.wrap-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
}
.wrap-label {
color: var(--color-text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
width: 100%;
}
.radio {
padding: 0.15rem 0.4rem;
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.surface { .surface {
position: fixed; position: fixed;
@@ -45,6 +45,7 @@ return to the lobby still disposes the stores via `onDestroy`.
<script lang="ts"> <script lang="ts">
import { onDestroy, setContext, untrack } from "svelte"; import { onDestroy, setContext, untrack } from "svelte";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { theme } from "$lib/theme/theme.svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte"; import { appScreen, activeView } from "$lib/app-nav.svelte";
import Header from "$lib/header/header.svelte"; import Header from "$lib/header/header.svelte";
import HistoryBanner from "$lib/header/history-banner.svelte"; import HistoryBanner from "$lib/header/history-banner.svelte";
@@ -562,6 +563,11 @@ return to the lobby still disposes the stores via `onDestroy`.
gameState.dispose(); gameState.dispose();
orderDraft.dispose(); orderDraft.dispose();
selection.dispose(); selection.dispose();
// The in-game header carries an ephemeral light/dark override
// on `theme`; drop it on shell teardown so the lobby (and any
// future re-entry into the game) re-projects the persisted
// preference.
theme.clearOverride();
}); });
function describeBootstrapError(err: unknown): string { function describeBootstrapError(err: unknown): string {
@@ -1,191 +0,0 @@
<!--
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,
type TranslationKey,
} from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { theme, type ThemeChoice } from "$lib/theme/theme.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus";
const THEME_CHOICES: ReadonlyArray<{ id: ThemeChoice; key: TranslationKey }> = [
{ id: "system", key: "game.shell.menu.theme_system" },
{ id: "light", key: "game.shell.menu.theme_light" },
{ id: "dark", key: "game.shell.menu.theme_dark" },
];
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 selectTheme(event: Event): void {
const value = (event.target as HTMLSelectElement).value as ThemeChoice;
theme.setChoice(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"
use:restoreFocus
>
<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>
<label class="field" data-testid="account-menu-theme">
<span>{i18n.t("game.shell.menu.theme")}</span>
<select
data-testid="account-menu-theme-select"
value={theme.choice}
onchange={selectTheme}
>
{#each THEME_CHOICES as entry (entry.id)}
<option value={entry.id}>{i18n.t(entry.key)}</option>
{/each}
</select>
</label>
<label class="field" 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 var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.trigger:hover {
background: var(--color-surface-hover);
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 12rem;
display: flex;
flex-direction: column;
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
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: var(--color-surface-hover);
}
.surface > button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.field select {
font: inherit;
background: var(--color-surface-raised);
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.15rem 0.35rem;
}
</style>
@@ -0,0 +1,60 @@
<!--
In-game ephemeral light/dark theme toggle. Replaces the previous account
menu in the top-right of the game shell. The persisted theme choice
(system/light/dark) lives in the lobby's profile screen; this toggle
only flips an in-memory `override` on the shared `theme` store, so the
game shell can be re-themed without touching the user's persisted
preference. The override is cleared on shell unmount (see
`game-shell.svelte`), so leaving and re-entering the game re-projects
the persisted choice.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import { theme } from "$lib/theme/theme.svelte";
const next = $derived(theme.resolved === "light" ? "dark" : "light");
const label = $derived(
i18n.t(
next === "light"
? "game.shell.theme_toggle.to_light"
: "game.shell.theme_toggle.to_dark",
),
);
function toggle(): void {
theme.setOverride(next);
}
</script>
<button
type="button"
class="theme-toggle"
data-testid="game-mode-theme-toggle"
data-theme={theme.resolved}
aria-label={label}
aria-pressed={theme.resolved === "dark"}
onclick={toggle}
>
<span aria-hidden="true">{theme.resolved === "light" ? "☼" : "☾"}</span>
</button>
<style>
.theme-toggle {
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
font: inherit;
font-size: 1.1rem;
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.theme-toggle:hover {
background: var(--color-surface-hover);
}
</style>
+9 -5
View File
@@ -2,9 +2,13 @@
Top header for the in-game shell. Composes the in-game ID strip Top header for the in-game shell. Composes the in-game ID strip
(race name @ game name) followed by the Phase 26 turn navigator (a (race name @ game name) followed by the Phase 26 turn navigator (a
`← Turn N →` triplet with a popover of every turn), the view `← Turn N →` triplet with a popover of every turn), the view
dropdown / hamburger, and the account menu. The sidebar-toggle slot dropdown / hamburger, and the in-game ephemeral light/dark theme
to its left appears only on tablet viewports (7681024 px) and is toggle. The sidebar-toggle slot to its left appears only on tablet
wired by `+layout.svelte`. viewports (7681024 px) and is wired by `+layout.svelte`.
The persisted theme choice (and the language picker, logout, etc.)
lives in the lobby — the in-game header carries only the ephemeral
toggle for quick visual flips during a session.
The race name is read from the engine's `Report.race`, the game The race name is read from the engine's `Report.race`, the game
name from the lobby's `GameSummary.gameName`. While either piece name from the lobby's `GameSummary.gameName`. While either piece
@@ -24,7 +28,7 @@ absent until Phase 24 wires push-event state.
type GameStateStore, type GameStateStore,
} from "$lib/game-state.svelte"; } from "$lib/game-state.svelte";
import ViewMenu from "./view-menu.svelte"; import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte"; import GameModeThemeToggle from "./game-mode-theme-toggle.svelte";
import TurnNavigator from "./turn-navigator.svelte"; import TurnNavigator from "./turn-navigator.svelte";
type Props = { type Props = {
@@ -78,7 +82,7 @@ absent until Phase 24 wires push-event state.
</button> </button>
<ViewMenu /> <ViewMenu />
<AccountMenu /> <GameModeThemeToggle />
</div> </div>
</header> </header>
+12 -14
View File
@@ -144,16 +144,9 @@ const en = {
"game.shell.menu.close_sidebar": "close sidebar", "game.shell.menu.close_sidebar": "close sidebar",
"game.shell.menu.open_views": "open views menu", "game.shell.menu.open_views": "open views menu",
"game.shell.menu.close_views": "close 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.theme_system": "system",
"game.shell.menu.theme_light": "light",
"game.shell.menu.theme_dark": "dark",
"game.shell.menu.language": "language",
"game.shell.menu.return_to_lobby": "return to lobby", "game.shell.menu.return_to_lobby": "return to lobby",
"game.shell.menu.logout": "logout", "game.shell.theme_toggle.to_light": "switch to light theme",
"game.shell.theme_toggle.to_dark": "switch to dark theme",
"game.shell.coming_soon": "coming soon", "game.shell.coming_soon": "coming soon",
"game.shell.turn.label": "turn {turn}", "game.shell.turn.label": "turn {turn}",
"game.shell.turn.list_item": "turn #{turn}", "game.shell.turn.list_item": "turn #{turn}",
@@ -183,9 +176,6 @@ const en = {
"game.map.toggles.unidentified_planets": "unidentified planets", "game.map.toggles.unidentified_planets": "unidentified planets",
"game.map.toggles.unreachable_planets": "show unreachable planets", "game.map.toggles.unreachable_planets": "show unreachable planets",
"game.map.toggles.visible_hyperspace": "visible hyperspace", "game.map.toggles.visible_hyperspace": "visible hyperspace",
"game.map.toggles.wrap.label": "wrap scrolling",
"game.map.toggles.wrap.torus": "torus",
"game.map.toggles.wrap.no_wrap": "no-wrap",
"game.view.table": "table", "game.view.table": "table",
"game.view.table.planets": "planets", "game.view.table.planets": "planets",
"game.view.table.ship_classes": "ship classes", "game.view.table.ship_classes": "ship classes",
@@ -293,7 +283,6 @@ const en = {
"game.inspector.planet.action.rename": "rename", "game.inspector.planet.action.rename": "rename",
"game.inspector.planet.rename.title": "rename planet", "game.inspector.planet.rename.title": "rename planet",
"game.inspector.planet.rename.confirm": "save", "game.inspector.planet.rename.confirm": "save",
"game.inspector.planet.rename.cancel": "cancel",
"game.inspector.planet.rename.invalid.empty": "name cannot be empty", "game.inspector.planet.rename.invalid.empty": "name cannot be empty",
"game.inspector.planet.rename.invalid.too_long": "name is too long (30 characters max)", "game.inspector.planet.rename.invalid.too_long": "name is too long (30 characters max)",
"game.inspector.planet.rename.invalid.starts_with_special": "name cannot start with a special character", "game.inspector.planet.rename.invalid.starts_with_special": "name cannot start with a special character",
@@ -302,6 +291,8 @@ const en = {
"game.inspector.planet.rename.invalid.whitespace": "name cannot contain spaces", "game.inspector.planet.rename.invalid.whitespace": "name cannot contain spaces",
"game.inspector.planet.rename.invalid.disallowed_character": "name contains disallowed characters", "game.inspector.planet.rename.invalid.disallowed_character": "name contains disallowed characters",
"game.inspector.planet.production.title": "production", "game.inspector.planet.production.title": "production",
"game.inspector.planet.production.main.aria": "production type",
"game.inspector.planet.production.main.placeholder": "(production)",
"game.inspector.planet.production.option.industry": "industry", "game.inspector.planet.production.option.industry": "industry",
"game.inspector.planet.production.option.materials": "materials", "game.inspector.planet.production.option.materials": "materials",
"game.inspector.planet.production.option.research": "research", "game.inspector.planet.production.option.research": "research",
@@ -310,8 +301,14 @@ const en = {
"game.inspector.planet.production.research.weapons": "weapons", "game.inspector.planet.production.research.weapons": "weapons",
"game.inspector.planet.production.research.shields": "shields", "game.inspector.planet.production.research.shields": "shields",
"game.inspector.planet.production.research.cargo": "cargo", "game.inspector.planet.production.research.cargo": "cargo",
"game.inspector.planet.production.target.research.aria": "research target",
"game.inspector.planet.production.target.research.placeholder": "(tech or science)",
"game.inspector.planet.production.target.ship.aria": "ship class",
"game.inspector.planet.production.target.ship.placeholder": "(ship class)",
"game.inspector.planet.production.ship.no_classes": "no ship classes designed yet", "game.inspector.planet.production.ship.no_classes": "no ship classes designed yet",
"game.inspector.planet.cargo.title": "cargo routes", "game.inspector.planet.production.apply": "apply production change",
"game.inspector.planet.production.cancel": "discard production change",
"game.inspector.planet.cargo.placeholder": "cargo routes",
"game.inspector.planet.cargo.slot.col": "colonists", "game.inspector.planet.cargo.slot.col": "colonists",
"game.inspector.planet.cargo.slot.cap": "industry", "game.inspector.planet.cargo.slot.cap": "industry",
"game.inspector.planet.cargo.slot.mat": "materials", "game.inspector.planet.cargo.slot.mat": "materials",
@@ -602,6 +599,7 @@ const en = {
"game.inspector.ship_group.action.invalid.level": "level must be in ({current}, {max}]", "game.inspector.ship_group.action.invalid.level": "level must be in ({current}, {max}]",
"game.inspector.ship_group.action.invalid.fleet_name": "fleet name does not match the entity-name rules", "game.inspector.ship_group.action.invalid.fleet_name": "fleet name does not match the entity-name rules",
"game.inspector.planet.ship_groups.race_filter.aria": "stationed race",
"game.inspector.planet.ship_groups.title": "stationed ship groups", "game.inspector.planet.ship_groups.title": "stationed ship groups",
"game.inspector.planet.ship_groups.row.count": "{count} ships", "game.inspector.planet.ship_groups.row.count": "{count} ships",
"game.inspector.planet.ship_groups.row.mass": "mass {mass}", "game.inspector.planet.ship_groups.row.mass": "mass {mass}",
+12 -14
View File
@@ -145,16 +145,9 @@ const ru: Record<keyof typeof en, string> = {
"game.shell.menu.close_sidebar": "закрыть боковую панель", "game.shell.menu.close_sidebar": "закрыть боковую панель",
"game.shell.menu.open_views": "открыть меню видов", "game.shell.menu.open_views": "открыть меню видов",
"game.shell.menu.close_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.theme_system": "системная",
"game.shell.menu.theme_light": "светлая",
"game.shell.menu.theme_dark": "тёмная",
"game.shell.menu.language": "язык",
"game.shell.menu.return_to_lobby": "вернуться в лобби", "game.shell.menu.return_to_lobby": "вернуться в лобби",
"game.shell.menu.logout": "выйти", "game.shell.theme_toggle.to_light": "переключить на светлую тему",
"game.shell.theme_toggle.to_dark": "переключить на тёмную тему",
"game.shell.coming_soon": "скоро будет", "game.shell.coming_soon": "скоро будет",
"game.shell.turn.label": "ход {turn}", "game.shell.turn.label": "ход {turn}",
"game.shell.turn.list_item": "ход #{turn}", "game.shell.turn.list_item": "ход #{turn}",
@@ -184,9 +177,6 @@ const ru: Record<keyof typeof en, string> = {
"game.map.toggles.unidentified_planets": "неопознанные планеты", "game.map.toggles.unidentified_planets": "неопознанные планеты",
"game.map.toggles.unreachable_planets": "показывать недостижимые планеты", "game.map.toggles.unreachable_planets": "показывать недостижимые планеты",
"game.map.toggles.visible_hyperspace": "видимое гиперпространство", "game.map.toggles.visible_hyperspace": "видимое гиперпространство",
"game.map.toggles.wrap.label": "перенос карты",
"game.map.toggles.wrap.torus": "тор",
"game.map.toggles.wrap.no_wrap": "без переноса",
"game.view.table": "таблица", "game.view.table": "таблица",
"game.view.table.planets": "планеты", "game.view.table.planets": "планеты",
"game.view.table.ship_classes": "классы кораблей", "game.view.table.ship_classes": "классы кораблей",
@@ -294,7 +284,6 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.action.rename": "переименовать", "game.inspector.planet.action.rename": "переименовать",
"game.inspector.planet.rename.title": "переименование планеты", "game.inspector.planet.rename.title": "переименование планеты",
"game.inspector.planet.rename.confirm": "сохранить", "game.inspector.planet.rename.confirm": "сохранить",
"game.inspector.planet.rename.cancel": "отмена",
"game.inspector.planet.rename.invalid.empty": "имя не может быть пустым", "game.inspector.planet.rename.invalid.empty": "имя не может быть пустым",
"game.inspector.planet.rename.invalid.too_long": "имя слишком длинное (максимум 30 символов)", "game.inspector.planet.rename.invalid.too_long": "имя слишком длинное (максимум 30 символов)",
"game.inspector.planet.rename.invalid.starts_with_special": "имя не может начинаться со спецсимвола", "game.inspector.planet.rename.invalid.starts_with_special": "имя не может начинаться со спецсимвола",
@@ -303,6 +292,8 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.rename.invalid.whitespace": "имя не может содержать пробелы", "game.inspector.planet.rename.invalid.whitespace": "имя не может содержать пробелы",
"game.inspector.planet.rename.invalid.disallowed_character": "имя содержит недопустимые символы", "game.inspector.planet.rename.invalid.disallowed_character": "имя содержит недопустимые символы",
"game.inspector.planet.production.title": "производство", "game.inspector.planet.production.title": "производство",
"game.inspector.planet.production.main.aria": "тип производства",
"game.inspector.planet.production.main.placeholder": "(производство)",
"game.inspector.planet.production.option.industry": "промышленность", "game.inspector.planet.production.option.industry": "промышленность",
"game.inspector.planet.production.option.materials": "сырьё", "game.inspector.planet.production.option.materials": "сырьё",
"game.inspector.planet.production.option.research": "исследование", "game.inspector.planet.production.option.research": "исследование",
@@ -311,8 +302,14 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.production.research.weapons": "оружие", "game.inspector.planet.production.research.weapons": "оружие",
"game.inspector.planet.production.research.shields": "щиты", "game.inspector.planet.production.research.shields": "щиты",
"game.inspector.planet.production.research.cargo": "трюм", "game.inspector.planet.production.research.cargo": "трюм",
"game.inspector.planet.production.target.research.aria": "цель исследования",
"game.inspector.planet.production.target.research.placeholder": "(технология или наука)",
"game.inspector.planet.production.target.ship.aria": "класс корабля",
"game.inspector.planet.production.target.ship.placeholder": "(класс корабля)",
"game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы", "game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы",
"game.inspector.planet.cargo.title": "грузовые маршруты", "game.inspector.planet.production.apply": "применить изменение производства",
"game.inspector.planet.production.cancel": "отменить изменение производства",
"game.inspector.planet.cargo.placeholder": "грузовые маршруты",
"game.inspector.planet.cargo.slot.col": "колонисты", "game.inspector.planet.cargo.slot.col": "колонисты",
"game.inspector.planet.cargo.slot.cap": "промышленность", "game.inspector.planet.cargo.slot.cap": "промышленность",
"game.inspector.planet.cargo.slot.mat": "сырьё", "game.inspector.planet.cargo.slot.mat": "сырьё",
@@ -603,6 +600,7 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.ship_group.action.invalid.level": "уровень должен быть в ({current}, {max}]", "game.inspector.ship_group.action.invalid.level": "уровень должен быть в ({current}, {max}]",
"game.inspector.ship_group.action.invalid.fleet_name": "имя флота не соответствует правилам имён сущностей", "game.inspector.ship_group.action.invalid.fleet_name": "имя флота не соответствует правилам имён сущностей",
"game.inspector.planet.ship_groups.race_filter.aria": "раса в орбите",
"game.inspector.planet.ship_groups.title": "корабли на орбите", "game.inspector.planet.ship_groups.title": "корабли на орбите",
"game.inspector.planet.ship_groups.row.count": "{count} кораблей", "game.inspector.planet.ship_groups.row.count": "{count} кораблей",
"game.inspector.planet.ship_groups.row.mass": "масса {mass}", "game.inspector.planet.ship_groups.row.mass": "масса {mass}",
+66 -73
View File
@@ -1,16 +1,20 @@
<!-- <!--
Planet inspector. Renders the documented field set for each planet Planet inspector. Renders the documented field set for each planet
kind (local / other / uninhabited / unidentified) and exposes a kind (local / other / uninhabited / unidentified) and exposes a
Rename action on owned (`local`) planets that opens an inline click-to-edit affordance on the name itself for owned (`local`)
editor. The editor runs the same `validateEntityName` rules as the planets: a click on the name turns it into an inline input with a
server-side validator (parity with `pkg/util/string.go`) and, on single ✓ confirm icon (Escape cancels). The editor runs the same
confirm, appends a `planetRename` command to the local order draft `validateEntityName` rules as the server-side validator (parity with
through the `OrderDraftStore` provided via context. `pkg/util/string.go`) and, on confirm, appends a `planetRename`
command to the local order draft through the `OrderDraftStore`
provided via context.
The read-only path stays unchanged for non-`local` planets. The The read-only path stays unchanged for non-`local` planets. The
inline editor lives directly inside this component per PLAN.md inline editor lives directly inside this component — a separate
Phase 14 — a separate file would be over-abstraction for one input file would be over-abstraction for one input field and a confirm
field with five buttons. button. F8-05 (issue #48 п.13) dropped the separate `Rename`
action button and the explicit `Cancel` button: the name itself is
the entry point, Escape (or unmounting the inspector) reverts.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext, tick } from "svelte"; import { getContext, tick } from "svelte";
@@ -158,37 +162,32 @@ field with five buttons.
> >
<header> <header>
<p class="kind" data-testid="inspector-planet-kind">{kindLabel}</p> <p class="kind" data-testid="inspector-planet-kind">{kindLabel}</p>
{#if planet.kind !== "unidentified"} {#if planet.kind === "local"}
<h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3> {#if renameOpen}
{/if}
{#if planet.kind === "local" && !renameOpen}
<button
type="button"
class="action"
data-testid="inspector-planet-rename-action"
onclick={openRename}
>
{i18n.t("game.inspector.planet.action.rename")}
</button>
{/if}
</header>
{#if planet.kind === "local" && renameOpen}
<div class="rename" data-testid="inspector-planet-rename"> <div class="rename" data-testid="inspector-planet-rename">
<label class="rename-label" for="planet-rename-input">
{i18n.t("game.inspector.planet.rename.title")}
</label>
<input <input
id="planet-rename-input" id="planet-rename-input"
type="text" type="text"
class="rename-input" class="rename-input"
data-testid="inspector-planet-rename-input" data-testid="inspector-planet-rename-input"
aria-label={i18n.t("game.inspector.planet.rename.title")}
bind:value={renameInput} bind:value={renameInput}
bind:this={inputEl} bind:this={inputEl}
onkeydown={onKeyDown} onkeydown={onKeyDown}
aria-invalid={renameValidation.ok ? "false" : "true"} aria-invalid={renameValidation.ok ? "false" : "true"}
aria-describedby={renameValidation.ok ? undefined : "planet-rename-error"} aria-describedby={renameValidation.ok ? undefined : "planet-rename-error"}
/> />
<button
type="button"
class="icon-action icon-action--apply"
data-testid="inspector-planet-rename-confirm"
disabled={!renameValidation.ok || draft === undefined}
aria-label={i18n.t("game.inspector.planet.rename.confirm")}
onclick={() => void confirmRename()}
>
<span aria-hidden="true"></span>
</button>
</div>
{#if !renameValidation.ok} {#if !renameValidation.ok}
<p <p
id="planet-rename-error" id="planet-rename-error"
@@ -198,27 +197,21 @@ field with five buttons.
{renameInvalidMessage} {renameInvalidMessage}
</p> </p>
{/if} {/if}
<div class="rename-actions"> {:else}
<button <button
type="button" type="button"
class="rename-cancel" class="name name--editable"
data-testid="inspector-planet-rename-cancel" data-testid="inspector-planet-name"
onclick={cancelRename} aria-label={i18n.t("game.inspector.planet.action.rename")}
onclick={openRename}
> >
{i18n.t("game.inspector.planet.rename.cancel")} {planet.name}
</button> </button>
<button
type="button"
class="rename-confirm"
data-testid="inspector-planet-rename-confirm"
disabled={!renameValidation.ok || draft === undefined}
onclick={() => void confirmRename()}
>
{i18n.t("game.inspector.planet.rename.confirm")}
</button>
</div>
</div>
{/if} {/if}
{:else if planet.kind !== "unidentified"}
<h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3>
{/if}
</header>
{#if planet.kind === "local"} {#if planet.kind === "local"}
<Production {planet} {localShipClass} {localScience} /> <Production {planet} {localShipClass} {localScience} />
@@ -374,34 +367,35 @@ field with five buttons.
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.8rem; font-size: 0.8rem;
} }
.action { .name--editable {
align-self: flex-start;
margin-top: 0.25rem;
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 1.05rem;
padding: 0.2rem 0.55rem; font-weight: 600;
text-align: left;
padding: 0.1rem 0.25rem;
margin: 0 -0.25rem;
background: transparent; background: transparent;
color: var(--color-text-muted); color: inherit;
border: 1px solid var(--color-border); border: 1px dashed transparent;
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: text;
} }
.action:hover { .name--editable:hover,
color: var(--color-text); .name--editable:focus-visible {
border-color: var(--color-accent); border-color: var(--color-border);
background: var(--color-surface-hover);
} }
.rename { .rename {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 0.35rem; gap: 0.4rem;
}
.rename-label {
font-size: 0.85rem;
color: var(--color-text-muted);
} }
.rename-input { .rename-input {
flex: 1;
min-width: 0;
font: inherit; font: inherit;
padding: 0.3rem 0.5rem; font-size: 1rem;
padding: 0.25rem 0.45rem;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -411,32 +405,31 @@ field with five buttons.
border-color: var(--color-danger); border-color: var(--color-danger);
} }
.rename-error { .rename-error {
margin: 0; margin: 0.2rem 0 0 0;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-danger); color: var(--color-danger);
} }
.rename-actions { .icon-action {
display: flex; flex: 0 0 auto;
gap: 0.4rem;
}
.rename-cancel,
.rename-confirm {
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.95rem;
padding: 0.25rem 0.65rem; line-height: 1;
padding: 0.25rem 0.5rem;
background: transparent; background: transparent;
color: var(--color-text-muted); color: var(--color-text-muted);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
} }
.rename-confirm:not(:disabled):hover, .icon-action:not(:disabled):hover {
.rename-cancel:hover {
color: var(--color-text); color: var(--color-text);
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.rename-confirm:disabled { .icon-action:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
.icon-action--apply:not(:disabled) {
color: var(--color-success);
}
</style> </style>
@@ -1,20 +1,24 @@
<!-- <!--
Phase 16 cargo-routes subsection of the planet inspector. Shows a F8-05 cargo-routes subsection of the planet inspector. Renders one
fixed COL/CAP/MAT/EMP four-slot table for the active local planet, compact row: a single `<select>` with the COL/CAP/MAT/EMP load-types
each slot either empty (with a single Add button) or filled (with (plus a default placeholder option that absorbs the old section
the destination planet's name plus Edit and Remove buttons). Add title) and a context block to its right showing either an `add`
and Edit hand off to the renderer-driven `MapPickService`: the map button when the selected type has no route, or `→ destination` plus
dims out-of-reach planets, draws the cursor-line anchor, and `edit` and `remove` buttons when a route is in place.
resolves with either a chosen destination id or `null` (cancel).
The component is purposely deferential to the existing infrastructure: Picking a load-type out of the dropdown does not commit anything by
- `OrderDraftStore` enforces the `(source, loadType)` collapse rule, itself: the player still has to press add / edit (which hand off to
so the optimistic overlay always matches what the server sees. the renderer-driven `MapPickService`) or remove (which appends a
- `MapPickService.pick(...)` is a renderer-side abstraction; its `removeCargoRoute` command directly). After every action the
source/destination semantics live in `lib/active-view/map.svelte`. dropdown stays on the type that was just acted on, so the result is
- Reach (`40 * driveTech` per `game/internal/model/game/race.go`) visible in place. The `OrderDraftStore.add()` collapse rule keeps at
is computed inline using `torusShortestDelta` to mirror the most one entry per `(source, loadType)` pair, mirroring the engine's
engine's torus distance — see `pkg/util/map.go.deltas`. own constraint.
Reach (`40 * driveTech` per `game/internal/model/game/race.go`) is
still computed inline through `torusShortestDelta`; the picker
shipping in `MapPickService.pick(...)` already mirrors the engine's
torus distance via the F8-07 (#50) fix.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
@@ -59,13 +63,15 @@ The component is purposely deferential to the existing infrastructure:
const disabled = $derived(draft === undefined || pick === undefined); const disabled = $derived(draft === undefined || pick === undefined);
let pendingSlot: CargoLoadType | null = $state(null); let pendingSlot: CargoLoadType | null = $state(null);
let selected = $state<CargoLoadType | "">("");
$effect(() => { $effect(() => {
// Reset the in-flight slot whenever the inspector switches to a // Reset the local UI whenever the inspector switches to a
// different planet so a stale "pick in progress" prompt does // different planet so a stale dropdown selection (or in-flight
// not leak across the selection boundary. // pick prompt) does not leak across the selection boundary.
void planet.number; void planet.number;
pendingSlot = null; pendingSlot = null;
selected = "";
}); });
const SLOT_LABELS: Record<CargoLoadType, TranslationKey> = { const SLOT_LABELS: Record<CargoLoadType, TranslationKey> = {
@@ -78,9 +84,9 @@ The component is purposely deferential to the existing infrastructure:
const currentEntries = $derived( const currentEntries = $derived(
routes.find((r) => r.sourcePlanetNumber === planet.number)?.entries ?? [], routes.find((r) => r.sourcePlanetNumber === planet.number)?.entries ?? [],
); );
// Per-slot derived map keeps the template's {#each} block free of // Per-slot derived map keeps the template free of the
// the {@const}/`find` chain that Svelte 5 sometimes mis-tracks // `.find(...)` chain that Svelte 5 sometimes mis-tracks when the
// when the source array is freshly cloned by `applyOrderOverlay`. // source array is freshly cloned by `applyOrderOverlay`.
const slotEntries = $derived.by(() => { const slotEntries = $derived.by(() => {
const map: Record<CargoLoadType, ReportRoute["entries"][number] | null> = { const map: Record<CargoLoadType, ReportRoute["entries"][number] | null> = {
COL: null, COL: null,
@@ -162,67 +168,82 @@ The component is purposely deferential to the existing infrastructure:
function cancelPick(): void { function cancelPick(): void {
pick?.cancel(); pick?.cancel();
} }
function pickType(event: Event): void {
selected = (event.target as HTMLSelectElement).value as CargoLoadType | "";
}
const activeEntry = $derived(
selected === "" ? null : slotEntries[selected],
);
const selectedSlug = $derived(
selected === "" ? "" : selected.toLowerCase(),
);
</script> </script>
<section class="cargo" data-testid="inspector-planet-cargo"> <section class="cargo" data-testid="inspector-planet-cargo">
<h4 class="title"> <div class="row">
{i18n.t("game.inspector.planet.cargo.title")} <select
</h4> class="select"
<dl class="slots"> data-testid="inspector-planet-cargo-type"
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)} aria-label={i18n.t("game.inspector.planet.cargo.placeholder")}
{@const entry = slotEntries[loadType]} value={selected}
{@const slug = loadType.toLowerCase()} onchange={pickType}
<div class="slot" data-testid={`inspector-planet-cargo-slot-${slug}`}> disabled={disabled || pendingSlot !== null}
<dt class="slot-label" data-testid={`inspector-planet-cargo-slot-${slug}-label`}>
{i18n.t(SLOT_LABELS[loadType])}
</dt>
<dd class="slot-body">
{#if entry === null}
<span
class="empty"
data-testid={`inspector-planet-cargo-slot-${slug}-empty`}
> >
{i18n.t("game.inspector.planet.cargo.empty")} <option value="">
</span> {i18n.t("game.inspector.planet.cargo.placeholder")}
</option>
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)}
<option
value={loadType}
data-testid={`inspector-planet-cargo-type-option-${loadType.toLowerCase()}`}
>
{i18n.t(SLOT_LABELS[loadType])}
</option>
{/each}
</select>
{#if selected !== ""}
{#if activeEntry === null}
<button <button
type="button" type="button"
class="action add" class="action add"
data-testid={`inspector-planet-cargo-slot-${slug}-add`} data-testid={`inspector-planet-cargo-slot-${selectedSlug}-add`}
disabled={disabled || pendingSlot !== null} disabled={disabled || pendingSlot !== null}
onclick={() => void startPick(loadType)} onclick={() => void startPick(selected as CargoLoadType)}
> >
{i18n.t("game.inspector.planet.cargo.add")} {i18n.t("game.inspector.planet.cargo.add")}
</button> </button>
{:else} {:else}
<span <span
class="destination" class="destination"
data-testid={`inspector-planet-cargo-slot-${slug}-destination`} data-testid={`inspector-planet-cargo-slot-${selectedSlug}-destination`}
> >
{destinationName(entry.destinationPlanetNumber)} {destinationName(activeEntry.destinationPlanetNumber)}
</span> </span>
<button <button
type="button" type="button"
class="action edit" class="action edit"
data-testid={`inspector-planet-cargo-slot-${slug}-edit`} data-testid={`inspector-planet-cargo-slot-${selectedSlug}-edit`}
disabled={disabled || pendingSlot !== null} disabled={disabled || pendingSlot !== null}
onclick={() => void startPick(loadType)} onclick={() => void startPick(selected as CargoLoadType)}
> >
{i18n.t("game.inspector.planet.cargo.edit")} {i18n.t("game.inspector.planet.cargo.edit")}
</button> </button>
<button <button
type="button" type="button"
class="action remove" class="action remove"
data-testid={`inspector-planet-cargo-slot-${slug}-remove`} data-testid={`inspector-planet-cargo-slot-${selectedSlug}-remove`}
disabled={disabled || pendingSlot !== null} disabled={disabled || pendingSlot !== null}
onclick={() => void removeRoute(loadType)} onclick={() => void removeRoute(selected as CargoLoadType)}
> >
{i18n.t("game.inspector.planet.cargo.remove")} {i18n.t("game.inspector.planet.cargo.remove")}
</button> </button>
{/if} {/if}
</dd> {/if}
</div> </div>
{/each}
</dl>
{#if pendingSlot !== null} {#if pendingSlot !== null}
<div <div
class="pick-prompt" class="pick-prompt"
@@ -241,7 +262,7 @@ The component is purposely deferential to the existing infrastructure:
{i18n.t("game.inspector.planet.cargo.pick.cancel")} {i18n.t("game.inspector.planet.cargo.pick.cancel")}
</button> </button>
</div> </div>
{:else if reach > 0 && reachableSet().size === 0} {:else if selected !== "" && reach > 0 && reachableSet().size === 0}
<p <p
class="no-destinations" class="no-destinations"
data-testid="inspector-planet-cargo-no-destinations" data-testid="inspector-planet-cargo-no-destinations"
@@ -259,29 +280,7 @@ The component is purposely deferential to the existing infrastructure:
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
} }
.title { .row {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
font-weight: 500;
}
.slots {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.6rem;
}
.slot {
display: contents;
}
.slot-label {
color: var(--color-text-muted);
font-size: 0.85rem;
align-self: center;
}
.slot-body {
margin: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
@@ -289,9 +288,21 @@ The component is purposely deferential to the existing infrastructure:
font-size: 0.9rem; font-size: 0.9rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.empty { .select {
color: var(--color-text-muted); flex: 1 1 8rem;
font-style: italic; min-width: 0;
font: inherit;
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.select:disabled {
cursor: not-allowed;
opacity: 0.5;
} }
.destination { .destination {
color: var(--color-text); color: var(--color-text);
@@ -1,35 +1,31 @@
<!-- <!--
Phase 15 production-controls subsection of the planet inspector. F8-05 production-controls subsection of the planet inspector. Renders
Renders four main segments — industry / materials / research / build two dropdowns on a single row (primary: industry / materials /
ship — and reveals a sub-row when the player picks a category that research / ship; secondary: tech / science / ship class — only for
needs a target (research → tech field or science, build ship → the research and ship contexts) together with green ✓ apply and
designed class). Every leaf click appends a `setProductionType` yellow ✗ cancel icon buttons. The apply button becomes enabled when
command to the local order draft via `OrderDraftStore`; the the row selection differs from the planet's current effective
collapse-by-`planetNumber` rule inside `add` keeps at most one production (post-overlay) and the choice is complete (industry /
production choice per planet. materials need no secondary, research / ship require one). Apply
appends a `setProductionType` command to the local order draft via
`OrderDraftStore`; the collapse-by-`planetNumber` rule inside `add`
keeps at most one production choice per planet. Cancel resets the
local row state to the current effective value — it does **not**
revoke an in-flight command (that is the Order tab's responsibility).
The currently-active segment is derived from `planet.production` Until F8-05 every leaf click auto-submitted; the apply gate is the
new shape. The active-on-row state is derived from `planet.production`
through a parser that mirrors the engine's through a parser that mirrors the engine's
`Cache.PlanetProductionDisplayName` mapping. While the player is `Cache.PlanetProductionDisplayName` mapping. Whenever the inspector
mid-navigation (e.g. clicked Research but has not picked a tech yet) switches to a different planet or `planet.production` itself changes
a transient `expandedMain` override widens the visible state so the (typically after a successful apply round-trip projected by
sub-row can appear without forcing the player to commit a choice `applyOrderOverlay`), the row re-seeds from the new parsed value.
first; the override resets whenever the inspector switches to a
different planet or after any leaf click.
Phase 15 deliberately defers the per-type forecast number — see The Research target list combines the four tech display strings with
`ui/docs/calc-bridge.md` for the gap analysis. The component does each defined science from `localScience`. A science name shadows the
not render forecast text; the existing `freeIndustry` ("free tech display string with the same text the engine sends a single
production") row in the parent inspector is unchanged. ambiguous display string in `planet.production`; user-defined
sciences win because they carry more user intent.
Phase 21 widens the Research sub-row: in addition to the four tech
buttons the player sees one extra button per defined science from
`localScience`. The active highlight prefers a science-name match
over the four tech display strings, so a science deliberately named
exactly "Drive" / "Weapons" / "Shields" / "Cargo" shadows the
matching tech button (the engine sends a single ambiguous display
string in `planet.production`; user-defined sciences win because
they carry more user intent).
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
@@ -59,26 +55,10 @@ they carry more user intent).
); );
const disabled = draft === undefined; const disabled = draft === undefined;
let expandedMain: MainSegment | null = $state(null); type TechFbs = "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO";
const parsedMain = $derived(
parseMain(planet.production, localShipClass, localScience),
);
const selectedMain = $derived(expandedMain ?? parsedMain);
const activeResearch = $derived(parseResearch(planet.production, localScience));
const activeScience = $derived(parseScience(planet.production, localScience));
const activeShip = $derived(parseShip(planet.production, localShipClass));
$effect(() => {
// Reset the expand-override whenever the inspector switches to a
// different planet so a stale category does not leak across the
// selection boundary.
void planet.number;
expandedMain = null;
});
const RESEARCH_OPTIONS: ReadonlyArray<{ const RESEARCH_OPTIONS: ReadonlyArray<{
fbs: ProductionType; fbs: TechFbs;
slug: "drive" | "weapons" | "shields" | "cargo"; slug: "drive" | "weapons" | "shields" | "cargo";
labelKey: TranslationKey; labelKey: TranslationKey;
}> = [ }> = [
@@ -128,14 +108,14 @@ they carry more user intent).
return classes.some((c) => c.name === value) ? "ship" : null; return classes.some((c) => c.name === value) ? "ship" : null;
} }
function parseResearch( function parseTarget(
value: string | null, value: string | null,
classes: ShipClassSummary[],
sciences: ScienceSummary[], sciences: ScienceSummary[],
): ProductionType | null { ): string | null {
// A science name shadows the four tech display strings — when a if (value === null || value === "") return null;
// science matches we surface no tech-button highlight so the // Science wins over tech display string (same shadowing rule).
// science button gets the active styling instead. if (sciences.some((s) => s.name === value)) return value;
if (value !== null && sciences.some((s) => s.name === value)) return null;
switch (value) { switch (value) {
case "Drive": case "Drive":
return "DRIVE"; return "DRIVE";
@@ -145,54 +125,88 @@ they carry more user intent).
return "SHIELDS"; return "SHIELDS";
case "Cargo": case "Cargo":
return "CARGO"; return "CARGO";
default:
return null;
} }
}
function parseScience(
value: string | null,
sciences: ScienceSummary[],
): string | null {
if (value === null || value === "") return null;
return sciences.some((s) => s.name === value) ? value : null;
}
function parseShip(
value: string | null,
classes: ShipClassSummary[],
): string | null {
if (value === null || value === "") return null;
return classes.some((c) => c.name === value) ? value : null; return classes.some((c) => c.name === value) ? value : null;
} }
function clickMain(segment: MainSegment): void { const parsedMain = $derived(
if (segment === "industry") { parseMain(planet.production, localShipClass, localScience),
void emit("CAP", ""); );
expandedMain = null; const parsedTarget = $derived(
parseTarget(planet.production, localShipClass, localScience),
);
let mainSel = $state<MainSegment | "">("");
let targetSel = $state<string>("");
$effect(() => {
// Reset row state whenever the inspector switches to a different
// planet or the effective production changes (e.g. after a
// successful apply projected by the overlay). Reads are tracked
// even though the assignments do not consume the values.
void planet.number;
void parsedMain;
void parsedTarget;
mainSel = parsedMain ?? "";
targetSel = parsedTarget ?? "";
});
const needsTarget = $derived(mainSel === "research" || mainSel === "ship");
const dirty = $derived(
(mainSel === "" ? null : mainSel) !== parsedMain
|| (targetSel === "" ? null : targetSel) !== parsedTarget,
);
const applyDisabled = $derived(
disabled
|| mainSel === ""
|| (needsTarget && targetSel === "")
|| !dirty,
);
const cancelDisabled = $derived(disabled || !dirty);
function pickMain(event: Event): void {
const value = (event.target as HTMLSelectElement).value as MainSegment | "";
mainSel = value;
// Switching the primary list clears any pending secondary
// choice — the picker for the new main might not even include
// the previous target.
targetSel = "";
}
function pickTarget(event: Event): void {
targetSel = (event.target as HTMLSelectElement).value;
}
async function applyRow(): Promise<void> {
if (applyDisabled || draft === undefined || mainSel === "") return;
if (mainSel === "industry") {
await emit("CAP", "");
return; return;
} }
if (segment === "materials") { if (mainSel === "materials") {
void emit("MAT", ""); await emit("MAT", "");
expandedMain = null;
return; return;
} }
expandedMain = segment; if (mainSel === "research") {
const tech = RESEARCH_OPTIONS.find((o) => o.fbs === targetSel);
if (tech !== undefined) {
await emit(tech.fbs, "");
} else {
await emit("SCIENCE", targetSel);
}
return;
}
if (mainSel === "ship") {
await emit("SHIP", targetSel);
}
} }
function clickResearch(value: ProductionType): void { function cancelRow(): void {
void emit(value, ""); mainSel = parsedMain ?? "";
expandedMain = null; targetSel = parsedTarget ?? "";
}
function clickScience(name: string): void {
void emit("SCIENCE", name);
expandedMain = null;
}
function clickShip(name: string): void {
void emit("SHIP", name);
expandedMain = null;
} }
async function emit( async function emit(
@@ -214,103 +228,113 @@ they carry more user intent).
<h4 class="title"> <h4 class="title">
{i18n.t("game.inspector.planet.production.title")} {i18n.t("game.inspector.planet.production.title")}
</h4> </h4>
<div class="row main"> <div class="row">
<button <select
type="button" class="select"
class="seg" data-testid="inspector-planet-production-main"
class:active={selectedMain === "industry"} aria-label={i18n.t("game.inspector.planet.production.main.aria")}
data-testid="inspector-planet-production-segment-industry" value={mainSel}
disabled={disabled} onchange={pickMain}
onclick={() => clickMain("industry")} {disabled}
> >
<option value="">
{i18n.t("game.inspector.planet.production.main.placeholder")}
</option>
<option value="industry">
{i18n.t("game.inspector.planet.production.option.industry")} {i18n.t("game.inspector.planet.production.option.industry")}
</button> </option>
<button <option value="materials">
type="button"
class="seg"
class:active={selectedMain === "materials"}
data-testid="inspector-planet-production-segment-materials"
disabled={disabled}
onclick={() => clickMain("materials")}
>
{i18n.t("game.inspector.planet.production.option.materials")} {i18n.t("game.inspector.planet.production.option.materials")}
</button> </option>
<button <option value="research">
type="button"
class="seg"
class:active={selectedMain === "research"}
data-testid="inspector-planet-production-segment-research"
disabled={disabled}
onclick={() => clickMain("research")}
>
{i18n.t("game.inspector.planet.production.option.research")} {i18n.t("game.inspector.planet.production.option.research")}
</button> </option>
<button <option value="ship">
type="button"
class="seg"
class:active={selectedMain === "ship"}
data-testid="inspector-planet-production-segment-ship"
disabled={disabled}
onclick={() => clickMain("ship")}
>
{i18n.t("game.inspector.planet.production.option.ship")} {i18n.t("game.inspector.planet.production.option.ship")}
</button> </option>
</div> </select>
{#if selectedMain === "research"} {#if needsTarget}
<div class="row sub" data-testid="inspector-planet-production-research-row"> <select
class="select"
data-testid="inspector-planet-production-target"
aria-label={i18n.t(
mainSel === "research"
? "game.inspector.planet.production.target.research.aria"
: "game.inspector.planet.production.target.ship.aria",
)}
value={targetSel}
onchange={pickTarget}
{disabled}
>
<option value="">
{i18n.t(
mainSel === "research"
? "game.inspector.planet.production.target.research.placeholder"
: "game.inspector.planet.production.target.ship.placeholder",
)}
</option>
{#if mainSel === "research"}
{#each RESEARCH_OPTIONS as option (option.fbs)} {#each RESEARCH_OPTIONS as option (option.fbs)}
<button <option
type="button" value={option.fbs}
class="sub-seg" data-testid={`inspector-planet-production-target-option-${option.slug}`}
class:active={activeResearch === option.fbs}
data-testid={`inspector-planet-production-research-${option.slug}`}
disabled={disabled}
onclick={() => clickResearch(option.fbs)}
> >
{i18n.t(option.labelKey)} {i18n.t(option.labelKey)}
</button> </option>
{/each} {/each}
{#each localScience as sci (sci.name)} {#each localScience as sci (sci.name)}
<button <option
type="button" value={sci.name}
class="sub-seg" data-testid={`inspector-planet-production-target-option-science-${sci.name}`}
class:active={activeScience === sci.name}
data-testid={`inspector-planet-production-science-${sci.name}`}
disabled={disabled}
onclick={() => clickScience(sci.name)}
> >
{sci.name} {sci.name}
</button> </option>
{/each} {/each}
</div> {:else if mainSel === "ship"}
{/if}
{#if selectedMain === "ship"}
<div class="row sub" data-testid="inspector-planet-production-ship-row">
{#if localShipClass.length === 0} {#if localShipClass.length === 0}
<p <option
class="empty" value=""
disabled
data-testid="inspector-planet-production-ship-empty" data-testid="inspector-planet-production-ship-empty"
> >
{i18n.t("game.inspector.planet.production.ship.no_classes")} {i18n.t("game.inspector.planet.production.ship.no_classes")}
</p> </option>
{:else} {:else}
{#each localShipClass as cls (cls.name)} {#each localShipClass as cls (cls.name)}
<button <option
type="button" value={cls.name}
class="sub-seg" data-testid={`inspector-planet-production-target-option-ship-${cls.name}`}
class:active={activeShip === cls.name}
data-testid={`inspector-planet-production-ship-${cls.name}`}
disabled={disabled}
onclick={() => clickShip(cls.name)}
> >
{cls.name} {cls.name}
</button> </option>
{/each} {/each}
{/if} {/if}
</div>
{/if} {/if}
</select>
{/if}
<button
type="button"
class="icon-action icon-action--apply"
data-testid="inspector-planet-production-apply"
disabled={applyDisabled}
aria-label={i18n.t("game.inspector.planet.production.apply")}
onclick={() => void applyRow()}
>
<span aria-hidden="true"></span>
</button>
<button
type="button"
class="icon-action icon-action--cancel"
data-testid="inspector-planet-production-cancel"
disabled={cancelDisabled}
aria-label={i18n.t("game.inspector.planet.production.cancel")}
onclick={cancelRow}
>
<span aria-hidden="true"></span>
</button>
</div>
</section> </section>
<style> <style>
@@ -327,43 +351,50 @@ they carry more user intent).
} }
.row { .row {
display: flex; display: flex;
gap: 0.3rem; align-items: center;
gap: 0.35rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.row.sub { .select {
padding-left: 0.6rem; flex: 1 1 6rem;
} min-width: 0;
.seg,
.sub-seg {
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
padding: 0.25rem 0.55rem; padding: 0.2rem 0.4rem;
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.icon-action {
flex: 0 0 auto;
font: inherit;
font-size: 0.9rem;
line-height: 1;
padding: 0.25rem 0.5rem;
background: transparent; background: transparent;
color: var(--color-text-muted); color: var(--color-text-muted);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
} }
.seg:not(:disabled):hover, .icon-action:not(:disabled):hover {
.sub-seg:not(:disabled):hover {
color: var(--color-text); color: var(--color-text);
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.seg.active, .icon-action:disabled {
.sub-seg.active {
color: var(--color-text);
border-color: var(--color-accent);
background: var(--color-accent-subtle);
}
.seg:disabled,
.sub-seg:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.4;
} }
.empty { .icon-action--apply:not(:disabled) {
margin: 0; color: var(--color-success);
font-size: 0.8rem; }
color: var(--color-text-muted); .icon-action--cancel:not(:disabled) {
font-style: italic; color: var(--color-warning);
} }
</style> </style>
@@ -10,14 +10,24 @@ best-effort:
typed contract does not carry per-group ownership outside typed contract does not carry per-group ownership outside
battle rosters. battle rosters.
Phase 20 makes own-ship rows interactive: clicking a row pivots Phase 20 made own-ship rows interactive: clicking a row pivots
the inspector to the corresponding ship-group inspector through the inspector to the corresponding ship-group inspector through
the shared `SelectionStore`. The actions panel mounts on top of the shared `SelectionStore`. The actions panel mounts on top of
the existing ship-group inspector, so the row is the on-planet the existing ship-group inspector, so the row is the on-planet
entry point for Send / Load / Modernize / etc. Foreign rows stay entry point for Send / Load / Modernize / etc. Foreign rows stay
non-interactive — there are no actions to drive against another non-interactive — there are no actions to drive against another
race's fleet. Phase 21+ will reuse the same row shape inside the race's fleet.
ship-groups table view with an additional `(planet, race)` filter.
F8-05 (issue #48 п.32) moved the race column into a dropdown
above the table: the previous "race | class | count | mass"
layout overflowed horizontally on narrow viewports. The dropdown
seeds with the player's own race when local groups are stationed
here, otherwise with the first race alphabetically; both cases
are after sorting `availableRaces` alphabetically so the picker
is stable across re-mounts. When a single race is in orbit the
dropdown is hidden — there is nothing to choose — and the table
renders straight through. The race column is dropped in both
modes because the dropdown already names the active race.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
@@ -91,6 +101,55 @@ ship-groups table view with an additional `(planet, race)` filter.
return rows; return rows;
}); });
const ownStationedHere = $derived(
stationedRows.some((r) => r.selectable),
);
const availableRaces = $derived.by(() => {
const set = new Set<string>();
for (const row of stationedRows) set.add(row.race);
return Array.from(set).sort((a, b) => a.localeCompare(b));
});
const ownRaceLabel = $derived(
localRace || i18n.t("game.inspector.planet.ship_groups.race.unknown"),
);
function defaultRace(races: ReadonlyArray<string>): string {
if (races.length === 0) return "";
if (ownStationedHere && races.includes(ownRaceLabel)) return ownRaceLabel;
return races[0]!;
}
let selectedRace = $state<string>("");
$effect(() => {
// Re-seed whenever the inspector switches planets or the
// stationed roster changes (new arrivals after a turn).
// Preserve the player's pick if it is still represented;
// otherwise fall back to the documented default.
void planet.number;
const races = availableRaces;
if (races.length === 0) {
selectedRace = "";
return;
}
if (selectedRace === "" || !races.includes(selectedRace)) {
selectedRace = defaultRace(races);
}
});
const showFilter = $derived(availableRaces.length > 1);
const filteredRows = $derived(
showFilter
? stationedRows.filter((r) => r.race === selectedRace)
: stationedRows,
);
function pickRace(event: Event): void {
selectedRace = (event.target as HTMLSelectElement).value;
}
function selectLocalGroup(groupId: string): void { function selectLocalGroup(groupId: string): void {
if (selection === undefined) return; if (selection === undefined) return;
selection.selectShipGroup({ variant: "local", id: groupId }); selection.selectShipGroup({ variant: "local", id: groupId });
@@ -99,9 +158,26 @@ ship-groups table view with an additional `(planet, race)` filter.
{#if stationedRows.length > 0} {#if stationedRows.length > 0}
<section class="ship-groups" data-testid="inspector-planet-ship-groups"> <section class="ship-groups" data-testid="inspector-planet-ship-groups">
<div class="head">
<h4>{i18n.t("game.inspector.planet.ship_groups.title")}</h4> <h4>{i18n.t("game.inspector.planet.ship_groups.title")}</h4>
{#if showFilter}
<select
class="race-select"
data-testid="inspector-planet-ship-groups-race-filter"
aria-label={i18n.t(
"game.inspector.planet.ship_groups.race_filter.aria",
)}
value={selectedRace}
onchange={pickRace}
>
{#each availableRaces as race (race)}
<option value={race}>{race}</option>
{/each}
</select>
{/if}
</div>
<ul class="rows"> <ul class="rows">
{#each stationedRows as row (row.key)} {#each filteredRows as row (row.key)}
<li class="row" data-testid="inspector-planet-ship-groups-row"> <li class="row" data-testid="inspector-planet-ship-groups-row">
{#if row.selectable && row.groupId !== null} {#if row.selectable && row.groupId !== null}
{@const groupId = row.groupId} {@const groupId = row.groupId}
@@ -111,9 +187,6 @@ ship-groups table view with an additional `(planet, race)` filter.
data-testid="inspector-planet-ship-groups-select" data-testid="inspector-planet-ship-groups-select"
onclick={() => selectLocalGroup(groupId)} onclick={() => selectLocalGroup(groupId)}
> >
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span> <span class="class">{row.class}</span>
<span class="count"> <span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", { {i18n.t("game.inspector.planet.ship_groups.row.count", {
@@ -127,9 +200,6 @@ ship-groups table view with an additional `(planet, race)` filter.
</span> </span>
</button> </button>
{:else} {:else}
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span> <span class="class">{row.class}</span>
<span class="count"> <span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", { {i18n.t("game.inspector.planet.ship_groups.row.count", {
@@ -154,6 +224,12 @@ ship-groups table view with an additional `(planet, race)` filter.
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
} }
.head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
h4 { h4 {
margin: 0; margin: 0;
font-size: 0.85rem; font-size: 0.85rem;
@@ -161,6 +237,18 @@ ship-groups table view with an additional `(planet, race)` filter.
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.race-select {
flex: 1 1 auto;
min-width: 0;
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.4rem;
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.rows { .rows {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -177,7 +265,7 @@ ship-groups table view with an additional `(planet, race)` filter.
.row > span, .row > span,
.row > .select { .row > .select {
display: grid; display: grid;
grid-template-columns: 1fr 1fr auto auto; grid-template-columns: 1fr auto auto;
gap: 0.5rem; gap: 0.5rem;
} }
.select { .select {
@@ -195,9 +283,6 @@ ship-groups table view with an additional `(planet, race)` filter.
border-color: var(--color-border); border-color: var(--color-border);
background: var(--color-surface-hover); background: var(--color-surface-hover);
} }
.race {
font-weight: 600;
}
.class { .class {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
+40 -1
View File
@@ -10,6 +10,13 @@
* `data-theme` from the same `localStorage` key before the app boots; * `data-theme` from the same `localStorage` key before the app boots;
* this store mirrors that logic and takes over once mounted, including * this store mirrors that logic and takes over once mounted, including
* reacting to OS theme changes while the choice is `system`. * reacting to OS theme changes while the choice is `system`.
*
* On top of the persisted choice the store carries an ephemeral
* `override` channel: while non-null it short-circuits `resolved` so the
* in-game light/dark toggle can flip the document theme without touching
* the lobby-side preference. The override lives in memory only — leaving
* the game shell (or any other consumer calling `clearOverride()`)
* re-projects the persisted choice.
*/ */
/** A user's theme preference; `system` follows the OS setting. */ /** A user's theme preference; `system` follows the OS setting. */
@@ -45,6 +52,7 @@ function systemTheme(): ResolvedTheme {
class ThemeStore { class ThemeStore {
#choice = $state<ThemeChoice>(readStoredChoice()); #choice = $state<ThemeChoice>(readStoredChoice());
#system = $state<ResolvedTheme>(systemTheme()); #system = $state<ResolvedTheme>(systemTheme());
#override = $state<ResolvedTheme | null>(null);
constructor() { constructor() {
if ( if (
@@ -55,7 +63,9 @@ class ThemeStore {
.matchMedia(SYSTEM_LIGHT_QUERY) .matchMedia(SYSTEM_LIGHT_QUERY)
.addEventListener("change", (event: MediaQueryListEvent) => { .addEventListener("change", (event: MediaQueryListEvent) => {
this.#system = event.matches ? "light" : "dark"; this.#system = event.matches ? "light" : "dark";
if (this.#choice === "system") this.#apply(); if (this.#choice === "system" && this.#override === null) {
this.#apply();
}
}); });
} }
this.#apply(); this.#apply();
@@ -66,8 +76,17 @@ class ThemeStore {
return this.#choice; return this.#choice;
} }
/**
* The current ephemeral override (set by the in-game toggle) or
* `null` when no override is active.
*/
get override(): ResolvedTheme | null {
return this.#override;
}
/** The concrete theme currently applied to the document. */ /** The concrete theme currently applied to the document. */
get resolved(): ResolvedTheme { get resolved(): ResolvedTheme {
if (this.#override !== null) return this.#override;
return this.#choice === "system" ? this.#system : this.#choice; return this.#choice === "system" ? this.#system : this.#choice;
} }
@@ -80,6 +99,26 @@ class ThemeStore {
this.#apply(); this.#apply();
} }
/**
* Set the ephemeral override. The override is not persisted; it lives
* until `clearOverride()` (or another `setOverride` call) replaces it.
*/
setOverride(value: ResolvedTheme): void {
this.#override = value;
this.#apply();
}
/**
* Drop the ephemeral override so the document re-projects the
* persisted preference. Cheap to call on every game-shell unmount —
* a no-op when no override was set.
*/
clearOverride(): void {
if (this.#override === null) return;
this.#override = null;
this.#apply();
}
#apply(): void { #apply(): void {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
document.documentElement.dataset.theme = this.resolved; document.documentElement.dataset.theme = this.resolved;
+10 -10
View File
@@ -44,17 +44,17 @@ test.describe("keyboard navigation", () => {
await expect(page.locator("#active-view-host")).toBeFocused(); await expect(page.locator("#active-view-host")).toBeFocused();
}); });
test("Escape closes the account menu and returns focus to its trigger", async ({ test("game-mode theme toggle is keyboard activatable", async ({ page }) => {
page,
}) => {
await bootShell(page); await bootShell(page);
await page.getByTestId("account-menu-trigger").click(); const toggle = page.getByTestId("game-mode-theme-toggle");
await expect(page.getByTestId("account-menu-list")).toBeVisible(); await toggle.focus();
// Move focus into the menu, then dismiss with Escape. await expect(toggle).toBeFocused();
await page.getByTestId("account-menu-theme-select").focus(); const before = await toggle.getAttribute("data-theme");
await page.keyboard.press("Escape"); await page.keyboard.press("Enter");
await expect(page.getByTestId("account-menu-list")).toBeHidden(); const after = await toggle.getAttribute("data-theme");
await expect(page.getByTestId("account-menu-trigger")).toBeFocused(); expect(after).not.toBe(before);
// The toggle keeps focus across an activation.
await expect(toggle).toBeFocused();
}); });
test("sidebar tabs move with the arrow keys (roving)", async ({ page }) => { test("sidebar tabs move with the arrow keys (roving)", async ({ page }) => {
+5 -4
View File
@@ -394,9 +394,8 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText( await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
SOURCE_PLANET.name, SOURCE_PLANET.name,
); );
await expect( const typeSelect = sidebar.getByTestId("inspector-planet-cargo-type");
sidebar.getByTestId("inspector-planet-cargo-slot-col-empty"), await typeSelect.selectOption("COL");
).toBeVisible();
// Add a COL route. Expect pick-mode to open with `reachableIds` // Add a COL route. Expect pick-mode to open with `reachableIds`
// covering only the two near planets. // covering only the two near planets.
@@ -470,6 +469,7 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
}); });
// Add a CAP route to confirm slots coexist. // Add a CAP route to confirm slots coexist.
await typeSelect.selectOption("CAP");
await page await page
.getByTestId("inspector-planet-cargo-slot-cap-add") .getByTestId("inspector-planet-cargo-slot-cap-add")
.first() .first()
@@ -495,12 +495,13 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
.toBe(6); .toBe(6);
// Remove the COL route. // Remove the COL route.
await typeSelect.selectOption("COL");
await page await page
.getByTestId("inspector-planet-cargo-slot-col-remove") .getByTestId("inspector-planet-cargo-slot-col-remove")
.first() .first()
.click(); .click();
await expect( await expect(
page.getByTestId("inspector-planet-cargo-slot-col-empty").first(), page.getByTestId("inspector-planet-cargo-slot-col-add").first(),
).toBeVisible({ timeout: 10000 }); ).toBeVisible({ timeout: 10000 });
await expect await expect
.poll(() => handle.lastRouteRemove, { timeout: 10000 }) .poll(() => handle.lastRouteRemove, { timeout: 10000 })
+1 -1
View File
@@ -52,7 +52,7 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
"turn", "turn",
); );
await expect(page.getByTestId("view-menu-trigger")).toBeVisible(); await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
await expect(page.getByTestId("account-menu-trigger")).toBeVisible(); await expect(page.getByTestId("game-mode-theme-toggle")).toBeVisible();
}); });
test("header view-menu navigates to every active view", async ({ page }) => { test("header view-menu navigates to every active view", async ({ page }) => {
-44
View File
@@ -9,8 +9,6 @@
// flag mirroring the renderer's hide set. The spec counts the // flag mirroring the renderer's hide set. The spec counts the
// visible-foreign-planet primitives, etc. // visible-foreign-planet primitives, etc.
// * `getMapFog()` — the current visibility-fog circle list. // * `getMapFog()` — the current visibility-fog circle list.
// * `getMapCamera()` — the wrap-mode test reads the centre before
// and after the flip to confirm camera preservation.
// * `getMapRenderCount()` — painted-frame counter used by the // * `getMapRenderCount()` — painted-frame counter used by the
// render-on-demand specs at the bottom of this file: an idle map // render-on-demand specs at the bottom of this file: an idle map
// must not keep repainting, and a released drag must not coast // must not keep repainting, and a released drag must not coast
@@ -330,48 +328,6 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o
); );
}); });
test("wrap mode radios flip the renderer and the camera centre survives", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
// Confirm the renderer starts in torus mode.
await page.waitForFunction(
() => window.__galaxyDebug?.getMapMode?.() === "torus",
);
const initial = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(),
);
expect(initial).not.toBeNull();
const startCentre = initial!.camera;
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-wrap-no-wrap").click();
// `setWrapMode` triggers a full Pixi remount; wait for the
// renderer to settle into the new mode and the debug surface to
// re-register before reading the camera. The mode provider is
// re-bound inside `runSerializedMount` after `createRenderer`
// resolves, so observing `getMapMode() === "no-wrap"` is the
// canonical "remount complete" signal.
await page.waitForFunction(
() => window.__galaxyDebug?.getMapMode?.() === "no-wrap",
);
const after = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(),
);
expect(after).not.toBeNull();
expect(
Math.abs(after!.camera.centerX - startCentre.centerX),
).toBeLessThanOrEqual(1);
expect(
Math.abs(after!.camera.centerY - startCentre.centerY),
).toBeLessThanOrEqual(1);
});
test("toggle state persists across a page reload", async ({ page }) => { test("toggle state persists across a page reload", async ({ page }) => {
await mockGateway(page, { currentTurn: 1 }); await mockGateway(page, { currentTurn: 1 });
await bootSession(page); await bootSession(page);
+1 -1
View File
@@ -263,7 +263,7 @@ async function clickPlanetCentre(page: Page): Promise<void> {
async function startRename(page: Page, newName: string): Promise<void> { async function startRename(page: Page, newName: string): Promise<void> {
await clickPlanetCentre(page); await clickPlanetCentre(page);
const sidebar = page.getByTestId("sidebar-tool-inspector"); const sidebar = page.getByTestId("sidebar-tool-inspector");
await sidebar.getByTestId("inspector-planet-rename-action").click(); await sidebar.getByTestId("inspector-planet-name").click();
const input = sidebar.getByTestId("inspector-planet-rename-input"); const input = sidebar.getByTestId("inspector-planet-rename-input");
await input.fill(newName); await input.fill(newName);
await sidebar.getByTestId("inspector-planet-rename-confirm").click(); await sidebar.getByTestId("inspector-planet-rename-confirm").click();
+22 -20
View File
@@ -301,16 +301,22 @@ test("switching production three times collapses to one auto-synced row", async
const sidebar = page.getByTestId("sidebar-tool-inspector"); const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth"); await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
// Initial state: report.production = "Drive" → research segment is const mainSelect = sidebar.getByTestId("inspector-planet-production-main");
// active, sub-row reveals Drive as the highlighted tech. const targetSelect = sidebar.getByTestId(
await expect( "inspector-planet-production-target",
sidebar.getByTestId("inspector-planet-production-segment-research"), );
).toHaveClass(/active/); const applyBtn = sidebar.getByTestId("inspector-planet-production-apply");
// Click 1: Industry → CAP // Initial state: report.production = "Drive" → main is "research"
await sidebar // and the target is "DRIVE"; both apply/cancel start inert.
.getByTestId("inspector-planet-production-segment-industry") await expect(mainSelect).toHaveValue("research");
.click(); await expect(targetSelect).toHaveValue("DRIVE");
await expect(applyBtn).toBeDisabled();
// Pick 1: Industry + ✓ → CAP
await mainSelect.selectOption("industry");
await expect(applyBtn).toBeEnabled();
await applyBtn.click();
await page.getByTestId("sidebar-tab-order").click(); await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order"); const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount( await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
@@ -323,11 +329,10 @@ test("switching production three times collapses to one auto-synced row", async
"applied", "applied",
); );
// Click 2: Materials → MAT (replaces CAP via collapse) // Pick 2: Materials + ✓ → MAT (replaces CAP via collapse)
await page.getByTestId("sidebar-tab-inspector").click(); await page.getByTestId("sidebar-tab-inspector").click();
await sidebar await mainSelect.selectOption("materials");
.getByTestId("inspector-planet-production-segment-materials") await applyBtn.click();
.click();
await page.getByTestId("sidebar-tab-order").click(); await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount( await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
1, 1,
@@ -336,14 +341,11 @@ test("switching production three times collapses to one auto-synced row", async
"Material", "Material",
); );
// Click 3: Build Ship → expand sub-row → Scout (replaces MAT) // Pick 3: Build Ship → target select appears → Scout + ✓ (replaces MAT)
await page.getByTestId("sidebar-tab-inspector").click(); await page.getByTestId("sidebar-tab-inspector").click();
await sidebar await mainSelect.selectOption("ship");
.getByTestId("inspector-planet-production-segment-ship") await targetSelect.selectOption(SHIP_CLASS);
.click(); await applyBtn.click();
await sidebar
.getByTestId(`inspector-planet-production-ship-${SHIP_CLASS}`)
.click();
await page.getByTestId("sidebar-tab-order").click(); await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount( await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
1, 1,
+2 -2
View File
@@ -245,7 +245,7 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
const sidebar = page.getByTestId("sidebar-tool-inspector"); const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth"); await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
await sidebar.getByTestId("inspector-planet-rename-action").click(); await sidebar.getByTestId("inspector-planet-name").click();
const input = sidebar.getByTestId("inspector-planet-rename-input"); const input = sidebar.getByTestId("inspector-planet-rename-input");
await input.fill("New-Earth"); await input.fill("New-Earth");
await sidebar.getByTestId("inspector-planet-rename-confirm").click(); await sidebar.getByTestId("inspector-planet-rename-confirm").click();
@@ -312,7 +312,7 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
); );
await clickPlanetCentre(page); await clickPlanetCentre(page);
const sidebar = page.getByTestId("sidebar-tool-inspector"); const sidebar = page.getByTestId("sidebar-tool-inspector");
await sidebar.getByTestId("inspector-planet-rename-action").click(); await sidebar.getByTestId("inspector-planet-name").click();
await sidebar.getByTestId("inspector-planet-rename-input").fill("Mars-2"); await sidebar.getByTestId("inspector-planet-rename-input").fill("Mars-2");
await sidebar.getByTestId("inspector-planet-rename-confirm").click(); await sidebar.getByTestId("inspector-planet-rename-confirm").click();
+19 -14
View File
@@ -419,23 +419,28 @@ test("planet production picker exposes user sciences in the Research sub-row", a
const sidebar = page.getByTestId("sidebar-tool-inspector"); const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth"); await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
// Expand the Research segment. const mainSelect = sidebar.getByTestId("inspector-planet-production-main");
await sidebar await mainSelect.selectOption("research");
.getByTestId("inspector-planet-production-segment-research")
.click();
// Tech buttons + the user's science button are both rendered. // Tech options and the user's science option are both rendered.
await expect( const targetSelect = sidebar.getByTestId(
sidebar.getByTestId("inspector-planet-production-research-drive"), "inspector-planet-production-target",
).toBeVisible();
const scienceButton = sidebar.getByTestId(
"inspector-planet-production-science-FirstStep",
); );
await expect(scienceButton).toBeVisible(); await expect(
targetSelect.locator(
'[data-testid="inspector-planet-production-target-option-drive"]',
),
).toHaveCount(1);
await expect(
targetSelect.locator(
'[data-testid="inspector-planet-production-target-option-science-FirstStep"]',
),
).toHaveCount(1);
// Click the science → setProductionType("SCIENCE", "FirstStep") // Select the science target + ✓ → setProductionType("SCIENCE",
// lands in the draft and auto-syncs. // "FirstStep") lands in the draft and auto-syncs.
await scienceButton.click(); await targetSelect.selectOption("FirstStep");
await sidebar.getByTestId("inspector-planet-production-apply").click();
await expect.poll(() => handle.lastProduce?.subject).toBe("FirstStep"); await expect.poll(() => handle.lastProduce?.subject).toBe("FirstStep");
expect(handle.lastProduce?.planetNumber).toBe(1); expect(handle.lastProduce?.planetNumber).toBe(1);
}); });
+20 -22
View File
@@ -2,11 +2,12 @@
// the identity strip (`<race> @ <game>`, falling back to `?` while // the identity strip (`<race> @ <game>`, falling back to `?` while
// the lobby / report calls are in flight), the Phase 26 turn // the lobby / report calls are in flight), the Phase 26 turn
// navigator (`← turn N →` with a popover of every turn), the // navigator (`← turn N →` with a popover of every turn), the
// view-menu, and the account-menu. The tests assert the visible // view-menu, and the in-game ephemeral light/dark theme toggle (F8-05
// copy, that every view-menu entry switches the active in-game view // replaced the previous account-menu — language picker and logout
// via `activeView.select(...)` (the single-URL app-shell has no // now live in the lobby). The tests assert the visible copy, that
// per-view routes), and that the Logout entry of the account-menu // every view-menu entry switches the active in-game view via
// calls `session.signOut("user")`. // `activeView.select(...)`, and that the theme toggle flips the
// in-memory `theme.override` channel.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte"; import { fireEvent, render } from "@testing-library/svelte";
@@ -20,7 +21,7 @@ import {
} from "vitest"; } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte"; import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte"; import { theme } from "../src/lib/theme/theme.svelte";
import Header from "../src/lib/header/header.svelte"; import Header from "../src/lib/header/header.svelte";
import { import {
GAME_STATE_CONTEXT_KEY, GAME_STATE_CONTEXT_KEY,
@@ -74,10 +75,11 @@ beforeEach(() => {
i18n.resetForTests("en"); i18n.resetForTests("en");
activeViewSelectSpy.mockReset(); activeViewSelectSpy.mockReset();
appScreenGoSpy.mockReset(); appScreenGoSpy.mockReset();
vi.spyOn(session, "signOut").mockResolvedValue(undefined); theme.clearOverride();
}); });
afterEach(() => { afterEach(() => {
theme.clearOverride();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -95,7 +97,7 @@ describe("game-shell header", () => {
"turn ?", "turn ?",
); );
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument(); expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument(); expect(ui.getByTestId("game-mode-theme-toggle")).toBeInTheDocument();
}); });
test("renders the live race / game / turn from GameStateStore", () => { test("renders the live race / game / turn from GameStateStore", () => {
@@ -194,22 +196,18 @@ describe("game-shell header", () => {
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby"); expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
}); });
test("account-menu Logout triggers session.signOut('user')", async () => { test("theme toggle flips theme.override between light and dark", async () => {
const ui = render(Header, { const ui = render(Header, {
props: { sidebarOpen: false, onToggleSidebar: () => {} }, props: { sidebarOpen: false, onToggleSidebar: () => {} },
}); });
await fireEvent.click(ui.getByTestId("account-menu-trigger")); const toggle = ui.getByTestId("game-mode-theme-toggle");
await fireEvent.click(ui.getByTestId("account-menu-logout")); const initialResolved = theme.resolved;
expect(session.signOut).toHaveBeenCalledWith("user"); const opposite = initialResolved === "light" ? "dark" : "light";
}); await fireEvent.click(toggle);
expect(theme.override).toBe(opposite);
test("account-menu language picker switches the i18n locale", async () => { expect(theme.resolved).toBe(opposite);
const ui = render(Header, { await fireEvent.click(toggle);
props: { sidebarOpen: false, onToggleSidebar: () => {} }, expect(theme.override).toBe(initialResolved);
}); expect(theme.resolved).toBe(initialResolved);
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");
}); });
}); });
@@ -1,10 +1,12 @@
// Vitest component coverage for the Phase 16 cargo-routes // Vitest component coverage for the F8-05 cargo-routes subsection of
// subsection of the planet inspector. Drives the component against // the planet inspector. Pre-F8-05 the surface rendered all four
// a real `OrderDraftStore` (with `fake-indexeddb` standing in for // COL/CAP/MAT/EMP slots side-by-side; F8-05 collapsed it into a
// the browser IDB factory) and a stub `MapPickService` whose // single `<select>` with a placeholder (absorbing the old section
// `pick(...)` resolves to a script-controlled answer. The tests // title) and contextual `add` / `edit` + `remove` buttons that only
// assert the four-slot rendering, the picker invocation, the // appear once the player picks a type. The tests drive the component
// per-(source, loadType) collapse rule, and the cancel path. // against a real `OrderDraftStore` (with `fake-indexeddb` standing
// in for the browser IDB factory) and a stub `MapPickService` whose
// `pick(...)` resolves to a script-controlled answer.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
@@ -129,39 +131,48 @@ function mount(
return { ui, pick }; return { ui, pick };
} }
async function selectType(
ui: ReturnType<typeof mount>["ui"],
value: string,
): Promise<void> {
const select = ui.getByTestId(
"inspector-planet-cargo-type",
) as HTMLSelectElement;
await fireEvent.change(select, { target: { value } });
}
describe("planet inspector — cargo routes", () => { describe("planet inspector — cargo routes", () => {
test("renders four slots in COL/CAP/MAT/EMP order", () => { test("dropdown exposes COL/CAP/MAT/EMP plus the placeholder; nothing else is rendered until a type is picked", () => {
const { ui, pick } = mount( const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })], [makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
); );
const slots = ui.container.querySelectorAll( const select = ui.getByTestId(
"[data-testid^='inspector-planet-cargo-slot-']", "inspector-planet-cargo-type",
); ) as HTMLSelectElement;
const slotIds = Array.from(slots).map((el) => expect(Array.from(select.options).map((o) => o.value)).toEqual([
el.getAttribute("data-testid"), "",
); "COL",
// Each slot generates several test ids (label + body items); "CAP",
// pick the row data-testid (slot itself, no suffix). "MAT",
const rowIds = slotIds.filter((id) => "EMP",
/^inspector-planet-cargo-slot-(col|cap|mat|emp)$/.test(id ?? ""),
);
expect(rowIds).toEqual([
"inspector-planet-cargo-slot-col",
"inspector-planet-cargo-slot-cap",
"inspector-planet-cargo-slot-mat",
"inspector-planet-cargo-slot-emp",
]); ]);
expect(select.value).toBe("");
// No action buttons surface before a type is picked.
expect(
ui.queryByTestId("inspector-planet-cargo-slot-col-add"),
).toBeNull();
expect(
ui.queryByTestId("inspector-planet-cargo-slot-col-destination"),
).toBeNull();
}); });
test("an empty slot exposes the Add button and the (no route) marker", () => { test("selecting an empty type reveals the Add button", async () => {
const { ui, pick } = mount( const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })], [makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
); );
expect( await selectType(ui, "COL");
ui.getByTestId("inspector-planet-cargo-slot-col-empty"),
).toBeInTheDocument();
expect( expect(
ui.getByTestId("inspector-planet-cargo-slot-col-add"), ui.getByTestId("inspector-planet-cargo-slot-col-add"),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -170,8 +181,8 @@ describe("planet inspector — cargo routes", () => {
).toBeNull(); ).toBeNull();
}); });
test("a filled slot shows the destination name plus Edit and Remove", () => { test("selecting a filled type shows the destination plus Edit and Remove", async () => {
const { ui, pick } = mount( const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[ [
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -184,6 +195,7 @@ describe("planet inspector — cargo routes", () => {
}, },
], ],
); );
await selectType(ui, "COL");
expect( expect(
ui.getByTestId("inspector-planet-cargo-slot-col-destination"), ui.getByTestId("inspector-planet-cargo-slot-col-destination"),
).toHaveTextContent("Mars"); ).toHaveTextContent("Mars");
@@ -211,7 +223,10 @@ describe("planet inspector — cargo routes", () => {
[], [],
2, 2,
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add")); await selectType(ui, "COL");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
const invocation = pick.invocations[0]!; const invocation = pick.invocations[0]!;
expect(invocation.request.sourcePlanetNumber).toBe(1); expect(invocation.request.sourcePlanetNumber).toBe(1);
@@ -222,11 +237,7 @@ describe("planet inspector — cargo routes", () => {
}); });
test("the reachable set spans every planet kind in range, not only own", async () => { test("the reachable set spans every planet kind in range, not only own", async () => {
// Reach = 40 * 1.5 = 60. Each candidate at distance 50 — in // Reach = 40 * 1.5 = 60. Each candidate at distance 50 — in reach.
// reach. The picker must include the foreign-race planet,
// the uninhabited rock, and the unidentified target so the
// engine's "destinations may be any planet" rule is honoured
// (route.go: only the source's ownership is enforced).
const { ui, pick } = mount( const { ui, pick } = mount(
makePlanet({ makePlanet({
number: 1, number: 1,
@@ -269,7 +280,10 @@ describe("planet inspector — cargo routes", () => {
[], [],
1.5, 1.5,
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add")); await selectType(ui, "COL");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
expect( expect(
Array.from(pick.invocations[0]!.request.reachableIds).sort(), Array.from(pick.invocations[0]!.request.reachableIds).sort(),
@@ -304,7 +318,10 @@ describe("planet inspector — cargo routes", () => {
[], [],
1.5, 1.5,
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-mat-add")); await selectType(ui, "MAT");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-mat-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(9); pick.invocations[0]!.resolve(9);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
@@ -325,7 +342,10 @@ describe("planet inspector — cargo routes", () => {
[], [],
2, 2,
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-cap-add")); await selectType(ui, "CAP");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-cap-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2); pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
@@ -342,6 +362,29 @@ describe("planet inspector — cargo routes", () => {
); );
}); });
test("dropdown stays on the just-picked type after add resolves", async () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
],
[],
2,
);
await selectType(ui, "CAP");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-cap-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const select = ui.getByTestId(
"inspector-planet-cargo-type",
) as HTMLSelectElement;
expect(select.value).toBe("CAP");
});
test("cancel resolves null and emits no command", async () => { test("cancel resolves null and emits no command", async () => {
const { ui, pick } = mount( const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -350,7 +393,10 @@ describe("planet inspector — cargo routes", () => {
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }), makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
], ],
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-mat-add")); await selectType(ui, "MAT");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-mat-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(null); pick.invocations[0]!.resolve(null);
await waitFor(() => await waitFor(() =>
@@ -361,8 +407,8 @@ describe("planet inspector — cargo routes", () => {
expect(draft.commands).toHaveLength(0); expect(draft.commands).toHaveLength(0);
}); });
test("Remove emits removeCargoRoute for the slot", async () => { test("Remove emits removeCargoRoute for the selected type", async () => {
const { ui, pick } = mount( const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[ [
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -375,6 +421,7 @@ describe("planet inspector — cargo routes", () => {
}, },
], ],
); );
await selectType(ui, "EMP");
await fireEvent.click( await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-emp-remove"), ui.getByTestId("inspector-planet-cargo-slot-emp-remove"),
); );
@@ -401,13 +448,14 @@ describe("planet inspector — cargo routes", () => {
}, },
], ],
); );
await selectType(ui, "COL");
await fireEvent.click( await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-edit"), ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
); );
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(3); pick.invocations[0]!.resolve(3);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
// Then a second edit to a different planet — collapse keeps a // A second edit to a different planet — collapse keeps a
// single row. // single row.
await fireEvent.click( await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-edit"), ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
@@ -429,11 +477,17 @@ describe("planet inspector — cargo routes", () => {
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }), makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
], ],
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add")); await selectType(ui, "COL");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2); pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-cap-add")); await selectType(ui, "CAP");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-cap-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(2)); await waitFor(() => expect(pick.invocations.length).toBe(2));
pick.invocations[1]!.resolve(2); pick.invocations[1]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(2)); await waitFor(() => expect(draft.commands).toHaveLength(2));
@@ -444,8 +498,8 @@ describe("planet inspector — cargo routes", () => {
expect(types).toEqual(["CAP", "COL"]); expect(types).toEqual(["CAP", "COL"]);
}); });
test("no_destinations message appears when reach is positive but every planet is out of range", () => { test("no_destinations message appears once a type is picked and every planet is out of range", async () => {
const { ui, pick } = mount( const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[ [
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -454,6 +508,11 @@ describe("planet inspector — cargo routes", () => {
[], [],
0.1, // reach 4 — far less than 5000 distance 0.1, // reach 4 — far less than 5000 distance
); );
// Hidden until the player engages with the dropdown.
expect(
ui.queryByTestId("inspector-planet-cargo-no-destinations"),
).toBeNull();
await selectType(ui, "COL");
expect( expect(
ui.getByTestId("inspector-planet-cargo-no-destinations"), ui.getByTestId("inspector-planet-cargo-no-destinations"),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -1,13 +1,17 @@
// Vitest component coverage for the Phase 15 production-controls // Vitest component coverage for the F8-05 production-controls
// subsection of the planet inspector. Drives the component against a // subsection of the planet inspector. The pre-F8-05 surface was four
// real `OrderDraftStore` (with `fake-indexeddb` standing in for the // segmented main buttons (auto-submitting on click) plus a contextual
// browser's IDB factory) so the collapse-by-`planetNumber` rule and // sub-row; F8-05 replaced it with two `<select>`s (main / target) and
// the per-row status side-effects are exercised end-to-end. // a green ✓ apply / yellow ✗ cancel pair on the same row. The apply
// gate is the new behaviour: row state is dirty when the user picked
// something different from the planet's current effective production,
// and only then can the player commit via the ✓.
// //
// The active-segment derivation is covered by direct render-and- // The tests drive the component against a real `OrderDraftStore`
// query assertions: the parser is small enough that a table-driven // (with `fake-indexeddb` standing in for the browser's IDB factory)
// pass over the canonical engine display strings catches every // so the collapse-by-`planetNumber` rule remains exercised. The
// branch. // active-target derivation is covered by a table-driven pass over the
// canonical engine display strings.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
@@ -106,28 +110,51 @@ function mountProduction(
}); });
} }
function getMain(ui: ReturnType<typeof mountProduction>): HTMLSelectElement {
return ui.getByTestId("inspector-planet-production-main") as HTMLSelectElement;
}
function getTarget(
ui: ReturnType<typeof mountProduction>,
): HTMLSelectElement {
return ui.getByTestId(
"inspector-planet-production-target",
) as HTMLSelectElement;
}
describe("planet inspector — production controls", () => { describe("planet inspector — production controls", () => {
test("renders the four main segments with localised labels", () => { test("renders the main select with localised options and ✓/✗ icons", () => {
const ui = mountProduction(localPlanet({ number: 1 })); const ui = mountProduction(localPlanet({ number: 1 }));
const main = getMain(ui);
expect(main.value).toBe("");
const labels = Array.from(main.options).map((o) => o.textContent?.trim());
// One placeholder + the four production kinds, in the documented order.
expect(labels).toEqual([
"(production)",
"industry",
"materials",
"research",
"build ship",
]);
// No secondary select until research / ship is chosen.
expect( expect(
ui.getByTestId("inspector-planet-production-segment-industry"), ui.queryByTestId("inspector-planet-production-target"),
).toHaveTextContent("industry"); ).toBeNull();
expect( expect(
ui.getByTestId("inspector-planet-production-segment-materials"), ui.getByTestId("inspector-planet-production-apply"),
).toHaveTextContent("materials"); ).toBeDisabled();
expect( expect(
ui.getByTestId("inspector-planet-production-segment-research"), ui.getByTestId("inspector-planet-production-cancel"),
).toHaveTextContent("research"); ).toBeDisabled();
expect(
ui.getByTestId("inspector-planet-production-segment-ship"),
).toHaveTextContent("build ship");
}); });
test("Industry click emits a CAP setProductionType command", async () => { test("Industry pick + ✓ emits a CAP setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 })); const ui = mountProduction(localPlanet({ number: 7 }));
await fireEvent.click( const main = getMain(ui);
ui.getByTestId("inspector-planet-production-segment-industry"), await fireEvent.change(main, { target: { value: "industry" } });
); const apply = ui.getByTestId("inspector-planet-production-apply");
expect(apply).not.toBeDisabled();
await fireEvent.click(apply);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!; const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("setProductionType"); expect(cmd.kind).toBe("setProductionType");
@@ -137,32 +164,29 @@ describe("planet inspector — production controls", () => {
expect(cmd.subject).toBe(""); expect(cmd.subject).toBe("");
}); });
test("Materials click emits a MAT setProductionType command", async () => { test("Materials pick + ✓ emits a MAT setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 })); const ui = mountProduction(localPlanet({ number: 7 }));
await fireEvent.click( await fireEvent.change(getMain(ui), { target: { value: "materials" } });
ui.getByTestId("inspector-planet-production-segment-materials"), await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!; const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind"); if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("MAT"); expect(cmd.productionType).toBe("MAT");
}); });
test("Research click reveals the four tech sub-buttons without emitting", async () => { test("Research pick reveals the target select and apply stays disabled until a tech is chosen", async () => {
const ui = mountProduction(localPlanet({ number: 7 })); const ui = mountProduction(localPlanet({ number: 7 }));
expect( expect(
ui.queryByTestId("inspector-planet-production-research-row"), ui.queryByTestId("inspector-planet-production-target"),
).toBeNull(); ).toBeNull();
await fireEvent.click( await fireEvent.change(getMain(ui), { target: { value: "research" } });
ui.getByTestId("inspector-planet-production-segment-research"), const target = getTarget(ui);
); expect(target.value).toBe("");
expect( expect(
ui.getByTestId("inspector-planet-production-research-row"), ui.getByTestId("inspector-planet-production-apply"),
).toBeInTheDocument(); ).toBeDisabled();
expect(draft.commands).toHaveLength(0); await fireEvent.change(target, { target: { value: "DRIVE" } });
await fireEvent.click( await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
ui.getByTestId("inspector-planet-production-research-drive"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!; const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind"); if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
@@ -170,27 +194,39 @@ describe("planet inspector — production controls", () => {
expect(cmd.subject).toBe(""); expect(cmd.subject).toBe("");
}); });
test("Build-Ship segment shows the empty placeholder when no classes designed", async () => { test("Research target with a science name emits a SCIENCE subject", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [], [
{ name: "Genetics", drive: 0, weapons: 0, shields: 0, cargo: 0 },
]);
await fireEvent.change(getMain(ui), { target: { value: "research" } });
await fireEvent.change(getTarget(ui), { target: { value: "Genetics" } });
await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("SCIENCE");
expect(cmd.subject).toBe("Genetics");
});
test("Build-Ship with no classes shows the empty placeholder option", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), []); const ui = mountProduction(localPlanet({ number: 7 }), []);
await fireEvent.click( await fireEvent.change(getMain(ui), { target: { value: "ship" } });
ui.getByTestId("inspector-planet-production-segment-ship"),
);
expect( expect(
ui.getByTestId("inspector-planet-production-ship-empty"), ui.getByTestId("inspector-planet-production-ship-empty"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
ui.getByTestId("inspector-planet-production-apply"),
).toBeDisabled();
}); });
test("Build-Ship click on a class emits a SHIP setProductionType command", async () => { test("Build-Ship + class pick + ✓ emits a SHIP setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [ const ui = mountProduction(localPlanet({ number: 7 }), [
shipClass({ name: "Scout" }), shipClass({ name: "Scout" }),
shipClass({ name: "Destroyer" }), shipClass({ name: "Destroyer" }),
]); ]);
await fireEvent.click( await fireEvent.change(getMain(ui), { target: { value: "ship" } });
ui.getByTestId("inspector-planet-production-segment-ship"), await fireEvent.change(getTarget(ui), { target: { value: "Scout" } });
); await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
await fireEvent.click(
ui.getByTestId("inspector-planet-production-ship-Scout"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!; const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind"); if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
@@ -198,32 +234,41 @@ describe("planet inspector — production controls", () => {
expect(cmd.subject).toBe("Scout"); expect(cmd.subject).toBe("Scout");
}); });
test("re-clicks on the same planet collapse to the latest entry via the store", async () => { test("Cancel resets the row to the current effective production without emitting", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [ const ui = mountProduction(
shipClass({ name: "Scout" }), localPlanet({ number: 7, production: "Capital" }),
]);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-industry"),
); );
const main = getMain(ui);
expect(main.value).toBe("industry");
await fireEvent.change(main, { target: { value: "research" } });
expect(
ui.getByTestId("inspector-planet-production-cancel"),
).not.toBeDisabled();
await fireEvent.click( await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-materials"), ui.getByTestId("inspector-planet-production-cancel"),
); );
await fireEvent.click( expect(getMain(ui).value).toBe("industry");
ui.getByTestId("inspector-planet-production-segment-research"), expect(draft.commands).toEqual([]);
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-research-cargo"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("CARGO");
}); });
test("active main segment derives from planet.production display string", () => { test("Apply gate is closed while the row matches the current effective production", () => {
const ui = mountProduction(
localPlanet({ number: 7, production: "Drive" }),
);
// On mount the parser seeds research + DRIVE, so the apply
// button is inert until the player actually changes something.
expect(
ui.getByTestId("inspector-planet-production-apply"),
).toBeDisabled();
expect(
ui.getByTestId("inspector-planet-production-cancel"),
).toBeDisabled();
});
test("active main derivation seeds the select from planet.production", () => {
const cases: ReadonlyArray<{ const cases: ReadonlyArray<{
production: string | null; production: string | null;
expected: "industry" | "materials" | "research" | "ship" | "none"; expected: "" | "industry" | "materials" | "research" | "ship";
}> = [ }> = [
{ production: "Capital", expected: "industry" }, { production: "Capital", expected: "industry" },
{ production: "Material", expected: "materials" }, { production: "Material", expected: "materials" },
@@ -232,67 +277,46 @@ describe("planet inspector — production controls", () => {
{ production: "Shields", expected: "research" }, { production: "Shields", expected: "research" },
{ production: "Cargo", expected: "research" }, { production: "Cargo", expected: "research" },
{ production: "Scout", expected: "ship" }, { production: "Scout", expected: "ship" },
{ production: "-", expected: "none" }, { production: "-", expected: "" },
{ production: null, expected: "none" }, { production: null, expected: "" },
{ production: "UnknownThing", expected: "none" }, { production: "UnknownThing", expected: "" },
]; ];
for (const tc of cases) { for (const tc of cases) {
const ui = mountProduction( const ui = mountProduction(
localPlanet({ number: 1, production: tc.production }), localPlanet({ number: 1, production: tc.production }),
[shipClass({ name: "Scout" })], [shipClass({ name: "Scout" })],
); );
const ids: ReadonlyArray< expect(getMain(ui).value).toBe(tc.expected);
"industry" | "materials" | "research" | "ship"
> = ["industry", "materials", "research", "ship"];
for (const id of ids) {
const el = ui.getByTestId(
`inspector-planet-production-segment-${id}`,
);
if (tc.expected === id) {
expect(el.classList.contains("active")).toBe(true);
} else {
expect(el.classList.contains("active")).toBe(false);
}
}
ui.unmount(); ui.unmount();
} }
}); });
test("active research sub-button highlights for known display strings", () => { test("active target seeds the secondary select for research display strings", () => {
const cases: ReadonlyArray<{ const cases: ReadonlyArray<{
production: string; production: string;
slug: "drive" | "weapons" | "shields" | "cargo"; expected: "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO";
}> = [ }> = [
{ production: "Drive", slug: "drive" }, { production: "Drive", expected: "DRIVE" },
{ production: "Weapons", slug: "weapons" }, { production: "Weapons", expected: "WEAPONS" },
{ production: "Shields", slug: "shields" }, { production: "Shields", expected: "SHIELDS" },
{ production: "Cargo", slug: "cargo" }, { production: "Cargo", expected: "CARGO" },
]; ];
for (const tc of cases) { for (const tc of cases) {
const ui = mountProduction( const ui = mountProduction(
localPlanet({ number: 1, production: tc.production }), localPlanet({ number: 1, production: tc.production }),
); );
const el = ui.getByTestId( expect(getMain(ui).value).toBe("research");
`inspector-planet-production-research-${tc.slug}`, expect(getTarget(ui).value).toBe(tc.expected);
);
expect(el.classList.contains("active")).toBe(true);
ui.unmount(); ui.unmount();
} }
}); });
test("ship class sub-row matches when production equals a class name", async () => { test("target select seeds the ship class when production is a class name", () => {
const ui = mountProduction( const ui = mountProduction(
localPlanet({ number: 1, production: "Scout" }), localPlanet({ number: 1, production: "Scout" }),
[shipClass({ name: "Scout" }), shipClass({ name: "Destroyer" })], [shipClass({ name: "Scout" }), shipClass({ name: "Destroyer" })],
); );
expect( expect(getMain(ui).value).toBe("ship");
ui.getByTestId("inspector-planet-production-ship-Scout").classList expect(getTarget(ui).value).toBe("Scout");
.contains("active"),
).toBe(true);
expect(
ui
.getByTestId("inspector-planet-production-ship-Destroyer")
.classList.contains("active"),
).toBe(false);
}); });
}); });
@@ -1,11 +1,17 @@
// Vitest coverage for the Phase 19 follow-up "stationed ship groups" // Vitest coverage for the "stationed ship groups" subsection of the
// subsection of the planet inspector. Phase 19 originally rendered // planet inspector. The map deliberately hides on-planet groups; this
// every in-orbit group as a small offset point on the map; the // subsection is the player's view of the fleets in orbit.
// resulting visual noise pushed the listing into this subsection //
// (`lib/inspectors/planet/ship-groups.svelte`) instead. // F8-05 (issue #48 п.32) moved the race column from the row into a
// dropdown above the table. The dropdown only renders when more than
// one race is stationed; it seeds with the player's own race when
// local groups are stationed here, otherwise with the first race
// alphabetically. Single-race cases skip the dropdown and render
// straight through. The race column is dropped in both modes — the
// dropdown's value already names the active race.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/svelte"; import { fireEvent, render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test } from "vitest"; import { beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte"; import { i18n } from "../src/lib/i18n/index.svelte";
@@ -86,7 +92,7 @@ function otherGroup(
} }
describe("planet inspector — stationed ship groups", () => { describe("planet inspector — stationed ship groups", () => {
test("renders one row per in-orbit local group with the player's race", () => { test("renders one row per in-orbit local group; the dropdown is hidden with a single race", () => {
const ui = render(ShipGroups, { const ui = render(ShipGroups, {
props: { props: {
planet: HOME_PLANET, planet: HOME_PLANET,
@@ -100,7 +106,13 @@ describe("planet inspector — stationed ship groups", () => {
}); });
const rows = ui.getAllByTestId("inspector-planet-ship-groups-row"); const rows = ui.getAllByTestId("inspector-planet-ship-groups-row");
expect(rows.length).toBe(2); expect(rows.length).toBe(2);
expect(rows[0]).toHaveTextContent("Earthlings"); // Race no longer appears in the row (it is hoisted to the
// dropdown — and the dropdown itself is hidden when only one
// race is present).
expect(
ui.queryByTestId("inspector-planet-ship-groups-race-filter"),
).toBeNull();
expect(rows[0]).not.toHaveTextContent("Earthlings");
expect(rows[0]).toHaveTextContent("Frontier"); expect(rows[0]).toHaveTextContent("Frontier");
expect(rows[0]).toHaveTextContent("2"); expect(rows[0]).toHaveTextContent("2");
expect(rows[0]).toHaveTextContent("24"); expect(rows[0]).toHaveTextContent("24");
@@ -108,6 +120,66 @@ describe("planet inspector — stationed ship groups", () => {
expect(rows[1]).toHaveTextContent("173.25"); expect(rows[1]).toHaveTextContent("173.25");
}); });
test("multiple races surface a dropdown that filters the table", async () => {
const ui = render(ShipGroups, {
props: {
planet: FOREIGN_PLANET,
localShipGroups: [
localGroup({ id: "own-1", destination: 99, class: "Frontier" }),
],
otherShipGroups: [
otherGroup({ class: "Bird-of-Prey", destination: 99 }),
],
localRace: "Earthlings",
},
});
const select = ui.getByTestId(
"inspector-planet-ship-groups-race-filter",
) as HTMLSelectElement;
// Own ships are stationed → own race wins as the default;
// alphabetical ordering puts the foreign one second.
expect(select.value).toBe("Earthlings");
expect(Array.from(select.options).map((o) => o.value)).toEqual([
"Earthlings",
"Klingons",
]);
expect(
ui.getAllByTestId("inspector-planet-ship-groups-row").length,
).toBe(1);
expect(
ui.getByTestId("inspector-planet-ship-groups-row"),
).toHaveTextContent("Frontier");
await fireEvent.change(select, { target: { value: "Klingons" } });
const rows = ui.getAllByTestId("inspector-planet-ship-groups-row");
expect(rows.length).toBe(1);
expect(rows[0]).toHaveTextContent("Bird-of-Prey");
});
test("with no own ships the dropdown collapses to the single foreign race", () => {
const ui = render(ShipGroups, {
props: {
planet: {
...HOME_PLANET,
owner: "Andorians",
kind: "other",
},
localShipGroups: [],
otherShipGroups: [
otherGroup({ class: "Bird-of-Prey", destination: 17 }),
],
localRace: "Earthlings",
},
});
// Single foreign race → no dropdown.
expect(
ui.queryByTestId("inspector-planet-ship-groups-race-filter"),
).toBeNull();
expect(
ui.getAllByTestId("inspector-planet-ship-groups-row").length,
).toBe(1);
});
test("filters out groups stationed on a different planet", () => { test("filters out groups stationed on a different planet", () => {
const ui = render(ShipGroups, { const ui = render(ShipGroups, {
props: { props: {
@@ -147,20 +219,6 @@ describe("planet inspector — stationed ship groups", () => {
); );
}); });
test("foreign-planet visitors fall back to the planet owner's race", () => {
const ui = render(ShipGroups, {
props: {
planet: FOREIGN_PLANET,
localShipGroups: [],
otherShipGroups: [otherGroup({ destination: 99 })],
localRace: "Earthlings",
},
});
const row = ui.getByTestId("inspector-planet-ship-groups-row");
expect(row).toHaveTextContent("Klingons");
expect(row).toHaveTextContent("Bird-of-Prey");
});
test("subsection collapses entirely when nothing is stationed", () => { test("subsection collapses entirely when nothing is stationed", () => {
const ui = render(ShipGroups, { const ui = render(ShipGroups, {
props: { props: {
+13 -8
View File
@@ -245,7 +245,7 @@ describe("planet inspector", () => {
expect(ui.queryByTestId("inspector-planet-field-natural_resources")).toBeNull(); expect(ui.queryByTestId("inspector-planet-field-natural_resources")).toBeNull();
}); });
test("Rename action is hidden for non-local planets", () => { test("Name is not editable for non-local planets", () => {
const ui = render(Planet, { const ui = render(Planet, {
props: { props: {
planet: makePlanet({ planet: makePlanet({
@@ -268,10 +268,13 @@ describe("planet inspector", () => {
localRace: "", localRace: "",
}, },
}); });
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull(); const name = ui.getByTestId("inspector-planet-name");
// Non-local planets render the name as a plain heading, not a
// click-to-edit button.
expect(name.tagName).toBe("H3");
}); });
test("Rename action opens an inline editor and validates locally", async () => { test("Clicking the name opens an inline editor and validates locally", async () => {
const dbName = `galaxy-rename-${crypto.randomUUID()}`; const dbName = `galaxy-rename-${crypto.randomUUID()}`;
const db = await openGalaxyDB(dbName); const db = await openGalaxyDB(dbName);
const cache = new IDBCache(db); const cache = new IDBCache(db);
@@ -311,8 +314,9 @@ describe("planet inspector", () => {
context, context,
}); });
const action = ui.getByTestId("inspector-planet-rename-action"); const name = ui.getByTestId("inspector-planet-name");
await fireEvent.click(action); expect(name.tagName).toBe("BUTTON");
await fireEvent.click(name);
const input = ui.getByTestId("inspector-planet-rename-input") as HTMLInputElement; const input = ui.getByTestId("inspector-planet-rename-input") as HTMLInputElement;
expect(input.value).toBe("Earth"); expect(input.value).toBe("Earth");
@@ -344,7 +348,7 @@ describe("planet inspector", () => {
db.close(); db.close();
}); });
test("Cancel closes the editor without adding to the draft", async () => { test("Escape closes the editor without adding to the draft", async () => {
const dbName = `galaxy-rename-${crypto.randomUUID()}`; const dbName = `galaxy-rename-${crypto.randomUUID()}`;
const db = await openGalaxyDB(dbName); const db = await openGalaxyDB(dbName);
const cache = new IDBCache(db); const cache = new IDBCache(db);
@@ -382,8 +386,9 @@ describe("planet inspector", () => {
}, },
context, context,
}); });
await fireEvent.click(ui.getByTestId("inspector-planet-rename-action")); await fireEvent.click(ui.getByTestId("inspector-planet-name"));
await fireEvent.click(ui.getByTestId("inspector-planet-rename-cancel")); const input = ui.getByTestId("inspector-planet-rename-input");
await fireEvent.keyDown(input, { key: "Escape" });
expect(ui.queryByTestId("inspector-planet-rename")).toBeNull(); expect(ui.queryByTestId("inspector-planet-rename")).toBeNull();
expect(draft.commands).toEqual([]); expect(draft.commands).toEqual([]);
draft.dispose(); draft.dispose();
@@ -1,9 +1,11 @@
// Phase 29 component coverage for `lib/active-view/map-toggles.svelte`. // Phase 29 component coverage for `lib/active-view/map-toggles.svelte`.
// The popover is a thin view of the `GameStateStore` runes — // The popover is a thin view of the `GameStateStore` runes —
// every control fires `setMapToggle` / `setWrapMode` on the store // every checkbox fires `setMapToggle` on the store and reads the
// and reads the current state through `store.mapToggles` / // current state through `store.mapToggles`. F8-05 (issue #48 п.8)
// `store.wrapMode`. The tests assert the wiring, the default // dropped the wrap-scrolling radio group from the UI; the
// rendering, and the popover lifecycle (open / Escape close). // `wrapMode` rune and the renderer's no-wrap path stay put for a
// future game-server-side feature flag, but no surface exposes
// the choice today.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte"; import { fireEvent, render } from "@testing-library/svelte";
@@ -19,7 +21,6 @@ import {
function buildStore(): GameStateStore { function buildStore(): GameStateStore {
const store = new GameStateStore(); const store = new GameStateStore();
store.status = "ready"; store.status = "ready";
store.wrapMode = "torus";
store.mapToggles = { ...DEFAULT_MAP_TOGGLES }; store.mapToggles = { ...DEFAULT_MAP_TOGGLES };
return store; return store;
} }
@@ -59,8 +60,8 @@ describe("MapTogglesControl", () => {
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked(); expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked(); expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-visible-hyperspace")).toBeChecked(); expect(ui.getByTestId("map-toggles-visible-hyperspace")).toBeChecked();
expect(ui.getByTestId("map-toggles-wrap-torus")).toBeChecked(); expect(ui.queryByTestId("map-toggles-wrap-torus")).toBeNull();
expect(ui.getByTestId("map-toggles-wrap-no-wrap")).not.toBeChecked(); expect(ui.queryByTestId("map-toggles-wrap-no-wrap")).toBeNull();
}); });
test("flipping a checkbox calls setMapToggle with the new value", async () => { test("flipping a checkbox calls setMapToggle with the new value", async () => {
@@ -90,17 +91,6 @@ describe("MapTogglesControl", () => {
expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false); expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false);
}); });
test("selecting the no-wrap radio calls setWrapMode", async () => {
const store = buildStore();
const setWrapMode = vi
.spyOn(store, "setWrapMode")
.mockResolvedValue(undefined);
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
await fireEvent.click(ui.getByTestId("map-toggles-wrap-no-wrap"));
expect(setWrapMode).toHaveBeenCalledWith("no-wrap");
});
test("Escape closes the popover", async () => { test("Escape closes the popover", async () => {
const store = buildStore(); const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } }); const ui = render(MapTogglesControl, { props: { store } });
+41
View File
@@ -86,4 +86,45 @@ describe("theme store", () => {
const { theme } = await freshStore(); const { theme } = await freshStore();
expect(theme.choice).toBe("system"); expect(theme.choice).toBe("system");
}); });
it("applies an ephemeral override without touching the persisted choice", async () => {
localStorage.setItem(STORAGE_KEY, "dark");
const { theme } = await freshStore();
expect(theme.resolved).toBe("dark");
expect(theme.override).toBeNull();
theme.setOverride("light");
expect(theme.override).toBe("light");
expect(theme.resolved).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
// Persisted choice is untouched.
expect(theme.choice).toBe("dark");
expect(localStorage.getItem(STORAGE_KEY)).toBe("dark");
});
it("clearOverride re-projects the persisted choice", async () => {
localStorage.setItem(STORAGE_KEY, "light");
const { theme } = await freshStore();
theme.setOverride("dark");
expect(theme.resolved).toBe("dark");
theme.clearOverride();
expect(theme.override).toBeNull();
expect(theme.resolved).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
});
it("override shadows setChoice until cleared", async () => {
const { theme } = await freshStore();
theme.setOverride("light");
theme.setChoice("dark");
// Override wins while it is non-null, but the choice is still
// persisted for the next lobby visit.
expect(theme.resolved).toBe("light");
expect(theme.choice).toBe("dark");
expect(localStorage.getItem(STORAGE_KEY)).toBe("dark");
theme.clearOverride();
expect(theme.resolved).toBe("dark");
});
}); });