ui/phase-13: planet inspector — read-only

Plumbs the map → inspector pathway: a click on a planet selects it
through the new SelectionStore, the sidebar Inspector tab swaps
its empty-state copy for a per-kind read-only field set, and a
mobile-only bottom-sheet mirrors the same content over the map.
Field projection in api/game-state.ts now surfaces every documented
planet field.
This commit is contained in:
Ilia Denisov
2026-05-09 08:29:03 +02:00
parent a3fdcfe9c5
commit 6364bba6fd
19 changed files with 1440 additions and 75 deletions
@@ -0,0 +1,197 @@
<!--
Phase 13 read-only planet inspector. Renders the documented field
set for the planet kind in question:
- `local` / `other` carry the full economy: name, owner (other only),
coordinates, size, population, colonists, industry, both stockpiles,
natural resources, current production, free production potential.
- `uninhabited` keeps name, coordinates, size, both stockpiles, and
natural resources — the engine does not project industry or
population for unowned planets.
- `unidentified` is reduced to coordinates plus a no-data hint.
The component is purely presentational: the parent supplies a
`ReportPlanet` snapshot resolved from `GameStateStore`, no store
lookups happen here. Phase 14 will extend the same component with a
`Rename` action; the read-only layout stays the structural baseline.
-->
<script lang="ts">
import type { ReportPlanet } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
type Props = {
planet: ReportPlanet;
};
let { planet }: Props = $props();
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
local: "game.inspector.planet.kind.local",
other: "game.inspector.planet.kind.other",
uninhabited: "game.inspector.planet.kind.uninhabited",
unidentified: "game.inspector.planet.kind.unidentified",
};
const kindLabel = $derived(i18n.t(kindKeyMap[planet.kind]));
const coordinates = $derived(
`(${formatNumber(planet.x)}, ${formatNumber(planet.y)})`,
);
const productionLabel = $derived(productionDisplay(planet.production));
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function productionDisplay(value: string | null): string {
if (value === null || value === "") {
return i18n.t("game.inspector.planet.production_none");
}
return value;
}
</script>
<section
class="inspector"
data-testid="inspector-planet"
data-planet-id={planet.number}
data-planet-kind={planet.kind}
>
<header>
<p class="kind" data-testid="inspector-planet-kind">{kindLabel}</p>
{#if planet.kind !== "unidentified"}
<h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3>
{/if}
</header>
<dl class="fields">
{#if planet.kind === "other" && planet.owner !== null}
<div class="field" data-testid="inspector-planet-field-owner">
<dt>{i18n.t("game.inspector.planet.field.owner")}</dt>
<dd>{planet.owner}</dd>
</div>
{/if}
<div class="field" data-testid="inspector-planet-field-coordinates">
<dt>{i18n.t("game.inspector.planet.field.coordinates")}</dt>
<dd>{coordinates}</dd>
</div>
{#if planet.size !== null}
<div class="field" data-testid="inspector-planet-field-size">
<dt>{i18n.t("game.inspector.planet.field.size")}</dt>
<dd>{formatNumber(planet.size)}</dd>
</div>
{/if}
{#if planet.resources !== null}
<div class="field" data-testid="inspector-planet-field-natural_resources">
<dt>{i18n.t("game.inspector.planet.field.natural_resources")}</dt>
<dd>{formatNumber(planet.resources)}</dd>
</div>
{/if}
{#if planet.population !== null}
<div class="field" data-testid="inspector-planet-field-population">
<dt>{i18n.t("game.inspector.planet.field.population")}</dt>
<dd>{formatNumber(planet.population)}</dd>
</div>
{/if}
{#if planet.colonists !== null}
<div class="field" data-testid="inspector-planet-field-colonists">
<dt>{i18n.t("game.inspector.planet.field.colonists")}</dt>
<dd>{formatNumber(planet.colonists)}</dd>
</div>
{/if}
{#if planet.industry !== null}
<div class="field" data-testid="inspector-planet-field-industry">
<dt>{i18n.t("game.inspector.planet.field.industry")}</dt>
<dd>{formatNumber(planet.industry)}</dd>
</div>
{/if}
{#if planet.industryStockpile !== null}
<div class="field" data-testid="inspector-planet-field-industry_stockpile">
<dt>{i18n.t("game.inspector.planet.field.industry_stockpile")}</dt>
<dd>{formatNumber(planet.industryStockpile)}</dd>
</div>
{/if}
{#if planet.materialsStockpile !== null}
<div class="field" data-testid="inspector-planet-field-materials_stockpile">
<dt>{i18n.t("game.inspector.planet.field.materials_stockpile")}</dt>
<dd>{formatNumber(planet.materialsStockpile)}</dd>
</div>
{/if}
{#if planet.production !== null}
<div class="field" data-testid="inspector-planet-field-production">
<dt>{i18n.t("game.inspector.planet.field.production")}</dt>
<dd>{productionLabel}</dd>
</div>
{/if}
{#if planet.freeIndustry !== null}
<div class="field" data-testid="inspector-planet-field-free_industry">
<dt>{i18n.t("game.inspector.planet.field.free_industry")}</dt>
<dd>{formatNumber(planet.freeIndustry)}</dd>
</div>
{/if}
</dl>
{#if planet.kind === "unidentified"}
<p class="hint" data-testid="inspector-planet-no-data">
{i18n.t("game.inspector.planet.unidentified_no_data")}
</p>
{/if}
</section>
<style>
.inspector {
padding: 1rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.kind {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #aab;
}
.name {
margin: 0;
font-size: 1.05rem;
}
.fields {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.75rem;
}
.field {
display: contents;
}
.field dt {
color: #aab;
font-size: 0.85rem;
}
.field dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.hint {
margin: 0;
color: #888;
font-size: 0.85rem;
}
</style>