Files
galaxy-game/ui/frontend/src/lib/sidebar/calculator-tab.svelte
T
Ilia Denisov b6770d394c feat(ui): app-shell core — single-route dispatcher, route collapse, nav→state
Collapse the game UI to one route (`/`): a screen dispatcher renders
login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of
URL routes. Move screen components to lib/screens & lib/game; the game shell
reads the game id from `appScreen.gameId` and re-inits per-game stores via an
$effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites
to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now
state-based. WIP: browser-history (Back→lobby), restore-validation, the
return-to-lobby button, push deep-links, and the test migration are follow-ups
on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:04:04 +02:00

903 lines
25 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Phase 30 ship-class calculator. Replaces the Phase 17/18 standalone
designer: it fuses the ship design blocks, the live derived results
(mass, speed, attack, defence, bombing), and a planet build-rate readout
into one sidebar tool, and adds single-target goal-seek — the player pins
one result and the model back-solves the single input it claims (see
`lib/calculator/calc-model.ts`). A second mode reuses the design area to
price ship-class modernization. All math comes from `pkg/calc` through
the `Core` WASM bridge; this component only renders + orchestrates.
Input state lives in the long-lived `calculatorState` singleton, not in
the component, so it survives the sidebar unmounting this tab on a tab
switch (the inspector auto-opens on a planet click) — the calculator is a
long-lived planning tool. `ensureGame` resets it when the game changes.
-->
<script lang="ts">
import { getContext } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import { CORE_CONTEXT_KEY, type CoreHandle } from "$lib/core-context.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import type { ReportPlanet, ShipClassSummary } from "../../api/game-state";
import {
validateShipClass,
type ShipClassInvalidReason,
} from "$lib/util/ship-class-validation";
import {
computeCalculator,
computePlanetBuild,
type LockableOutputId,
type LoadMode,
} from "$lib/calculator/calc-model";
import ShipDesignArea, {
type TechKey,
} from "$lib/calculator/ship-design-area.svelte";
import { reachStore } from "$lib/calculator/reach.svelte";
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
import { calculatorState } from "$lib/calculator/calc-state.svelte";
const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const selection = getContext<SelectionStore | undefined>(
SELECTION_CONTEXT_KEY,
);
// The long-lived input state (survives tab unmount/remount).
const cs = calculatorState;
// Reset the design when the active game changes; a no-op otherwise, so
// the design persists across tab switches within a game.
$effect(() => {
cs.ensureGame(appScreen.gameId ?? "");
});
const core = $derived(coreHandle?.core ?? null);
const report = $derived(rendered?.report ?? null);
const localShipClass = $derived<ShipClassSummary[]>(
report?.localShipClass ?? [],
);
const existingNames = $derived(localShipClass.map((c) => c.name));
const playerTech = $derived({
drive: report?.localPlayerDrive ?? 0,
weapons: report?.localPlayerWeapons ?? 0,
shields: report?.localPlayerShields ?? 0,
cargo: report?.localPlayerCargo ?? 0,
});
const techKeys: TechKey[] = ["drive", "weapons", "shields", "cargo"];
// Non-overridden tech levels track the player's current tech; the
// effect resets them whenever the report (history snapshot included)
// changes, so the calculator reflects the right turn's tech.
$effect(() => {
for (const k of techKeys) {
if (!cs.techOverridden[k]) cs.techValues[k] = playerTech[k];
}
});
// Seed the modernization target with the player's current tech once
// the report has loaded; afterwards it is the player's to edit.
$effect(() => {
if (cs.targetSeeded) return;
if (
playerTech.drive ||
playerTech.weapons ||
playerTech.shields ||
playerTech.cargo
) {
cs.targetTech = { ...playerTech };
cs.targetSeeded = true;
}
});
const result = $derived(
computeCalculator(
{
blocks: cs.blocks,
driveTech: cs.techValues.drive,
weaponsTech: cs.techValues.weapons,
shieldsTech: cs.techValues.shields,
cargoTech: cs.techValues.cargo,
loadMode: cs.loadMode,
customLoad: cs.customLoad,
lock: cs.lock === null ? null : { output: cs.lock, value: cs.lockValue },
},
core,
),
);
// Selected own planet (MVP: own planets only).
const selectedPlanet = $derived.by<ReportPlanet | null>(() => {
const sel = selection?.selected;
if (sel === undefined || sel === null || sel.kind !== "planet") return null;
const planet = report?.planets.find((p) => p.number === sel.id) ?? null;
if (planet === null || planet.kind !== "local") return null;
return planet;
});
$effect(() => {
if (!cs.matOverridden) {
cs.matValue = selectedPlanet?.materialsStockpile ?? 0;
}
});
// With no cargo block there is no hold to load: pin the load to empty
// and disable the toggle.
const cargoEmpty = $derived(cs.blocks.cargo === 0);
$effect(() => {
if (cargoEmpty && cs.loadMode !== "empty") cs.loadMode = "empty";
});
const planetBuild = $derived.by(() => {
if (selectedPlanet === null) return null;
const emptyMass = result.outputs?.emptyMass;
if (emptyMass === undefined) return null;
return computePlanetBuild(
{
shipMass: emptyMass,
freeIndustry: selectedPlanet.freeIndustry ?? 0,
material: cs.matValue,
resources: selectedPlanet.resources ?? 0,
},
core,
);
});
// Publish the selected planet's reach (loaded speed) so the map view
// can draw 13 reach circles. Cleared when the design is invalid, no
// own planet is selected, or the calculator is in modernization mode.
$effect(() => {
const out = result.outputs;
if (cs.mode === "ship" && selectedPlanet !== null && out !== null) {
reachStore.set(
{ x: selectedPlanet.x, y: selectedPlanet.y },
out.speedLoaded,
);
} else {
reachStore.clear();
}
return () => reachStore.clear();
});
const nameInvalidKeyMap: Record<ShipClassInvalidReason, TranslationKey> = {
empty: "game.calculator.invalid.empty",
too_long: "game.calculator.invalid.too_long",
starts_with_special: "game.calculator.invalid.starts_with_special",
ends_with_special: "game.calculator.invalid.ends_with_special",
consecutive_specials: "game.calculator.invalid.consecutive_specials",
whitespace: "game.calculator.invalid.whitespace",
disallowed_character: "game.calculator.invalid.disallowed_character",
duplicate_name: "game.calculator.invalid.duplicate_name",
drive_value: "game.calculator.invalid.drive_value",
armament_value: "game.calculator.invalid.armament_value",
armament_not_integer: "game.calculator.invalid.armament_not_integer",
weapons_value: "game.calculator.invalid.weapons_value",
shields_value: "game.calculator.invalid.shields_value",
cargo_value: "game.calculator.invalid.cargo_value",
armament_weapons_pair: "game.calculator.invalid.armament_weapons_pair",
all_zero: "game.calculator.invalid.all_zero",
};
const nameValidation = $derived(
validateShipClass({ name: cs.name, ...result.blocks }, { existingNames }),
);
const createMessage = $derived(
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
);
const canCreate = $derived(nameValidation.ok && draft !== undefined);
const canDelete = $derived(
cs.loadedExisting !== null &&
existingNames.includes(cs.loadedExisting) &&
draft !== undefined,
);
// Per-block modernization upgrade cost (current tech → target tech).
const modernCosts = $derived.by(() => {
if (core === null) return null;
const weaponsMass = core.weaponsBlockMass({
weapons: cs.blocks.weapons,
armament: cs.blocks.armament,
});
const rows: { key: TechKey; mass: number }[] = [
{ key: "drive", mass: cs.blocks.drive },
{ key: "weapons", mass: weaponsMass ?? 0 },
{ key: "shields", mass: cs.blocks.shields },
{ key: "cargo", mass: cs.blocks.cargo },
];
const perBlock = rows.map((r) => ({
key: r.key,
cost: core.blockUpgradeCost({
blockMass: r.mass,
currentTech: cs.techValues[r.key],
targetTech: cs.targetTech[r.key],
}),
}));
const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
return { perBlock, total };
});
// Display every computed number rounded up to three decimals via the
// shared `Ceil3` bridge, so a value is never shown lower than it is.
function fmt(value: number | null | undefined): string {
if (value === null || value === undefined) {
return i18n.t("game.calculator.unavailable");
}
const rounded = core !== null ? core.ceil3({ value }) : value;
return rounded.toLocaleString(undefined, { maximumFractionDigits: 3 });
}
// The goal-seek back-solved block, shown in its read-only cell, is
// ceiled the same way (only the claimed block's cell is displayed).
const resolvedCeil = $derived.by(() => {
if (core === null) return result.blocks;
const c = (v: number) => core.ceil3({ value: v });
return {
drive: c(result.blocks.drive),
armament: result.blocks.armament,
weapons: c(result.blocks.weapons),
shields: c(result.blocks.shields),
cargo: c(result.blocks.cargo),
};
});
// A custom load must stay within [0, cargo capacity]; beyond that the
// ship cannot hold it.
const customLoadError = $derived.by(() => {
if (cs.loadMode !== "custom") return "";
if (cs.customLoad < 0) return i18n.t("game.calculator.invalid.negative");
if (cs.customLoad > result.cargoCapacity) {
return i18n.t("game.calculator.invalid.load_over_capacity");
}
return "";
});
const matError = $derived(
cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
);
// Locking a speed back-solves the drive block; with a zero drive the
// ship is deliberately immobile, so disallow it.
function lockDisabledReason(output: LockableOutputId): string {
if (
(output === "speedEmpty" || output === "speedLoaded") &&
cs.blocks.drive === 0
) {
return i18n.t("game.calculator.lock.no_drive");
}
return "";
}
function onTechInput(key: TechKey): void {
cs.techOverridden[key] = true;
}
function onResetTech(key: TechKey): void {
cs.techOverridden[key] = false;
}
function onMatInput(): void {
cs.matOverridden = true;
}
function resetMat(): void {
cs.matOverridden = false;
}
function lockOutput(output: LockableOutputId): void {
if (cs.lock !== null) return;
cs.lockValue = result.outputs?.[output] ?? 0;
cs.lock = output;
}
function unlock(): void {
cs.lock = null;
}
function loadExisting(clsName: string): void {
const cls = localShipClass.find((c) => c.name === clsName);
if (cls === undefined) return;
cs.blocks = {
drive: cls.drive,
armament: cls.armament,
weapons: cls.weapons,
shields: cls.shields,
cargo: cls.cargo,
};
cs.name = cls.name;
cs.loadedExisting = cls.name;
cs.lock = null;
}
// React to the ship-classes table / bottom-tabs asking to load a
// class (or start a fresh design) into the calculator. The layout
// flips the sidebar to this tab in parallel.
$effect(() => {
const token = calculatorLoadRequest.token;
if (token === cs.handledLoadToken) return;
cs.handledLoadToken = token;
cs.mode = "ship";
if (calculatorLoadRequest.name === null) cs.resetDesign();
else loadExisting(calculatorLoadRequest.name);
});
async function create(): Promise<void> {
if (!nameValidation.ok || draft === undefined) return;
// Capture the validated draft before awaiting: adding the command
// re-projects `localShipClass`, which re-runs the `nameValidation`
// derived into a `duplicate_name` failure (the class now exists),
// leaving `nameValidation.value` undefined after the await.
const created = nameValidation.value;
await draft.add({
kind: "createShipClass",
id: crypto.randomUUID(),
name: created.name,
drive: created.drive,
armament: created.armament,
weapons: created.weapons,
shields: created.shields,
cargo: created.cargo,
});
cs.loadedExisting = created.name;
}
async function deleteClass(): Promise<void> {
if (cs.loadedExisting === null || draft === undefined) return;
await draft.add({
kind: "removeShipClass",
id: crypto.randomUUID(),
name: cs.loadedExisting,
});
cs.loadedExisting = null;
}
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
emptyMass: i18n.t("game.calculator.out.mass"),
loadedMass: i18n.t("game.calculator.out.mass"),
speedEmpty: i18n.t("game.calculator.out.speed"),
speedLoaded: i18n.t("game.calculator.out.speed"),
attack: i18n.t("game.calculator.out.attack"),
defense: i18n.t("game.calculator.out.defense"),
});
</script>
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
{#if cs.lock === output}
<span class="cell locked" class:infeasible={!result.lockFeasible}>
<input
type="number"
step="0.001"
bind:value={cs.lockValue}
data-testid={`calculator-locked-${output}`}
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
/>
<button
type="button"
class="lock active"
title={i18n.t("game.calculator.lock.reset")}
aria-label={i18n.t("game.calculator.lock.reset")}
data-testid={`calculator-unlock-${output}`}
onclick={unlock}
>
🔒
</button>
</span>
{:else}
{@const extra = lockDisabledReason(output)}
<span class="cell">
<span class="val" data-testid={`calculator-out-${output}`}>{fmt(value)}</span>
<button
type="button"
class="lock"
disabled={cs.lock !== null || value === undefined || extra !== ""}
title={cs.lock !== null
? i18n.t("game.calculator.lock.max")
: extra !== ""
? extra
: LOCK_LABELS[output]}
aria-label={LOCK_LABELS[output]}
data-testid={`calculator-lock-${output}`}
onclick={() => lockOutput(output)}
>
🔓
</button>
</span>
{/if}
{/snippet}
<section class="calculator" data-testid="sidebar-tool-calculator">
<div class="modes" role="tablist">
<button
type="button"
class:active={cs.mode === "ship"}
data-testid="calculator-mode-ship"
onclick={() => (cs.mode = "ship")}
>
{i18n.t("game.calculator.mode.ship")}
</button>
<button
type="button"
class:active={cs.mode === "modernization"}
data-testid="calculator-mode-modernization"
onclick={() => (cs.mode = "modernization")}
>
{i18n.t("game.calculator.mode.modernization")}
</button>
</div>
<div class="namebar">
<input
type="text"
class="name"
list="calculator-existing-classes"
placeholder={i18n.t("game.calculator.name.placeholder")}
maxlength="30"
bind:value={cs.name}
oninput={() => (cs.loadedExisting = null)}
onchange={() => loadExisting(cs.name)}
aria-invalid={nameValidation.ok ? "false" : "true"}
data-testid="calculator-name"
/>
<datalist id="calculator-existing-classes">
{#each localShipClass as cls (cls.name)}
<option value={cls.name}></option>
{/each}
</datalist>
{#if cs.mode === "ship"}
<button
type="button"
class="create"
disabled={!canCreate}
title={canCreate ? "" : createMessage}
data-testid="calculator-create"
onclick={() => void create()}
>
{i18n.t("game.calculator.action.create")}
</button>
{/if}
</div>
{#if cs.mode === "ship" && canDelete}
<button
type="button"
class="delete"
data-testid="calculator-delete"
onclick={() => void deleteClass()}
>
{i18n.t("game.calculator.action.delete")} {cs.loadedExisting}
</button>
{/if}
<ShipDesignArea
bind:blocks={cs.blocks}
resolved={resolvedCeil}
bind:techs={cs.techValues}
techOverridden={cs.techOverridden}
computedInput={result.computedInput}
{onTechInput}
{onResetTech}
/>
{#if cs.mode === "ship"}
<div class="load">
<span class="label">{i18n.t("game.calculator.load.label")}</span>
<div class="seg" role="group">
{#each LOAD_MODES as m (m)}
<button
type="button"
class:active={cs.loadMode === m}
disabled={cargoEmpty}
data-testid={`calculator-load-${m}`}
onclick={() => (cs.loadMode = m)}
>
{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
</button>
{/each}
</div>
{#if cs.loadMode === "custom"}
<input
type="number"
step="0.01"
min="0"
class="custom-load"
bind:value={cs.customLoad}
aria-invalid={customLoadError !== "" ? "true" : "false"}
title={customLoadError}
data-testid="calculator-custom-load"
/>
{:else if cs.loadMode === "full"}
<span
class="full-capacity"
title={i18n.t("game.calculator.out.cargo_capacity")}
data-testid="calculator-full-capacity"
>
{fmt(result.cargoCapacity)}
</span>
{/if}
</div>
<div class="results" data-testid="calculator-results">
<div class="rrow head">
<span></span>
<span class="col-head">{i18n.t("game.calculator.col.empty")}</span>
<span class="col-head">{i18n.t("game.calculator.col.loaded")}</span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.mass")}</span>
{@render lockable("emptyMass", result.outputs?.emptyMass)}
{@render lockable("loadedMass", result.outputs?.loadedMass)}
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.speed")}</span>
{@render lockable("speedEmpty", result.outputs?.speedEmpty)}
{@render lockable("speedLoaded", result.outputs?.speedLoaded)}
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.attack")}</span>
{@render lockable("attack", result.outputs?.attack)}
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.defense")}</span>
{@render lockable("defense", result.outputs?.defense)}
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.bombing")}</span>
<span class="cell">
<span class="val" data-testid="calculator-out-bombing">
{fmt(result.outputs?.bombing)}
</span>
</span>
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.cargo_capacity")}</span>
<span class="cell">
<span class="val" data-testid="calculator-out-cargo-capacity">
{fmt(result.outputs === null ? null : result.cargoCapacity)}
</span>
</span>
<span></span>
</div>
</div>
<div class="planet" data-testid="calculator-planet-area">
{#if selectedPlanet === null}
<p class="hint" data-testid="calculator-planet-none">
{i18n.t("game.calculator.planet.none")}
</p>
{:else}
<p class="planet-name" data-testid="calculator-planet-name">
{i18n.t("game.calculator.planet.label", {
name: selectedPlanet.name,
number: String(selectedPlanet.number),
})}
</p>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
<span class="cell">
<input
type="number"
step="0.01"
min="0"
bind:value={cs.matValue}
oninput={onMatInput}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
{#if cs.matOverridden}
<button
type="button"
class="lock active"
title={i18n.t("game.calculator.mat.reset")}
aria-label={i18n.t("game.calculator.mat.reset")}
data-testid="calculator-mat-reset"
onclick={resetMat}
>
🔒
</button>
{/if}
</span>
<span></span>
</div>
<dl class="planet-stats">
<div>
<dt>{i18n.t("game.calculator.planet.ships_per_turn")}</dt>
<dd data-testid="calculator-ships-per-turn">
{fmt(planetBuild?.shipsPerTurn)}
</dd>
</div>
<div>
<dt>{i18n.t("game.calculator.planet.turns_per_ship")}</dt>
<dd data-testid="calculator-turns-per-ship">
{fmt(planetBuild?.turnsPerShip ?? null)}
</dd>
</div>
</dl>
{/if}
</div>
{:else}
<div class="modern" data-testid="calculator-modernization">
<div class="rrow head">
<span></span>
<span class="col-head">{i18n.t("game.calculator.modern.target")}</span>
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
</div>
{#each modernCosts?.perBlock ?? [] as row (row.key)}
<div class="rrow">
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
<span class="cell">
<input
type="number"
step="0.001"
min="0"
bind:value={cs.targetTech[row.key]}
aria-invalid={cs.targetTech[row.key] < 0 ? "true" : "false"}
title={cs.targetTech[row.key] < 0
? i18n.t("game.calculator.invalid.negative")
: ""}
data-testid={`calculator-target-${row.key}`}
/>
</span>
<span class="cell">
<span class="val" data-testid={`calculator-modern-cost-${row.key}`}>
{fmt(row.cost)}
</span>
</span>
</div>
{/each}
<div class="rrow total">
<span class="label">{i18n.t("game.calculator.modern.total")}</span>
<span class="cell">
<span class="val" data-testid="calculator-modern-total">
{fmt(modernCosts?.total)}
</span>
</span>
</div>
</div>
{/if}
</section>
<style>
.calculator {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.75rem;
font-family: system-ui, sans-serif;
}
.modes {
display: flex;
gap: 0.25rem;
}
.modes button {
flex: 1;
font: inherit;
font-size: 0.8rem;
padding: 0.25rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.modes button.active {
color: var(--color-text);
border-color: var(--color-accent);
background: var(--color-surface-raised);
}
.namebar {
display: flex;
gap: 0.35rem;
}
.name {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.4rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.name[aria-invalid="true"] {
border-color: var(--color-danger);
}
.create,
.delete {
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.create:not(:disabled):hover {
color: var(--color-text);
border-color: var(--color-accent);
}
.create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete {
color: var(--color-danger);
align-self: flex-start;
}
.load {
display: flex;
align-items: center;
gap: 0.4rem;
}
.seg {
display: flex;
gap: 0.15rem;
}
.seg button {
font: inherit;
font-size: 0.75rem;
padding: 0.15rem 0.4rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.seg button.active {
color: var(--color-text);
border-color: var(--color-accent);
}
.custom-load {
width: 4rem;
font: inherit;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.results,
.modern {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rrow {
display: grid;
grid-template-columns: 4.5rem 1fr 1fr;
align-items: center;
gap: 0.35rem;
}
.col-head {
color: var(--color-text-muted);
font-size: 0.7rem;
text-align: center;
}
.label {
color: var(--color-text-muted);
font-size: 0.8rem;
}
.cell {
display: flex;
align-items: center;
gap: 0.2rem;
justify-content: flex-end;
}
.cell .val {
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
}
.cell input {
width: 100%;
min-width: 0;
font: inherit;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.cell.locked input {
color: var(--color-accent);
border-color: var(--color-accent);
}
.cell.infeasible input {
border-color: var(--color-danger);
color: var(--color-danger);
}
.lock {
flex: none;
padding: 0;
font-size: 0.7rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
opacity: 0.5;
}
.lock.active,
.lock:not(:disabled):hover {
opacity: 1;
}
.lock:disabled {
cursor: not-allowed;
opacity: 0.2;
}
.planet {
border-top: 1px solid var(--color-border-subtle);
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hint {
margin: 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.planet-name {
margin: 0;
font-size: 0.8rem;
color: var(--color-text);
}
.planet-stats {
margin: 0;
display: grid;
grid-template-columns: 1fr max-content;
row-gap: 0.2rem;
column-gap: 0.5rem;
}
.planet-stats div {
display: contents;
}
.planet-stats dt {
color: var(--color-text-muted);
font-size: 0.8rem;
}
.planet-stats dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
}
.rrow.total .label {
grid-column: 1 / 3;
color: var(--color-text);
white-space: nowrap;
}
input[aria-invalid="true"] {
border-color: var(--color-danger);
}
.seg button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.full-capacity {
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
color: var(--color-accent);
}
</style>