feat(ui): F8-05 — game-mode chrome cleanup + inspector compact rows (#48)
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:
@@ -1,10 +1,15 @@
|
||||
<!--
|
||||
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
|
||||
store — every checkbox / radio fires `store.setMapToggle(...)` or
|
||||
`store.setWrapMode(...)` and reads back the current state through
|
||||
the rune.
|
||||
store — every checkbox fires `store.setMapToggle(...)` and reads
|
||||
back the current state through 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
|
||||
`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 { restoreFocus } from "$lib/a11y/restore-focus";
|
||||
import type { MapToggles, GameStateStore } from "$lib/game-state.svelte";
|
||||
import type { WrapMode } from "../../map/world";
|
||||
|
||||
type Props = { store: GameStateStore };
|
||||
let { store }: Props = $props();
|
||||
@@ -35,18 +39,6 @@ bottom-tabs bar.
|
||||
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 {
|
||||
if (event.key === "Escape" && open) {
|
||||
open = false;
|
||||
@@ -197,31 +189,6 @@ bottom-tabs bar.
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.visible_hyperspace")}</span>
|
||||
</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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -295,27 +262,9 @@ bottom-tabs bar.
|
||||
label:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
input[type="checkbox"] {
|
||||
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) {
|
||||
.surface {
|
||||
position: fixed;
|
||||
|
||||
@@ -45,6 +45,7 @@ return to the lobby still disposes the stores via `onDestroy`.
|
||||
<script lang="ts">
|
||||
import { onDestroy, setContext, untrack } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { theme } from "$lib/theme/theme.svelte";
|
||||
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||
import Header from "$lib/header/header.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();
|
||||
orderDraft.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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -2,9 +2,13 @@
|
||||
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
|
||||
`← Turn N →` triplet with a popover of every turn), the view
|
||||
dropdown / hamburger, and the account menu. The sidebar-toggle slot
|
||||
to its left appears only on tablet viewports (768–1024 px) and is
|
||||
wired by `+layout.svelte`.
|
||||
dropdown / hamburger, and the in-game ephemeral light/dark theme
|
||||
toggle. The sidebar-toggle slot to its left appears only on tablet
|
||||
viewports (768–1024 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
|
||||
name from the lobby's `GameSummary.gameName`. While either piece
|
||||
@@ -24,7 +28,7 @@ absent until Phase 24 wires push-event state.
|
||||
type GameStateStore,
|
||||
} from "$lib/game-state.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";
|
||||
|
||||
type Props = {
|
||||
@@ -78,7 +82,7 @@ absent until Phase 24 wires push-event state.
|
||||
⤧
|
||||
</button>
|
||||
<ViewMenu />
|
||||
<AccountMenu />
|
||||
<GameModeThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -144,16 +144,9 @@ const en = {
|
||||
"game.shell.menu.close_sidebar": "close sidebar",
|
||||
"game.shell.menu.open_views": "open views menu",
|
||||
"game.shell.menu.close_views": "close views menu",
|
||||
"game.shell.menu.account": "account",
|
||||
"game.shell.menu.settings": "settings",
|
||||
"game.shell.menu.sessions": "sessions",
|
||||
"game.shell.menu.theme": "theme",
|
||||
"game.shell.menu.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.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.turn.label": "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.unreachable_planets": "show unreachable planets",
|
||||
"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.planets": "planets",
|
||||
"game.view.table.ship_classes": "ship classes",
|
||||
@@ -293,7 +283,6 @@ const en = {
|
||||
"game.inspector.planet.action.rename": "rename",
|
||||
"game.inspector.planet.rename.title": "rename planet",
|
||||
"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.too_long": "name is too long (30 characters max)",
|
||||
"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.disallowed_character": "name contains disallowed characters",
|
||||
"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.materials": "materials",
|
||||
"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.shields": "shields",
|
||||
"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.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.cap": "industry",
|
||||
"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.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.row.count": "{count} ships",
|
||||
"game.inspector.planet.ship_groups.row.mass": "mass {mass}",
|
||||
|
||||
@@ -145,16 +145,9 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.shell.menu.close_sidebar": "закрыть боковую панель",
|
||||
"game.shell.menu.open_views": "открыть меню видов",
|
||||
"game.shell.menu.close_views": "закрыть меню видов",
|
||||
"game.shell.menu.account": "аккаунт",
|
||||
"game.shell.menu.settings": "настройки",
|
||||
"game.shell.menu.sessions": "сессии",
|
||||
"game.shell.menu.theme": "тема",
|
||||
"game.shell.menu.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.logout": "выйти",
|
||||
"game.shell.theme_toggle.to_light": "переключить на светлую тему",
|
||||
"game.shell.theme_toggle.to_dark": "переключить на тёмную тему",
|
||||
"game.shell.coming_soon": "скоро будет",
|
||||
"game.shell.turn.label": "ход {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.unreachable_planets": "показывать недостижимые планеты",
|
||||
"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.planets": "планеты",
|
||||
"game.view.table.ship_classes": "классы кораблей",
|
||||
@@ -294,7 +284,6 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.inspector.planet.action.rename": "переименовать",
|
||||
"game.inspector.planet.rename.title": "переименование планеты",
|
||||
"game.inspector.planet.rename.confirm": "сохранить",
|
||||
"game.inspector.planet.rename.cancel": "отмена",
|
||||
"game.inspector.planet.rename.invalid.empty": "имя не может быть пустым",
|
||||
"game.inspector.planet.rename.invalid.too_long": "имя слишком длинное (максимум 30 символов)",
|
||||
"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.disallowed_character": "имя содержит недопустимые символы",
|
||||
"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.materials": "сырьё",
|
||||
"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.shields": "щиты",
|
||||
"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.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.cap": "промышленность",
|
||||
"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.fleet_name": "имя флота не соответствует правилам имён сущностей",
|
||||
|
||||
"game.inspector.planet.ship_groups.race_filter.aria": "раса в орбите",
|
||||
"game.inspector.planet.ship_groups.title": "корабли на орбите",
|
||||
"game.inspector.planet.ship_groups.row.count": "{count} кораблей",
|
||||
"game.inspector.planet.ship_groups.row.mass": "масса {mass}",
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<!--
|
||||
Planet inspector. Renders the documented field set for each planet
|
||||
kind (local / other / uninhabited / unidentified) and exposes a
|
||||
Rename action on owned (`local`) planets that opens an inline
|
||||
editor. The editor runs the same `validateEntityName` rules as the
|
||||
server-side validator (parity with `pkg/util/string.go`) and, on
|
||||
confirm, appends a `planetRename` command to the local order draft
|
||||
through the `OrderDraftStore` provided via context.
|
||||
click-to-edit affordance on the name itself for owned (`local`)
|
||||
planets: a click on the name turns it into an inline input with a
|
||||
single ✓ confirm icon (Escape cancels). The editor runs the same
|
||||
`validateEntityName` rules as the server-side validator (parity with
|
||||
`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
|
||||
inline editor lives directly inside this component per PLAN.md
|
||||
Phase 14 — a separate file would be over-abstraction for one input
|
||||
field with five buttons.
|
||||
inline editor lives directly inside this component — a separate
|
||||
file would be over-abstraction for one input field and a confirm
|
||||
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">
|
||||
import { getContext, tick } from "svelte";
|
||||
@@ -158,68 +162,57 @@ field with five buttons.
|
||||
>
|
||||
<header>
|
||||
<p class="kind" data-testid="inspector-planet-kind">{kindLabel}</p>
|
||||
{#if planet.kind !== "unidentified"}
|
||||
{#if planet.kind === "local"}
|
||||
{#if renameOpen}
|
||||
<div class="rename" data-testid="inspector-planet-rename">
|
||||
<input
|
||||
id="planet-rename-input"
|
||||
type="text"
|
||||
class="rename-input"
|
||||
data-testid="inspector-planet-rename-input"
|
||||
aria-label={i18n.t("game.inspector.planet.rename.title")}
|
||||
bind:value={renameInput}
|
||||
bind:this={inputEl}
|
||||
onkeydown={onKeyDown}
|
||||
aria-invalid={renameValidation.ok ? "false" : "true"}
|
||||
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}
|
||||
<p
|
||||
id="planet-rename-error"
|
||||
class="rename-error"
|
||||
data-testid="inspector-planet-rename-error"
|
||||
>
|
||||
{renameInvalidMessage}
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="name name--editable"
|
||||
data-testid="inspector-planet-name"
|
||||
aria-label={i18n.t("game.inspector.planet.action.rename")}
|
||||
onclick={openRename}
|
||||
>
|
||||
{planet.name}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if planet.kind !== "unidentified"}
|
||||
<h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3>
|
||||
{/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">
|
||||
<label class="rename-label" for="planet-rename-input">
|
||||
{i18n.t("game.inspector.planet.rename.title")}
|
||||
</label>
|
||||
<input
|
||||
id="planet-rename-input"
|
||||
type="text"
|
||||
class="rename-input"
|
||||
data-testid="inspector-planet-rename-input"
|
||||
bind:value={renameInput}
|
||||
bind:this={inputEl}
|
||||
onkeydown={onKeyDown}
|
||||
aria-invalid={renameValidation.ok ? "false" : "true"}
|
||||
aria-describedby={renameValidation.ok ? undefined : "planet-rename-error"}
|
||||
/>
|
||||
{#if !renameValidation.ok}
|
||||
<p
|
||||
id="planet-rename-error"
|
||||
class="rename-error"
|
||||
data-testid="inspector-planet-rename-error"
|
||||
>
|
||||
{renameInvalidMessage}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="rename-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="rename-cancel"
|
||||
data-testid="inspector-planet-rename-cancel"
|
||||
onclick={cancelRename}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.rename.cancel")}
|
||||
</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 planet.kind === "local"}
|
||||
<Production {planet} {localShipClass} {localScience} />
|
||||
<CargoRoutes
|
||||
@@ -374,34 +367,35 @@ field with five buttons.
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.action {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.25rem;
|
||||
.name--editable {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.1rem 0.25rem;
|
||||
margin: 0 -0.25rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
color: inherit;
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
cursor: text;
|
||||
}
|
||||
.action:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-accent);
|
||||
.name--editable:hover,
|
||||
.name--editable:focus-visible {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
.rename {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.rename-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rename-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.45rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -411,32 +405,31 @@ field with five buttons.
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.rename-error {
|
||||
margin: 0;
|
||||
margin: 0.2rem 0 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.rename-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rename-cancel,
|
||||
.rename-confirm {
|
||||
.icon-action {
|
||||
flex: 0 0 auto;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rename-confirm:not(:disabled):hover,
|
||||
.rename-cancel:hover {
|
||||
.icon-action:not(:disabled):hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.rename-confirm:disabled {
|
||||
.icon-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.icon-action--apply:not(:disabled) {
|
||||
color: var(--color-success);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
<!--
|
||||
Phase 16 cargo-routes subsection of the planet inspector. Shows a
|
||||
fixed COL/CAP/MAT/EMP four-slot table for the active local planet,
|
||||
each slot either empty (with a single Add button) or filled (with
|
||||
the destination planet's name plus Edit and Remove buttons). Add
|
||||
and Edit hand off to the renderer-driven `MapPickService`: the map
|
||||
dims out-of-reach planets, draws the cursor-line anchor, and
|
||||
resolves with either a chosen destination id or `null` (cancel).
|
||||
F8-05 cargo-routes subsection of the planet inspector. Renders one
|
||||
compact row: a single `<select>` with the COL/CAP/MAT/EMP load-types
|
||||
(plus a default placeholder option that absorbs the old section
|
||||
title) and a context block to its right showing either an `add`
|
||||
button when the selected type has no route, or `→ destination` plus
|
||||
`edit` and `remove` buttons when a route is in place.
|
||||
|
||||
The component is purposely deferential to the existing infrastructure:
|
||||
- `OrderDraftStore` enforces the `(source, loadType)` collapse rule,
|
||||
so the optimistic overlay always matches what the server sees.
|
||||
- `MapPickService.pick(...)` is a renderer-side abstraction; its
|
||||
source/destination semantics live in `lib/active-view/map.svelte`.
|
||||
- Reach (`40 * driveTech` per `game/internal/model/game/race.go`)
|
||||
is computed inline using `torusShortestDelta` to mirror the
|
||||
engine's torus distance — see `pkg/util/map.go.deltas`.
|
||||
Picking a load-type out of the dropdown does not commit anything by
|
||||
itself: the player still has to press add / edit (which hand off to
|
||||
the renderer-driven `MapPickService`) or remove (which appends a
|
||||
`removeCargoRoute` command directly). After every action the
|
||||
dropdown stays on the type that was just acted on, so the result is
|
||||
visible in place. The `OrderDraftStore.add()` collapse rule keeps at
|
||||
most one entry per `(source, loadType)` pair, mirroring the engine's
|
||||
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">
|
||||
import { getContext } from "svelte";
|
||||
@@ -59,13 +63,15 @@ The component is purposely deferential to the existing infrastructure:
|
||||
const disabled = $derived(draft === undefined || pick === undefined);
|
||||
|
||||
let pendingSlot: CargoLoadType | null = $state(null);
|
||||
let selected = $state<CargoLoadType | "">("");
|
||||
|
||||
$effect(() => {
|
||||
// Reset the in-flight slot whenever the inspector switches to a
|
||||
// different planet so a stale "pick in progress" prompt does
|
||||
// not leak across the selection boundary.
|
||||
// Reset the local UI whenever the inspector switches to a
|
||||
// different planet so a stale dropdown selection (or in-flight
|
||||
// pick prompt) does not leak across the selection boundary.
|
||||
void planet.number;
|
||||
pendingSlot = null;
|
||||
selected = "";
|
||||
});
|
||||
|
||||
const SLOT_LABELS: Record<CargoLoadType, TranslationKey> = {
|
||||
@@ -78,9 +84,9 @@ The component is purposely deferential to the existing infrastructure:
|
||||
const currentEntries = $derived(
|
||||
routes.find((r) => r.sourcePlanetNumber === planet.number)?.entries ?? [],
|
||||
);
|
||||
// Per-slot derived map keeps the template's {#each} block free of
|
||||
// the {@const}/`find` chain that Svelte 5 sometimes mis-tracks
|
||||
// when the source array is freshly cloned by `applyOrderOverlay`.
|
||||
// Per-slot derived map keeps the template free of the
|
||||
// `.find(...)` chain that Svelte 5 sometimes mis-tracks when the
|
||||
// source array is freshly cloned by `applyOrderOverlay`.
|
||||
const slotEntries = $derived.by(() => {
|
||||
const map: Record<CargoLoadType, ReportRoute["entries"][number] | null> = {
|
||||
COL: null,
|
||||
@@ -162,67 +168,82 @@ The component is purposely deferential to the existing infrastructure:
|
||||
function cancelPick(): void {
|
||||
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>
|
||||
|
||||
<section class="cargo" data-testid="inspector-planet-cargo">
|
||||
<h4 class="title">
|
||||
{i18n.t("game.inspector.planet.cargo.title")}
|
||||
</h4>
|
||||
<dl class="slots">
|
||||
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)}
|
||||
{@const entry = slotEntries[loadType]}
|
||||
{@const slug = loadType.toLowerCase()}
|
||||
<div class="slot" data-testid={`inspector-planet-cargo-slot-${slug}`}>
|
||||
<dt class="slot-label" data-testid={`inspector-planet-cargo-slot-${slug}-label`}>
|
||||
<div class="row">
|
||||
<select
|
||||
class="select"
|
||||
data-testid="inspector-planet-cargo-type"
|
||||
aria-label={i18n.t("game.inspector.planet.cargo.placeholder")}
|
||||
value={selected}
|
||||
onchange={pickType}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
>
|
||||
<option value="">
|
||||
{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])}
|
||||
</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")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="action add"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-add`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void startPick(loadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.add")}
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="destination"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-destination`}
|
||||
>
|
||||
→ {destinationName(entry.destinationPlanetNumber)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="action edit"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-edit`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void startPick(loadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action remove"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-remove`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void removeRoute(loadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.remove")}
|
||||
</button>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if selected !== ""}
|
||||
{#if activeEntry === null}
|
||||
<button
|
||||
type="button"
|
||||
class="action add"
|
||||
data-testid={`inspector-planet-cargo-slot-${selectedSlug}-add`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void startPick(selected as CargoLoadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.add")}
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="destination"
|
||||
data-testid={`inspector-planet-cargo-slot-${selectedSlug}-destination`}
|
||||
>
|
||||
→ {destinationName(activeEntry.destinationPlanetNumber)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="action edit"
|
||||
data-testid={`inspector-planet-cargo-slot-${selectedSlug}-edit`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void startPick(selected as CargoLoadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action remove"
|
||||
data-testid={`inspector-planet-cargo-slot-${selectedSlug}-remove`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void removeRoute(selected as CargoLoadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.remove")}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if pendingSlot !== null}
|
||||
<div
|
||||
class="pick-prompt"
|
||||
@@ -241,7 +262,7 @@ The component is purposely deferential to the existing infrastructure:
|
||||
{i18n.t("game.inspector.planet.cargo.pick.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
{:else if reach > 0 && reachableSet().size === 0}
|
||||
{:else if selected !== "" && reach > 0 && reachableSet().size === 0}
|
||||
<p
|
||||
class="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;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.title {
|
||||
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;
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -289,9 +288,21 @@ The component is purposely deferential to the existing infrastructure:
|
||||
font-size: 0.9rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.empty {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
.select {
|
||||
flex: 1 1 8rem;
|
||||
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 {
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
<!--
|
||||
Phase 15 production-controls subsection of the planet inspector.
|
||||
Renders four main segments — industry / materials / research / build
|
||||
ship — and reveals a sub-row when the player picks a category that
|
||||
needs a target (research → tech field or science, build ship →
|
||||
designed class). Every leaf click 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.
|
||||
F8-05 production-controls subsection of the planet inspector. Renders
|
||||
two dropdowns on a single row (primary: industry / materials /
|
||||
research / ship; secondary: tech / science / ship class — only for
|
||||
the research and ship contexts) together with green ✓ apply and
|
||||
yellow ✗ cancel icon buttons. The apply button becomes enabled when
|
||||
the row selection differs from the planet's current effective
|
||||
production (post-overlay) and the choice is complete (industry /
|
||||
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
|
||||
`Cache.PlanetProductionDisplayName` mapping. While the player is
|
||||
mid-navigation (e.g. clicked Research but has not picked a tech yet)
|
||||
a transient `expandedMain` override widens the visible state so the
|
||||
sub-row can appear without forcing the player to commit a choice
|
||||
first; the override resets whenever the inspector switches to a
|
||||
different planet or after any leaf click.
|
||||
`Cache.PlanetProductionDisplayName` mapping. Whenever the inspector
|
||||
switches to a different planet or `planet.production` itself changes
|
||||
(typically after a successful apply round-trip projected by
|
||||
`applyOrderOverlay`), the row re-seeds from the new parsed value.
|
||||
|
||||
Phase 15 deliberately defers the per-type forecast number — see
|
||||
`ui/docs/calc-bridge.md` for the gap analysis. The component does
|
||||
not render forecast text; the existing `freeIndustry` ("free
|
||||
production") row in the parent inspector is unchanged.
|
||||
|
||||
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).
|
||||
The Research target list combines the four tech display strings with
|
||||
each defined science from `localScience`. A science name shadows the
|
||||
tech display string with the same text — the engine sends a single
|
||||
ambiguous display string in `planet.production`; user-defined
|
||||
sciences win because they carry more user intent.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
@@ -59,26 +55,10 @@ they carry more user intent).
|
||||
);
|
||||
const disabled = draft === undefined;
|
||||
|
||||
let expandedMain: MainSegment | null = $state(null);
|
||||
|
||||
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;
|
||||
});
|
||||
type TechFbs = "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO";
|
||||
|
||||
const RESEARCH_OPTIONS: ReadonlyArray<{
|
||||
fbs: ProductionType;
|
||||
fbs: TechFbs;
|
||||
slug: "drive" | "weapons" | "shields" | "cargo";
|
||||
labelKey: TranslationKey;
|
||||
}> = [
|
||||
@@ -128,14 +108,14 @@ they carry more user intent).
|
||||
return classes.some((c) => c.name === value) ? "ship" : null;
|
||||
}
|
||||
|
||||
function parseResearch(
|
||||
function parseTarget(
|
||||
value: string | null,
|
||||
classes: ShipClassSummary[],
|
||||
sciences: ScienceSummary[],
|
||||
): ProductionType | null {
|
||||
// A science name shadows the four tech display strings — when a
|
||||
// science matches we surface no tech-button highlight so the
|
||||
// science button gets the active styling instead.
|
||||
if (value !== null && sciences.some((s) => s.name === value)) return null;
|
||||
): string | null {
|
||||
if (value === null || value === "") return null;
|
||||
// Science wins over tech display string (same shadowing rule).
|
||||
if (sciences.some((s) => s.name === value)) return value;
|
||||
switch (value) {
|
||||
case "Drive":
|
||||
return "DRIVE";
|
||||
@@ -145,54 +125,88 @@ they carry more user intent).
|
||||
return "SHIELDS";
|
||||
case "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;
|
||||
}
|
||||
|
||||
function clickMain(segment: MainSegment): void {
|
||||
if (segment === "industry") {
|
||||
void emit("CAP", "");
|
||||
expandedMain = null;
|
||||
const parsedMain = $derived(
|
||||
parseMain(planet.production, localShipClass, localScience),
|
||||
);
|
||||
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;
|
||||
}
|
||||
if (segment === "materials") {
|
||||
void emit("MAT", "");
|
||||
expandedMain = null;
|
||||
if (mainSel === "materials") {
|
||||
await emit("MAT", "");
|
||||
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 {
|
||||
void emit(value, "");
|
||||
expandedMain = null;
|
||||
}
|
||||
|
||||
function clickScience(name: string): void {
|
||||
void emit("SCIENCE", name);
|
||||
expandedMain = null;
|
||||
}
|
||||
|
||||
function clickShip(name: string): void {
|
||||
void emit("SHIP", name);
|
||||
expandedMain = null;
|
||||
function cancelRow(): void {
|
||||
mainSel = parsedMain ?? "";
|
||||
targetSel = parsedTarget ?? "";
|
||||
}
|
||||
|
||||
async function emit(
|
||||
@@ -214,103 +228,113 @@ they carry more user intent).
|
||||
<h4 class="title">
|
||||
{i18n.t("game.inspector.planet.production.title")}
|
||||
</h4>
|
||||
<div class="row main">
|
||||
<div class="row">
|
||||
<select
|
||||
class="select"
|
||||
data-testid="inspector-planet-production-main"
|
||||
aria-label={i18n.t("game.inspector.planet.production.main.aria")}
|
||||
value={mainSel}
|
||||
onchange={pickMain}
|
||||
{disabled}
|
||||
>
|
||||
<option value="">
|
||||
{i18n.t("game.inspector.planet.production.main.placeholder")}
|
||||
</option>
|
||||
<option value="industry">
|
||||
{i18n.t("game.inspector.planet.production.option.industry")}
|
||||
</option>
|
||||
<option value="materials">
|
||||
{i18n.t("game.inspector.planet.production.option.materials")}
|
||||
</option>
|
||||
<option value="research">
|
||||
{i18n.t("game.inspector.planet.production.option.research")}
|
||||
</option>
|
||||
<option value="ship">
|
||||
{i18n.t("game.inspector.planet.production.option.ship")}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
{#if needsTarget}
|
||||
<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)}
|
||||
<option
|
||||
value={option.fbs}
|
||||
data-testid={`inspector-planet-production-target-option-${option.slug}`}
|
||||
>
|
||||
{i18n.t(option.labelKey)}
|
||||
</option>
|
||||
{/each}
|
||||
{#each localScience as sci (sci.name)}
|
||||
<option
|
||||
value={sci.name}
|
||||
data-testid={`inspector-planet-production-target-option-science-${sci.name}`}
|
||||
>
|
||||
{sci.name}
|
||||
</option>
|
||||
{/each}
|
||||
{:else if mainSel === "ship"}
|
||||
{#if localShipClass.length === 0}
|
||||
<option
|
||||
value=""
|
||||
disabled
|
||||
data-testid="inspector-planet-production-ship-empty"
|
||||
>
|
||||
{i18n.t("game.inspector.planet.production.ship.no_classes")}
|
||||
</option>
|
||||
{:else}
|
||||
{#each localShipClass as cls (cls.name)}
|
||||
<option
|
||||
value={cls.name}
|
||||
data-testid={`inspector-planet-production-target-option-ship-${cls.name}`}
|
||||
>
|
||||
{cls.name}
|
||||
</option>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
class:active={selectedMain === "industry"}
|
||||
data-testid="inspector-planet-production-segment-industry"
|
||||
disabled={disabled}
|
||||
onclick={() => clickMain("industry")}
|
||||
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()}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.production.option.industry")}
|
||||
<span aria-hidden="true">✓</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
class:active={selectedMain === "materials"}
|
||||
data-testid="inspector-planet-production-segment-materials"
|
||||
disabled={disabled}
|
||||
onclick={() => clickMain("materials")}
|
||||
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}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.production.option.materials")}
|
||||
</button>
|
||||
<button
|
||||
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")}
|
||||
</button>
|
||||
<button
|
||||
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")}
|
||||
<span aria-hidden="true">✗</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if selectedMain === "research"}
|
||||
<div class="row sub" data-testid="inspector-planet-production-research-row">
|
||||
{#each RESEARCH_OPTIONS as option (option.fbs)}
|
||||
<button
|
||||
type="button"
|
||||
class="sub-seg"
|
||||
class:active={activeResearch === option.fbs}
|
||||
data-testid={`inspector-planet-production-research-${option.slug}`}
|
||||
disabled={disabled}
|
||||
onclick={() => clickResearch(option.fbs)}
|
||||
>
|
||||
{i18n.t(option.labelKey)}
|
||||
</button>
|
||||
{/each}
|
||||
{#each localScience as sci (sci.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="sub-seg"
|
||||
class:active={activeScience === sci.name}
|
||||
data-testid={`inspector-planet-production-science-${sci.name}`}
|
||||
disabled={disabled}
|
||||
onclick={() => clickScience(sci.name)}
|
||||
>
|
||||
{sci.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedMain === "ship"}
|
||||
<div class="row sub" data-testid="inspector-planet-production-ship-row">
|
||||
{#if localShipClass.length === 0}
|
||||
<p
|
||||
class="empty"
|
||||
data-testid="inspector-planet-production-ship-empty"
|
||||
>
|
||||
{i18n.t("game.inspector.planet.production.ship.no_classes")}
|
||||
</p>
|
||||
{:else}
|
||||
{#each localShipClass as cls (cls.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="sub-seg"
|
||||
class:active={activeShip === cls.name}
|
||||
data-testid={`inspector-planet-production-ship-${cls.name}`}
|
||||
disabled={disabled}
|
||||
onclick={() => clickShip(cls.name)}
|
||||
>
|
||||
{cls.name}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -327,43 +351,50 @@ they carry more user intent).
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.row.sub {
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
.seg,
|
||||
.sub-seg {
|
||||
.select {
|
||||
flex: 1 1 6rem;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
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;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg:not(:disabled):hover,
|
||||
.sub-seg:not(:disabled):hover {
|
||||
.icon-action:not(:disabled):hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.seg.active,
|
||||
.sub-seg.active {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
.seg:disabled,
|
||||
.sub-seg:disabled {
|
||||
.icon-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
.icon-action--apply:not(:disabled) {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.icon-action--cancel:not(:disabled) {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,14 +10,24 @@ best-effort:
|
||||
typed contract does not carry per-group ownership outside
|
||||
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 shared `SelectionStore`. The actions panel mounts on top of
|
||||
the existing ship-group inspector, so the row is the on-planet
|
||||
entry point for Send / Load / Modernize / etc. Foreign rows stay
|
||||
non-interactive — there are no actions to drive against another
|
||||
race's fleet. Phase 21+ will reuse the same row shape inside the
|
||||
ship-groups table view with an additional `(planet, race)` filter.
|
||||
race's fleet.
|
||||
|
||||
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">
|
||||
import { getContext } from "svelte";
|
||||
@@ -91,6 +101,55 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
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 {
|
||||
if (selection === undefined) return;
|
||||
selection.selectShipGroup({ variant: "local", id: groupId });
|
||||
@@ -99,9 +158,26 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
|
||||
{#if stationedRows.length > 0}
|
||||
<section class="ship-groups" data-testid="inspector-planet-ship-groups">
|
||||
<h4>{i18n.t("game.inspector.planet.ship_groups.title")}</h4>
|
||||
<div class="head">
|
||||
<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">
|
||||
{#each stationedRows as row (row.key)}
|
||||
{#each filteredRows as row (row.key)}
|
||||
<li class="row" data-testid="inspector-planet-ship-groups-row">
|
||||
{#if row.selectable && row.groupId !== null}
|
||||
{@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"
|
||||
onclick={() => selectLocalGroup(groupId)}
|
||||
>
|
||||
<span class="race" data-testid="inspector-planet-ship-groups-race">
|
||||
{row.race}
|
||||
</span>
|
||||
<span class="class">{row.class}</span>
|
||||
<span class="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>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="race" data-testid="inspector-planet-ship-groups-race">
|
||||
{row.race}
|
||||
</span>
|
||||
<span class="class">{row.class}</span>
|
||||
<span class="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;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
@@ -161,6 +237,18 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
letter-spacing: 0.05em;
|
||||
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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -177,7 +265,7 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
.row > span,
|
||||
.row > .select {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto auto;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.select {
|
||||
@@ -195,9 +283,6 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
.race {
|
||||
font-weight: 600;
|
||||
}
|
||||
.class {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
* `data-theme` from the same `localStorage` key before the app boots;
|
||||
* this store mirrors that logic and takes over once mounted, including
|
||||
* 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. */
|
||||
@@ -45,6 +52,7 @@ function systemTheme(): ResolvedTheme {
|
||||
class ThemeStore {
|
||||
#choice = $state<ThemeChoice>(readStoredChoice());
|
||||
#system = $state<ResolvedTheme>(systemTheme());
|
||||
#override = $state<ResolvedTheme | null>(null);
|
||||
|
||||
constructor() {
|
||||
if (
|
||||
@@ -55,7 +63,9 @@ class ThemeStore {
|
||||
.matchMedia(SYSTEM_LIGHT_QUERY)
|
||||
.addEventListener("change", (event: MediaQueryListEvent) => {
|
||||
this.#system = event.matches ? "light" : "dark";
|
||||
if (this.#choice === "system") this.#apply();
|
||||
if (this.#choice === "system" && this.#override === null) {
|
||||
this.#apply();
|
||||
}
|
||||
});
|
||||
}
|
||||
this.#apply();
|
||||
@@ -66,8 +76,17 @@ class ThemeStore {
|
||||
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. */
|
||||
get resolved(): ResolvedTheme {
|
||||
if (this.#override !== null) return this.#override;
|
||||
return this.#choice === "system" ? this.#system : this.#choice;
|
||||
}
|
||||
|
||||
@@ -80,6 +99,26 @@ class ThemeStore {
|
||||
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 {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.theme = this.resolved;
|
||||
|
||||
Reference in New Issue
Block a user