Merge pull request 'fix(ui): F8-08 unified number format — mono, fixed 3-decimal, no separators' (#65) from feature/issue-51-number-format into development
Deploy · Dev / deploy (push) Successful in 54s
Tests · UI / test (push) Successful in 2m50s

This commit was merged in pull request #65.
This commit is contained in:
2026-05-27 10:14:05 +00:00
35 changed files with 486 additions and 381 deletions
@@ -43,6 +43,7 @@ fractions is a Phase 21 decision documented in
validateScience,
type ScienceInvalidReason,
} from "$lib/util/science-validation";
import { formatPercent } from "$lib/util/number-format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
@@ -106,12 +107,7 @@ fractions is a Phase 21 decision documented in
const canSave = $derived(validation.ok && draft !== undefined);
const sumPercent = $derived(drive + weapons + shields + cargo);
const sumDisplay = $derived(
sumPercent.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}),
);
const sumDisplay = $derived(sumPercent.toFixed(1));
$effect(() => {
if (!isViewMode) {
@@ -119,13 +115,6 @@ fractions is a Phase 21 decision documented in
}
});
function formatPercent(fraction: number): string {
return (fraction * 100).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
}
function backToTable(): void {
activeView.select("table", { tableEntity: "sciences" });
}
@@ -1,61 +1,44 @@
// Shared number / planet formatters for the Phase 23 Report View
// sections. Inlined in 10+ components, so factoring keeps each
// section component focused on its data shape. The formatters
// match the conventions of the per-entity tables (tabular numerals,
// one-decimal percent without a `%` suffix — the header carries the
// unit) so the report's grids read the same way as the
// table-races / table-sciences views.
// Number formatters and lookup helpers reused across the report-section
// components. The numeric formatters delegate to the project-wide
// `lib/util/number-format` so inspector, report tables, and the
// calculator panel all render numbers identically.
import type { ReportPlanet } from "../../../api/game-state";
import {
formatFloat as formatFloatBase,
formatInt,
formatPercent as formatPercentBase,
} from "$lib/util/number-format";
/**
* formatPercent renders a `[0, 1]` fraction as a one-decimal
* percent (without a `%` suffix — the column header carries the
* unit). Matches the convention used by `table-races.svelte` and
* `table-sciences.svelte`.
* formatPercent renders a `[0, 1]` fraction as a one-decimal percent
* (without a `%` suffix — the column header carries the unit).
* Re-exported from the shared util for backwards-compatible imports
* across the report sections.
*/
export function formatPercent(fraction: number): string {
return (fraction * 100).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
}
export const formatPercent = formatPercentBase;
/**
* formatCount renders an integer-ish value (population, industry,
* planet count, …) without fractional digits and with locale-aware
* thousand separators.
* planet count, …) with zero fractional digits and no thousand
* separators. Alias of the shared `formatInt`.
*/
export function formatCount(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
export const formatCount = formatInt;
/**
* formatFloat renders a floating-point value with up to two
* fractional digits. Used for stockpiles, distances, cost, mass —
* everything the engine emits as a `Float` that is not a fraction.
* formatFloat renders an engine `Float` (Fixed3-quantised) with three
* fractional digits and no thousand separators. Used for stockpiles,
* distances, cost, mass, tech levels — every report payload that is
* neither an integer count nor a `[0, 1]` fraction.
*/
export function formatFloat(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
export const formatFloat = formatFloatBase;
/**
* formatVotes renders a vote weight with up to two decimal digits —
* mirrors the races table's column convention so the cumulative
* vote totals line up across views.
* formatVotes renders a vote weight. Votes travel as the same `Float`
* shape as every other float field, so this is a semantic alias of
* `formatFloat` kept for readability at the call site.
*/
export function formatVotes(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
export const formatVotes = formatFloatBase;
/**
* planetLabel renders a planet reference as `#<number> (<name>)` if
@@ -42,11 +42,11 @@ class when the group lands and a battle roster forms.
<tr>
<th>{i18n.t("game.report.section.approaching_groups.column.from")}</th>
<th>{i18n.t("game.report.section.approaching_groups.column.to")}</th>
<th>
<th class="numeric">
{i18n.t("game.report.section.approaching_groups.column.distance")}
</th>
<th>{i18n.t("game.report.section.approaching_groups.column.speed")}</th>
<th>{i18n.t("game.report.section.approaching_groups.column.mass")}</th>
<th class="numeric">{i18n.t("game.report.section.approaching_groups.column.speed")}</th>
<th class="numeric">{i18n.t("game.report.section.approaching_groups.column.mass")}</th>
</tr>
</thead>
<tbody>
@@ -54,9 +54,9 @@ class when the group lands and a battle roster forms.
<tr data-testid="approaching-groups-row">
<td>{planetLabel(r.origin, planets)}</td>
<td>{planetLabel(r.destination, planets)}</td>
<td>{formatFloat(r.distance)}</td>
<td>{formatFloat(r.speed)}</td>
<td>{formatFloat(r.mass)}</td>
<td class="numeric">{formatFloat(r.distance)}</td>
<td class="numeric">{formatFloat(r.speed)}</td>
<td class="numeric">{formatFloat(r.mass)}</td>
</tr>
{/each}
</tbody>
@@ -79,7 +79,7 @@ class when the group lands and a battle roster forms.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -93,6 +93,11 @@ class when the group lands and a battle roster forms.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -42,16 +42,16 @@ Decoder sorts by `planetNumber` already.
<th>{i18n.t("game.report.section.bombings.column.owner")}</th>
<th>{i18n.t("game.report.section.bombings.column.attacker")}</th>
<th>{i18n.t("game.report.section.bombings.column.production")}</th>
<th>{i18n.t("game.report.section.bombings.column.industry")}</th>
<th>{i18n.t("game.report.section.bombings.column.population")}</th>
<th>{i18n.t("game.report.section.bombings.column.colonists")}</th>
<th>
<th class="numeric">{i18n.t("game.report.section.bombings.column.industry")}</th>
<th class="numeric">{i18n.t("game.report.section.bombings.column.population")}</th>
<th class="numeric">{i18n.t("game.report.section.bombings.column.colonists")}</th>
<th class="numeric">
{i18n.t("game.report.section.bombings.column.industry_stockpile")}
</th>
<th>
<th class="numeric">
{i18n.t("game.report.section.bombings.column.materials_stockpile")}
</th>
<th>{i18n.t("game.report.section.bombings.column.attack_power")}</th>
<th class="numeric">{i18n.t("game.report.section.bombings.column.attack_power")}</th>
<th></th>
</tr>
</thead>
@@ -67,12 +67,12 @@ Decoder sorts by `planetNumber` already.
<td>{b.owner}</td>
<td>{b.attacker}</td>
<td>{b.production}</td>
<td>{formatFloat(b.industry)}</td>
<td>{formatFloat(b.population)}</td>
<td>{formatFloat(b.colonists)}</td>
<td>{formatFloat(b.industryStockpile)}</td>
<td>{formatFloat(b.materialsStockpile)}</td>
<td>{formatCount(b.attackPower)}</td>
<td class="numeric">{formatFloat(b.industry)}</td>
<td class="numeric">{formatFloat(b.population)}</td>
<td class="numeric">{formatFloat(b.colonists)}</td>
<td class="numeric">{formatFloat(b.industryStockpile)}</td>
<td class="numeric">{formatFloat(b.materialsStockpile)}</td>
<td class="numeric">{formatCount(b.attackPower)}</td>
<td>
{#if b.wiped}
<span
@@ -105,7 +105,7 @@ Decoder sorts by `planetNumber` already.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -119,6 +119,11 @@ Decoder sorts by `planetNumber` already.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -94,7 +94,7 @@ has many routes.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -42,20 +42,20 @@ as the local planets table plus an `owner` column.
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
<th>{i18n.t("game.report.section.foreign_planets.column.owner")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th>{i18n.t("game.report.section.my_planets.column.population")}</th>
<th>{i18n.t("game.report.section.my_planets.column.industry")}</th>
<th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.size")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.population")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.industry")}</th>
<th class="numeric">
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
</th>
<th>
<th class="numeric">
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
</th>
<th>{i18n.t("game.report.section.my_planets.column.colonists")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.colonists")}</th>
<th>{i18n.t("game.report.section.my_planets.column.production")}</th>
<th>{i18n.t("game.report.section.my_planets.column.free_industry")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.free_industry")}</th>
</tr>
</thead>
<tbody>
@@ -64,16 +64,16 @@ as the local planets table plus an `owner` column.
<td>{p.number}</td>
<td>{p.name}</td>
<td>{p.owner ?? ""}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td>{formatFloat(p.size ?? 0)}</td>
<td>{formatFloat(p.resources ?? 0)}</td>
<td>{formatFloat(p.population ?? 0)}</td>
<td>{formatFloat(p.industry ?? 0)}</td>
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
<td>{formatFloat(p.colonists ?? 0)}</td>
<td class="numeric">{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td class="numeric">{formatFloat(p.size ?? 0)}</td>
<td class="numeric">{formatFloat(p.resources ?? 0)}</td>
<td class="numeric">{formatFloat(p.population ?? 0)}</td>
<td class="numeric">{formatFloat(p.industry ?? 0)}</td>
<td class="numeric">{formatFloat(p.industryStockpile ?? 0)}</td>
<td class="numeric">{formatFloat(p.materialsStockpile ?? 0)}</td>
<td class="numeric">{formatFloat(p.colonists ?? 0)}</td>
<td>{p.production ?? "—"}</td>
<td>{formatFloat(p.freeIndustry ?? 0)}</td>
<td class="numeric">{formatFloat(p.freeIndustry ?? 0)}</td>
</tr>
{/each}
</tbody>
@@ -96,7 +96,7 @@ as the local planets table plus an `owner` column.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -110,6 +110,11 @@ as the local planets table plus an `owner` column.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -67,10 +67,10 @@ unit even when the section spans many races.
<thead>
<tr>
<th>{i18n.t("game.report.section.my_sciences.column.name")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.drive")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.shields")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.cargo")}</th>
<th class="numeric">{i18n.t("game.report.section.my_sciences.column.drive")}</th>
<th class="numeric">{i18n.t("game.report.section.my_sciences.column.weapons")}</th>
<th class="numeric">{i18n.t("game.report.section.my_sciences.column.shields")}</th>
<th class="numeric">{i18n.t("game.report.section.my_sciences.column.cargo")}</th>
</tr>
</thead>
<tbody>
@@ -81,10 +81,10 @@ unit even when the section spans many races.
data-name={r.name}
>
<td>{r.name}</td>
<td>{formatPercent(r.drive)}</td>
<td>{formatPercent(r.weapons)}</td>
<td>{formatPercent(r.shields)}</td>
<td>{formatPercent(r.cargo)}</td>
<td class="numeric">{formatPercent(r.drive)}</td>
<td class="numeric">{formatPercent(r.weapons)}</td>
<td class="numeric">{formatPercent(r.shields)}</td>
<td class="numeric">{formatPercent(r.cargo)}</td>
</tr>
{/each}
</tbody>
@@ -115,7 +115,7 @@ unit even when the section spans many races.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -129,6 +129,11 @@ unit even when the section spans many races.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -65,12 +65,12 @@ incoming groups.
<thead>
<tr>
<th>{i18n.t("game.report.section.my_ship_classes.column.name")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.drive")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_classes.column.drive")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.armament")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.shields")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.cargo")}</th>
<th>{i18n.t("game.report.section.foreign_ship_classes.column.mass")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_classes.column.weapons")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_classes.column.shields")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_classes.column.cargo")}</th>
<th class="numeric">{i18n.t("game.report.section.foreign_ship_classes.column.mass")}</th>
</tr>
</thead>
<tbody>
@@ -81,12 +81,12 @@ incoming groups.
data-name={r.name}
>
<td>{r.name}</td>
<td>{formatFloat(r.drive)}</td>
<td class="numeric">{formatFloat(r.drive)}</td>
<td>{r.armament}</td>
<td>{formatFloat(r.weapons)}</td>
<td>{formatFloat(r.shields)}</td>
<td>{formatFloat(r.cargo)}</td>
<td>{formatFloat(r.mass)}</td>
<td class="numeric">{formatFloat(r.weapons)}</td>
<td class="numeric">{formatFloat(r.shields)}</td>
<td class="numeric">{formatFloat(r.cargo)}</td>
<td class="numeric">{formatFloat(r.mass)}</td>
</tr>
{/each}
</tbody>
@@ -117,7 +117,7 @@ incoming groups.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -131,6 +131,11 @@ incoming groups.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -49,8 +49,8 @@ to groups the player doesn't own.
<th>{i18n.t("game.report.section.my_ship_groups.column.destination")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.origin")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.range")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.speed")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.mass")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_groups.column.speed")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_groups.column.mass")}</th>
</tr>
</thead>
<tbody>
@@ -64,8 +64,8 @@ to groups the player doesn't own.
{g.origin === null ? "—" : planetLabel(g.origin, planets)}
</td>
<td>{g.range === null ? "—" : formatFloat(g.range)}</td>
<td>{formatFloat(g.speed)}</td>
<td>{formatFloat(g.mass)}</td>
<td class="numeric">{formatFloat(g.speed)}</td>
<td class="numeric">{formatFloat(g.mass)}</td>
</tr>
{/each}
</tbody>
@@ -88,7 +88,7 @@ to groups the player doesn't own.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -102,6 +102,11 @@ to groups the player doesn't own.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -31,13 +31,13 @@ section is never empty as long as the report has loaded.
{:else}
<dl class="kv">
<dt>{i18n.t("game.report.section.galaxy_summary.field.turn")}</dt>
<dd data-testid="galaxy-summary-field-turn">{report.turn}</dd>
<dd class="numeric" data-testid="galaxy-summary-field-turn">{report.turn}</dd>
<dt>{i18n.t("game.report.section.galaxy_summary.field.size")}</dt>
<dd data-testid="galaxy-summary-field-size">
<dd class="numeric" data-testid="galaxy-summary-field-size">
{report.mapWidth} × {report.mapHeight}
</dd>
<dt>{i18n.t("game.report.section.galaxy_summary.field.planets")}</dt>
<dd data-testid="galaxy-summary-field-planets">{report.planetCount}</dd>
<dd class="numeric" data-testid="galaxy-summary-field-planets">{report.planetCount}</dd>
<dt>{i18n.t("game.report.section.galaxy_summary.field.race")}</dt>
<dd data-testid="galaxy-summary-field-race">{report.race}</dd>
</dl>
@@ -60,7 +60,7 @@ section is never empty as long as the report has loaded.
grid-template-columns: max-content 1fr;
gap: 0.3rem 1rem;
margin: 0;
font-size: 0.9rem;
font-size: 0.85rem;
}
.kv dt {
color: var(--color-text-muted);
@@ -73,4 +73,7 @@ section is never empty as long as the report has loaded.
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.kv dd.numeric {
font-family: var(--font-mono);
}
</style>
@@ -44,7 +44,7 @@ in orbit has neither); empty cells in those columns are normal.
<th>{i18n.t("game.report.section.my_fleets.column.destination")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.origin")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.range")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.speed")}</th>
<th class="numeric">{i18n.t("game.report.section.my_fleets.column.speed")}</th>
</tr>
</thead>
<tbody>
@@ -58,7 +58,7 @@ in orbit has neither); empty cells in those columns are normal.
{f.origin === null ? "—" : planetLabel(f.origin, planets)}
</td>
<td>{f.range === null ? "—" : formatFloat(f.range)}</td>
<td>{formatFloat(f.speed)}</td>
<td class="numeric">{formatFloat(f.speed)}</td>
</tr>
{/each}
</tbody>
@@ -81,7 +81,7 @@ in orbit has neither); empty cells in those columns are normal.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -95,6 +95,11 @@ in orbit has neither); empty cells in those columns are normal.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -41,20 +41,20 @@ column set (matches `ReportPlanet` shape).
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th>{i18n.t("game.report.section.my_planets.column.population")}</th>
<th>{i18n.t("game.report.section.my_planets.column.industry")}</th>
<th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.size")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.population")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.industry")}</th>
<th class="numeric">
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
</th>
<th>
<th class="numeric">
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
</th>
<th>{i18n.t("game.report.section.my_planets.column.colonists")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.colonists")}</th>
<th>{i18n.t("game.report.section.my_planets.column.production")}</th>
<th>{i18n.t("game.report.section.my_planets.column.free_industry")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.free_industry")}</th>
</tr>
</thead>
<tbody>
@@ -62,16 +62,16 @@ column set (matches `ReportPlanet` shape).
<tr data-testid="my-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{p.name}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td>{formatFloat(p.size ?? 0)}</td>
<td>{formatFloat(p.resources ?? 0)}</td>
<td>{formatFloat(p.population ?? 0)}</td>
<td>{formatFloat(p.industry ?? 0)}</td>
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
<td>{formatFloat(p.colonists ?? 0)}</td>
<td class="numeric">{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td class="numeric">{formatFloat(p.size ?? 0)}</td>
<td class="numeric">{formatFloat(p.resources ?? 0)}</td>
<td class="numeric">{formatFloat(p.population ?? 0)}</td>
<td class="numeric">{formatFloat(p.industry ?? 0)}</td>
<td class="numeric">{formatFloat(p.industryStockpile ?? 0)}</td>
<td class="numeric">{formatFloat(p.materialsStockpile ?? 0)}</td>
<td class="numeric">{formatFloat(p.colonists ?? 0)}</td>
<td>{p.production ?? "—"}</td>
<td>{formatFloat(p.freeIndustry ?? 0)}</td>
<td class="numeric">{formatFloat(p.freeIndustry ?? 0)}</td>
</tr>
{/each}
</tbody>
@@ -94,7 +94,7 @@ column set (matches `ReportPlanet` shape).
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -108,6 +108,11 @@ column set (matches `ReportPlanet` shape).
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -39,20 +39,20 @@ table).
<thead>
<tr>
<th>{i18n.t("game.report.section.my_sciences.column.name")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.drive")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.shields")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.cargo")}</th>
<th class="numeric">{i18n.t("game.report.section.my_sciences.column.drive")}</th>
<th class="numeric">{i18n.t("game.report.section.my_sciences.column.weapons")}</th>
<th class="numeric">{i18n.t("game.report.section.my_sciences.column.shields")}</th>
<th class="numeric">{i18n.t("game.report.section.my_sciences.column.cargo")}</th>
</tr>
</thead>
<tbody>
{#each rows as r (r.name)}
<tr data-testid="my-sciences-row" data-name={r.name}>
<td>{r.name}</td>
<td>{formatPercent(r.drive)}</td>
<td>{formatPercent(r.weapons)}</td>
<td>{formatPercent(r.shields)}</td>
<td>{formatPercent(r.cargo)}</td>
<td class="numeric">{formatPercent(r.drive)}</td>
<td class="numeric">{formatPercent(r.weapons)}</td>
<td class="numeric">{formatPercent(r.shields)}</td>
<td class="numeric">{formatPercent(r.cargo)}</td>
</tr>
{/each}
</tbody>
@@ -75,7 +75,7 @@ table).
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -89,6 +89,11 @@ table).
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -40,22 +40,22 @@ drafts immediately, matching the ship-class designer's behaviour.
<thead>
<tr>
<th>{i18n.t("game.report.section.my_ship_classes.column.name")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.drive")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_classes.column.drive")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.armament")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.shields")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.cargo")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_classes.column.weapons")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_classes.column.shields")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_classes.column.cargo")}</th>
</tr>
</thead>
<tbody>
{#each rows as r (r.name)}
<tr data-testid="my-ship-classes-row" data-name={r.name}>
<td>{r.name}</td>
<td>{formatFloat(r.drive)}</td>
<td class="numeric">{formatFloat(r.drive)}</td>
<td>{r.armament}</td>
<td>{formatFloat(r.weapons)}</td>
<td>{formatFloat(r.shields)}</td>
<td>{formatFloat(r.cargo)}</td>
<td class="numeric">{formatFloat(r.weapons)}</td>
<td class="numeric">{formatFloat(r.shields)}</td>
<td class="numeric">{formatFloat(r.cargo)}</td>
</tr>
{/each}
</tbody>
@@ -78,7 +78,7 @@ drafts immediately, matching the ship-class designer's behaviour.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -92,6 +92,11 @@ drafts immediately, matching the ship-class designer's behaviour.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -59,8 +59,8 @@ shown together with `load` when carrying.
<th>{i18n.t("game.report.section.my_ship_groups.column.destination")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.origin")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.range")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.speed")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.mass")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_groups.column.speed")}</th>
<th class="numeric">{i18n.t("game.report.section.my_ship_groups.column.mass")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.fleet")}</th>
</tr>
</thead>
@@ -77,8 +77,8 @@ shown together with `load` when carrying.
{g.origin === null ? "—" : planetLabel(g.origin, planets)}
</td>
<td>{g.range === null ? "—" : formatFloat(g.range)}</td>
<td>{formatFloat(g.speed)}</td>
<td>{formatFloat(g.mass)}</td>
<td class="numeric">{formatFloat(g.speed)}</td>
<td class="numeric">{formatFloat(g.mass)}</td>
<td>{g.fleet ?? "—"}</td>
</tr>
{/each}
@@ -102,7 +102,7 @@ shown together with `load` when carrying.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -116,6 +116,11 @@ shown together with `load` when carrying.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -14,7 +14,7 @@ highlight so the user can locate themselves quickly.
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatCount, formatPercent, formatVotes } from "./format";
import { formatCount, formatFloat, formatVotes } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
@@ -37,14 +37,14 @@ highlight so the user can locate themselves quickly.
<thead>
<tr>
<th>{i18n.t("game.report.section.player_status.column.name")}</th>
<th>{i18n.t("game.report.section.player_status.column.drive")}</th>
<th>{i18n.t("game.report.section.player_status.column.weapons")}</th>
<th>{i18n.t("game.report.section.player_status.column.shields")}</th>
<th>{i18n.t("game.report.section.player_status.column.cargo")}</th>
<th>{i18n.t("game.report.section.player_status.column.population")}</th>
<th>{i18n.t("game.report.section.player_status.column.industry")}</th>
<th>{i18n.t("game.report.section.player_status.column.planets")}</th>
<th>{i18n.t("game.report.section.player_status.column.votes")}</th>
<th class="numeric">{i18n.t("game.report.section.player_status.column.drive")}</th>
<th class="numeric">{i18n.t("game.report.section.player_status.column.weapons")}</th>
<th class="numeric">{i18n.t("game.report.section.player_status.column.shields")}</th>
<th class="numeric">{i18n.t("game.report.section.player_status.column.cargo")}</th>
<th class="numeric">{i18n.t("game.report.section.player_status.column.population")}</th>
<th class="numeric">{i18n.t("game.report.section.player_status.column.industry")}</th>
<th class="numeric">{i18n.t("game.report.section.player_status.column.planets")}</th>
<th class="numeric">{i18n.t("game.report.section.player_status.column.votes")}</th>
</tr>
</thead>
<tbody>
@@ -73,14 +73,14 @@ highlight so the user can locate themselves quickly.
</span>
{/if}
</td>
<td>{formatPercent(p.drive)}</td>
<td>{formatPercent(p.weapons)}</td>
<td>{formatPercent(p.shields)}</td>
<td>{formatPercent(p.cargo)}</td>
<td>{formatCount(p.population)}</td>
<td>{formatCount(p.industry)}</td>
<td>{formatCount(p.planets)}</td>
<td>{formatVotes(p.votesReceived)}</td>
<td class="numeric">{formatFloat(p.drive)}</td>
<td class="numeric">{formatFloat(p.weapons)}</td>
<td class="numeric">{formatFloat(p.shields)}</td>
<td class="numeric">{formatFloat(p.cargo)}</td>
<td class="numeric">{formatCount(p.population)}</td>
<td class="numeric">{formatCount(p.industry)}</td>
<td class="numeric">{formatCount(p.planets)}</td>
<td class="numeric">{formatVotes(p.votesReceived)}</td>
</tr>
{/each}
</tbody>
@@ -103,7 +103,7 @@ highlight so the user can locate themselves quickly.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -117,6 +117,11 @@ highlight so the user can locate themselves quickly.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -41,12 +41,12 @@ reads `#17 (Castle)` rather than just `#17`.
<tr>
<th>{i18n.t("game.report.section.ships_in_production.column.planet")}</th>
<th>{i18n.t("game.report.section.ships_in_production.column.class")}</th>
<th>{i18n.t("game.report.section.ships_in_production.column.cost")}</th>
<th>
<th class="numeric">{i18n.t("game.report.section.ships_in_production.column.cost")}</th>
<th class="numeric">
{i18n.t("game.report.section.ships_in_production.column.prod_used")}
</th>
<th>{i18n.t("game.report.section.ships_in_production.column.percent")}</th>
<th>{i18n.t("game.report.section.ships_in_production.column.free")}</th>
<th class="numeric">{i18n.t("game.report.section.ships_in_production.column.free")}</th>
</tr>
</thead>
<tbody>
@@ -58,10 +58,10 @@ reads `#17 (Castle)` rather than just `#17`.
>
<td>{planetLabel(r.planetNumber, planets)}</td>
<td>{r.class}</td>
<td>{formatFloat(r.cost)}</td>
<td>{formatFloat(r.prodUsed)}</td>
<td class="numeric">{formatFloat(r.cost)}</td>
<td class="numeric">{formatFloat(r.prodUsed)}</td>
<td>{(r.percent * 100).toFixed(1)}</td>
<td>{formatFloat(r.freeIndustry)}</td>
<td class="numeric">{formatFloat(r.freeIndustry)}</td>
</tr>
{/each}
</tbody>
@@ -84,7 +84,7 @@ reads `#17 (Castle)` rather than just `#17`.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -98,6 +98,11 @@ reads `#17 (Castle)` rather than just `#17`.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -37,15 +37,15 @@ radar that doesn't even resolve to a planet.
<table class="grid" data-testid="unidentified-groups-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.unidentified_groups.column.x")}</th>
<th>{i18n.t("game.report.section.unidentified_groups.column.y")}</th>
<th class="numeric">{i18n.t("game.report.section.unidentified_groups.column.x")}</th>
<th class="numeric">{i18n.t("game.report.section.unidentified_groups.column.y")}</th>
</tr>
</thead>
<tbody>
{#each rows as g, i (i)}
<tr data-testid="unidentified-groups-row">
<td>{formatFloat(g.x)}</td>
<td>{formatFloat(g.y)}</td>
<td class="numeric">{formatFloat(g.x)}</td>
<td class="numeric">{formatFloat(g.y)}</td>
</tr>
{/each}
</tbody>
@@ -68,7 +68,7 @@ radar that doesn't even resolve to a planet.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -82,6 +82,11 @@ radar that doesn't even resolve to a planet.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -42,13 +42,13 @@ are intentionally omitted.
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.size")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th class="numeric">
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
</th>
<th>
<th class="numeric">
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
</th>
</tr>
@@ -58,11 +58,11 @@ are intentionally omitted.
<tr data-testid="uninhabited-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{p.name}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td>{formatFloat(p.size ?? 0)}</td>
<td>{formatFloat(p.resources ?? 0)}</td>
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
<td class="numeric">{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td class="numeric">{formatFloat(p.size ?? 0)}</td>
<td class="numeric">{formatFloat(p.resources ?? 0)}</td>
<td class="numeric">{formatFloat(p.industryStockpile ?? 0)}</td>
<td class="numeric">{formatFloat(p.materialsStockpile ?? 0)}</td>
</tr>
{/each}
</tbody>
@@ -85,7 +85,7 @@ are intentionally omitted.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -99,6 +99,11 @@ are intentionally omitted.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -40,14 +40,14 @@ else is known.
<thead>
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th class="numeric">{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
</tr>
</thead>
<tbody>
{#each rows as p (p.number)}
<tr data-testid="unknown-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td class="numeric">{formatFloat(p.x)}, {formatFloat(p.y)}</td>
</tr>
{/each}
</tbody>
@@ -70,7 +70,7 @@ else is known.
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -84,6 +84,11 @@ else is known.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -58,14 +58,14 @@ explanatory text on the races table.
<thead>
<tr>
<th>{i18n.t("game.report.section.votes.column.race")}</th>
<th>{i18n.t("game.report.section.votes.column.votes")}</th>
<th class="numeric">{i18n.t("game.report.section.votes.column.votes")}</th>
</tr>
</thead>
<tbody>
{#each races as r (r.name)}
<tr data-testid="votes-received-row" data-race={r.name}>
<td>{r.name}</td>
<td>{formatVotes(r.votesReceived)}</td>
<td class="numeric">{formatVotes(r.votesReceived)}</td>
</tr>
{/each}
</tbody>
@@ -113,7 +113,7 @@ explanatory text on the races table.
.grid {
border-collapse: collapse;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th,
.grid td {
@@ -35,6 +35,10 @@ data fetching is performed here — the layout is responsible.
} from "../../sync/order-draft.svelte";
import type { Relation } from "../../sync/order-types";
import ViewState from "$lib/ui/view-state.svelte";
import {
formatFloat,
formatInt,
} from "$lib/util/number-format";
type SortColumn =
| "name"
@@ -122,31 +126,6 @@ data fetching is performed here — the layout is responsible.
return sortDirection === "asc" ? "ascending" : "descending";
}
// Render a fraction in `[0, 1]` as a one-decimal percent
// (`0.225` → `"22.5"`). The conversion is value-only — no `%`
// suffix — so the column header carries the unit. Matches the
// sciences-table convention.
function formatPercent(fraction: number): string {
return (fraction * 100).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
}
function formatCount(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
function formatVotes(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
async function setStance(acceptor: string, relation: Relation): Promise<void> {
if (draft === undefined) return;
// No-op when the row already reflects the requested stance — the
@@ -192,7 +171,7 @@ data fetching is performed here — the layout is responsible.
<span class="summary-label">
{i18n.t("game.table.races.votes.mine")}:
</span>
<span class="summary-value">{formatVotes(myVotes)}</span>
<span class="summary-value numeric">{formatFloat(myVotes)}</span>
</span>
<label class="summary-cell vote-picker">
<span class="summary-label">
@@ -244,7 +223,7 @@ data fetching is performed here — the layout is responsible.
<thead>
<tr>
{#each COLUMNS as column (column)}
<th aria-sort={ariaSort(column)}>
<th aria-sort={ariaSort(column)} class:numeric={column !== "name"}>
<button
type="button"
class="sort"
@@ -267,23 +246,29 @@ data fetching is performed here — the layout is responsible.
{#each sorted as r (r.name)}
<tr data-testid="races-row" data-name={r.name}>
<td data-testid="races-cell-name">{r.name}</td>
<td data-testid="races-cell-drive">{formatPercent(r.drive)}</td>
<td data-testid="races-cell-weapons">
{formatPercent(r.weapons)}
<td class="numeric" data-testid="races-cell-drive">
{formatFloat(r.drive)}
</td>
<td data-testid="races-cell-shields">
{formatPercent(r.shields)}
<td class="numeric" data-testid="races-cell-weapons">
{formatFloat(r.weapons)}
</td>
<td data-testid="races-cell-cargo">{formatPercent(r.cargo)}</td>
<td data-testid="races-cell-population">
{formatCount(r.population)}
<td class="numeric" data-testid="races-cell-shields">
{formatFloat(r.shields)}
</td>
<td data-testid="races-cell-industry">
{formatCount(r.industry)}
<td class="numeric" data-testid="races-cell-cargo">
{formatFloat(r.cargo)}
</td>
<td data-testid="races-cell-planets">{formatCount(r.planets)}</td>
<td data-testid="races-cell-votes">
{formatVotes(r.votesReceived)}
<td class="numeric" data-testid="races-cell-population">
{formatInt(r.population)}
</td>
<td class="numeric" data-testid="races-cell-industry">
{formatInt(r.industry)}
</td>
<td class="numeric" data-testid="races-cell-planets">
{formatInt(r.planets)}
</td>
<td class="numeric" data-testid="races-cell-votes">
{formatFloat(r.votesReceived)}
</td>
<td>
<div
@@ -359,6 +344,9 @@ data fetching is performed here — the layout is responsible.
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.summary-value.numeric {
font-family: var(--font-mono);
}
.vote-picker select {
font: inherit;
padding: 0.2rem 0.4rem;
@@ -403,7 +391,7 @@ data fetching is performed here — the layout is responsible.
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--color-border-subtle);
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th {
color: var(--color-text-muted);
@@ -411,6 +399,14 @@ data fetching is performed here — the layout is responsible.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid th.numeric .sort {
justify-content: flex-end;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
@@ -31,6 +31,7 @@ data fetching is performed here — the shell is responsible.
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import { formatPercent } from "$lib/util/number-format";
type SortColumn = "name" | "drive" | "weapons" | "shields" | "cargo";
type SortDirection = "asc" | "desc";
@@ -103,16 +104,6 @@ data fetching is performed here — the shell is responsible.
return sortDirection === "asc" ? "ascending" : "descending";
}
// Render a fraction in `[0, 1]` as a one-decimal percent
// (`0.225` → `"22.5"`). The conversion is value-only — no `%`
// suffix — so callers can decorate independently.
function formatPercent(fraction: number): string {
return (fraction * 100).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
}
function openDesigner(name: string): void {
activeView.select("designer-science", { scienceId: name });
}
@@ -174,7 +165,7 @@ data fetching is performed here — the shell is responsible.
<thead>
<tr>
{#each COLUMNS as column (column)}
<th aria-sort={ariaSort(column)}>
<th aria-sort={ariaSort(column)} class:numeric={column !== "name"}>
<button
type="button"
class="sort"
@@ -201,14 +192,18 @@ data fetching is performed here — the shell is responsible.
ondblclick={() => openDesigner(sci.name)}
>
<td data-testid="sciences-cell-name">{sci.name}</td>
<td data-testid="sciences-cell-drive">{formatPercent(sci.drive)}</td>
<td data-testid="sciences-cell-weapons">
<td class="numeric" data-testid="sciences-cell-drive">
{formatPercent(sci.drive)}
</td>
<td class="numeric" data-testid="sciences-cell-weapons">
{formatPercent(sci.weapons)}
</td>
<td data-testid="sciences-cell-shields">
<td class="numeric" data-testid="sciences-cell-shields">
{formatPercent(sci.shields)}
</td>
<td data-testid="sciences-cell-cargo">{formatPercent(sci.cargo)}</td>
<td class="numeric" data-testid="sciences-cell-cargo">
{formatPercent(sci.cargo)}
</td>
<td>
<button
type="button"
@@ -283,7 +278,7 @@ data fetching is performed here — the shell is responsible.
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--color-border-subtle);
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th {
color: var(--color-text-muted);
@@ -291,6 +286,14 @@ data fetching is performed here — the shell is responsible.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid th.numeric .sort {
justify-content: flex-end;
}
.grid tbody tr {
cursor: pointer;
}
@@ -27,6 +27,7 @@ data fetching is performed here — the layout is responsible.
} from "../../sync/order-draft.svelte";
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
import ViewState from "$lib/ui/view-state.svelte";
import { formatFloat } from "$lib/util/number-format";
type SortColumn =
| "name"
@@ -106,10 +107,6 @@ data fetching is performed here — the layout is responsible.
return sortDirection === "asc" ? "ascending" : "descending";
}
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function openInCalculator(name: string): void {
calculatorLoadRequest.request(name);
}
@@ -171,7 +168,7 @@ data fetching is performed here — the layout is responsible.
<thead>
<tr>
{#each COLUMNS as column (column)}
<th aria-sort={ariaSort(column)}>
<th aria-sort={ariaSort(column)} class:numeric={column !== "name"}>
<button
type="button"
class="sort"
@@ -198,11 +195,21 @@ data fetching is performed here — the layout is responsible.
ondblclick={() => openInCalculator(cls.name)}
>
<td data-testid="ship-classes-cell-name">{cls.name}</td>
<td data-testid="ship-classes-cell-drive">{formatNumber(cls.drive)}</td>
<td data-testid="ship-classes-cell-armament">{cls.armament}</td>
<td data-testid="ship-classes-cell-weapons">{formatNumber(cls.weapons)}</td>
<td data-testid="ship-classes-cell-shields">{formatNumber(cls.shields)}</td>
<td data-testid="ship-classes-cell-cargo">{formatNumber(cls.cargo)}</td>
<td class="numeric" data-testid="ship-classes-cell-drive">
{formatFloat(cls.drive)}
</td>
<td class="numeric" data-testid="ship-classes-cell-armament">
{cls.armament}
</td>
<td class="numeric" data-testid="ship-classes-cell-weapons">
{formatFloat(cls.weapons)}
</td>
<td class="numeric" data-testid="ship-classes-cell-shields">
{formatFloat(cls.shields)}
</td>
<td class="numeric" data-testid="ship-classes-cell-cargo">
{formatFloat(cls.cargo)}
</td>
<td>
<button
type="button"
@@ -277,7 +284,7 @@ data fetching is performed here — the layout is responsible.
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--color-border-subtle);
font-size: 0.9rem;
font-size: 0.85rem;
}
.grid th {
color: var(--color-text-muted);
@@ -285,6 +292,14 @@ data fetching is performed here — the layout is responsible.
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid th.numeric .sort {
justify-content: flex-end;
}
.grid tbody tr {
cursor: pointer;
}
+12 -12
View File
@@ -282,8 +282,8 @@ const en = {
"game.inspector.planet.field.population": "population",
"game.inspector.planet.field.colonists": "colonists",
"game.inspector.planet.field.industry": "industry",
"game.inspector.planet.field.industry_stockpile": "industry stockpile ($)",
"game.inspector.planet.field.materials_stockpile": "materials stockpile (M)",
"game.inspector.planet.field.industry_stockpile": "industry ($)",
"game.inspector.planet.field.materials_stockpile": "materials (M)",
"game.inspector.planet.field.natural_resources": "natural resources",
"game.inspector.planet.field.production": "current production",
"game.inspector.planet.field.free_industry": "free production",
@@ -503,10 +503,10 @@ const en = {
"game.table.races.empty": "no other races known yet",
"game.table.races.filter.placeholder": "filter by name",
"game.table.races.column.name": "name",
"game.table.races.column.drive": "drive %",
"game.table.races.column.weapons": "weapons %",
"game.table.races.column.shields": "shields %",
"game.table.races.column.cargo": "cargo %",
"game.table.races.column.drive": "drive",
"game.table.races.column.weapons": "weapons",
"game.table.races.column.shields": "shields",
"game.table.races.column.cargo": "cargo",
"game.table.races.column.population": "population",
"game.table.races.column.industry": "production",
"game.table.races.column.planets": "planets",
@@ -627,10 +627,10 @@ const en = {
"game.report.section.votes.empty": "no votes cast yet",
"game.report.section.player_status.title": "player status",
"game.report.section.player_status.column.name": "name",
"game.report.section.player_status.column.drive": "drive %",
"game.report.section.player_status.column.weapons": "weapons %",
"game.report.section.player_status.column.shields": "shields %",
"game.report.section.player_status.column.cargo": "cargo %",
"game.report.section.player_status.column.drive": "drive",
"game.report.section.player_status.column.weapons": "weapons",
"game.report.section.player_status.column.shields": "shields",
"game.report.section.player_status.column.cargo": "cargo",
"game.report.section.player_status.column.population": "population",
"game.report.section.player_status.column.industry": "production",
"game.report.section.player_status.column.planets": "planets",
@@ -693,8 +693,8 @@ const en = {
"game.report.section.bombings.column.industry": "industry",
"game.report.section.bombings.column.population": "population",
"game.report.section.bombings.column.colonists": "colonists",
"game.report.section.bombings.column.industry_stockpile": "industry stockpile ($)",
"game.report.section.bombings.column.materials_stockpile": "materials stockpile (M)",
"game.report.section.bombings.column.industry_stockpile": "industry ($)",
"game.report.section.bombings.column.materials_stockpile": "materials (M)",
"game.report.section.bombings.column.attack_power": "attack power",
"game.report.section.bombings.wiped": "wiped",
"game.report.section.approaching_groups.title": "approaching groups",
+12 -12
View File
@@ -283,8 +283,8 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.field.population": "население",
"game.inspector.planet.field.colonists": "колонисты",
"game.inspector.planet.field.industry": "промышленность",
"game.inspector.planet.field.industry_stockpile": "запасы промышленности ($)",
"game.inspector.planet.field.materials_stockpile": "запасы сырья (M)",
"game.inspector.planet.field.industry_stockpile": "промышленность ($)",
"game.inspector.planet.field.materials_stockpile": "сырьё (M)",
"game.inspector.planet.field.natural_resources": "природные ресурсы",
"game.inspector.planet.field.production": "текущее производство",
"game.inspector.planet.field.free_industry": "свободные мощности",
@@ -504,10 +504,10 @@ const ru: Record<keyof typeof en, string> = {
"game.table.races.empty": "других рас пока не видно",
"game.table.races.filter.placeholder": "фильтр по имени",
"game.table.races.column.name": "имя",
"game.table.races.column.drive": "двигатель %",
"game.table.races.column.weapons": "оружие %",
"game.table.races.column.shields": "защита %",
"game.table.races.column.cargo": "трюм %",
"game.table.races.column.drive": "двигатель",
"game.table.races.column.weapons": "оружие",
"game.table.races.column.shields": "защита",
"game.table.races.column.cargo": "трюм",
"game.table.races.column.population": "население",
"game.table.races.column.industry": "производство",
"game.table.races.column.planets": "планет",
@@ -628,10 +628,10 @@ const ru: Record<keyof typeof en, string> = {
"game.report.section.votes.empty": "голосов ещё нет",
"game.report.section.player_status.title": "статус игроков",
"game.report.section.player_status.column.name": "имя",
"game.report.section.player_status.column.drive": "двигатель %",
"game.report.section.player_status.column.weapons": "оружие %",
"game.report.section.player_status.column.shields": "защита %",
"game.report.section.player_status.column.cargo": "трюм %",
"game.report.section.player_status.column.drive": "двигатель",
"game.report.section.player_status.column.weapons": "оружие",
"game.report.section.player_status.column.shields": "защита",
"game.report.section.player_status.column.cargo": "трюм",
"game.report.section.player_status.column.population": "население",
"game.report.section.player_status.column.industry": "производство",
"game.report.section.player_status.column.planets": "планет",
@@ -694,8 +694,8 @@ const ru: Record<keyof typeof en, string> = {
"game.report.section.bombings.column.industry": "промышленность",
"game.report.section.bombings.column.population": "население",
"game.report.section.bombings.column.colonists": "колонисты",
"game.report.section.bombings.column.industry_stockpile": "запас промышленности ($)",
"game.report.section.bombings.column.materials_stockpile": "запас материалов (M)",
"game.report.section.bombings.column.industry_stockpile": "промышленность ($)",
"game.report.section.bombings.column.materials_stockpile": "сырьё (M)",
"game.report.section.bombings.column.attack_power": "сила удара",
"game.report.section.bombings.wiped": "уничтожена",
"game.report.section.approaching_groups.title": "приближающиеся группы",
+17 -17
View File
@@ -31,6 +31,7 @@ field with five buttons.
validateEntityName,
type EntityNameInvalidReason,
} from "$lib/util/entity-name";
import { formatFloat } from "$lib/util/number-format";
import CargoRoutes from "./planet/cargo-routes.svelte";
import Production from "./planet/production.svelte";
import ShipGroups from "./planet/ship-groups.svelte";
@@ -99,14 +100,10 @@ field with five buttons.
const kindLabel = $derived(i18n.t(kindKeyMap[planet.kind]));
const coordinates = $derived(
`(${formatNumber(planet.x)}, ${formatNumber(planet.y)})`,
`${formatFloat(planet.x)}, ${formatFloat(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");
@@ -252,55 +249,55 @@ field with five buttons.
<div class="field" data-testid="inspector-planet-field-coordinates">
<dt>{i18n.t("game.inspector.planet.field.coordinates")}</dt>
<dd>{coordinates}</dd>
<dd class="numeric">{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>
<dd class="numeric">{formatFloat(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>
<dd class="numeric">{formatFloat(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>
<dd class="numeric">{formatFloat(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>
<dd class="numeric">{formatFloat(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>
<dd class="numeric">{formatFloat(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>
<dd class="numeric">{formatFloat(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>
<dd class="numeric">{formatFloat(planet.materialsStockpile)}</dd>
</div>
{/if}
@@ -314,7 +311,7 @@ field with five buttons.
{#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>
<dd class="numeric">{formatFloat(planet.freeIndustry)}</dd>
</div>
{/if}
</dl>
@@ -362,17 +359,20 @@ field with five buttons.
}
.field dt {
color: var(--color-text-muted);
font-size: 0.85rem;
font-size: 0.8rem;
}
.field dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.field dd.numeric {
font-family: var(--font-mono);
}
.hint {
margin: 0;
color: var(--color-text-muted);
font-size: 0.85rem;
font-size: 0.8rem;
}
.action {
align-self: flex-start;
@@ -32,6 +32,7 @@ ship-groups table view with an additional `(planet, race)` filter.
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import { formatFloat } from "$lib/util/number-format";
type Props = {
planet: ReportPlanet;
@@ -90,10 +91,6 @@ ship-groups table view with an additional `(planet, race)` filter.
return rows;
});
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function selectLocalGroup(groupId: string): void {
if (selection === undefined) return;
selection.selectShipGroup({ variant: "local", id: groupId });
@@ -125,7 +122,7 @@ ship-groups table view with an additional `(planet, race)` filter.
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatNumber(row.mass),
mass: formatFloat(row.mass),
})}
</span>
</button>
@@ -141,7 +138,7 @@ ship-groups table view with an additional `(planet, race)` filter.
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatNumber(row.mass),
mass: formatFloat(row.mass),
})}
</span>
{/if}
@@ -20,6 +20,7 @@ variant — for Phase 19 the inspector is intentionally read-only.
ShipClassSummary,
} from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { formatFloat } from "$lib/util/number-format";
import Actions from "./ship-group/actions.svelte";
export type ShipGroupSelection =
@@ -82,10 +83,6 @@ variant — for Phase 19 the inspector is intentionally read-only.
return planet.name;
}
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function cargoLabel(cargo: "NONE" | "COL" | "CAP" | "MAT" | "EMP"): string {
if (cargo === "NONE") {
return i18n.t("game.inspector.ship_group.cargo.none");
@@ -132,27 +129,27 @@ variant — for Phase 19 the inspector is intentionally read-only.
<dl class="fields">
<div class="field" data-testid="inspector-ship-group-field-count">
<dt>{i18n.t("game.inspector.ship_group.field.count")}</dt>
<dd>{g.count}</dd>
<dd class="numeric">{g.count}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-drive">
<dt>{i18n.t("game.inspector.ship_group.field.drive")}</dt>
<dd>{formatNumber(g.tech.drive)}</dd>
<dd class="numeric">{formatFloat(g.tech.drive)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-weapons">
<dt>{i18n.t("game.inspector.ship_group.field.weapons")}</dt>
<dd>{formatNumber(g.tech.weapons)}</dd>
<dd class="numeric">{formatFloat(g.tech.weapons)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-shields">
<dt>{i18n.t("game.inspector.ship_group.field.shields")}</dt>
<dd>{formatNumber(g.tech.shields)}</dd>
<dd class="numeric">{formatFloat(g.tech.shields)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-cargo-tech">
<dt>{i18n.t("game.inspector.ship_group.field.cargo_tech")}</dt>
<dd>{formatNumber(g.tech.cargo)}</dd>
<dd class="numeric">{formatFloat(g.tech.cargo)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-mass">
<dt>{i18n.t("game.inspector.ship_group.field.mass")}</dt>
<dd>{formatNumber(g.mass)}</dd>
<dd class="numeric">{formatFloat(g.mass)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-cargo-load">
<dt>{i18n.t("game.inspector.ship_group.field.cargo_load")}</dt>
@@ -160,7 +157,7 @@ variant — for Phase 19 the inspector is intentionally read-only.
{#if g.cargo === "NONE"}
{cargoLabel(g.cargo)}
{:else}
{cargoLabel(g.cargo)} × {formatNumber(g.load)}
{cargoLabel(g.cargo)} × <span class="numeric">{formatFloat(g.load)}</span>
{/if}
</dd>
</div>
@@ -181,7 +178,7 @@ variant — for Phase 19 the inspector is intentionally read-only.
</div>
<div class="field" data-testid="inspector-ship-group-field-distance">
<dt>{i18n.t("game.inspector.ship_group.field.distance")}</dt>
<dd>{formatNumber(g.range!)}</dd>
<dd class="numeric">{formatFloat(g.range!)}</dd>
</div>
{/if}
@@ -212,23 +209,25 @@ variant — for Phase 19 the inspector is intentionally read-only.
</div>
<div class="field" data-testid="inspector-ship-group-field-distance">
<dt>{i18n.t("game.inspector.ship_group.field.distance")}</dt>
<dd>{formatNumber(g.distance)}</dd>
<dd class="numeric">{formatFloat(g.distance)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-speed">
<dt>{i18n.t("game.inspector.ship_group.field.speed")}</dt>
<dd>{formatNumber(g.speed)}</dd>
<dd class="numeric">{formatFloat(g.speed)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-eta">
<dt>{i18n.t("game.inspector.ship_group.field.eta")}</dt>
<dd>
{eta === null
? i18n.t("game.designer.ship_class.preview.unavailable")
: eta}
{#if eta === null}
{i18n.t("game.designer.ship_class.preview.unavailable")}
{:else}
<span class="numeric">{eta}</span>
{/if}
</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-mass">
<dt>{i18n.t("game.inspector.ship_group.field.mass")}</dt>
<dd>{formatNumber(g.mass)}</dd>
<dd class="numeric">{formatFloat(g.mass)}</dd>
</div>
</dl>
{:else}
@@ -238,8 +237,8 @@ variant — for Phase 19 the inspector is intentionally read-only.
data-testid="inspector-ship-group-field-coordinates"
>
<dt>{i18n.t("game.inspector.ship_group.field.coordinates")}</dt>
<dd>
({formatNumber(selection.group.x)}, {formatNumber(selection.group.y)})
<dd class="numeric">
{formatFloat(selection.group.x)}, {formatFloat(selection.group.y)}
</dd>
</div>
</dl>
@@ -285,16 +284,20 @@ variant — for Phase 19 the inspector is intentionally read-only.
}
.field dt {
color: var(--color-text-muted);
font-size: 0.85rem;
font-size: 0.8rem;
}
.field dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
font-size: 0.85rem;
}
.field dd.numeric,
.field dd .numeric {
font-family: var(--font-mono);
}
.hint {
margin: 0;
color: var(--color-text-muted);
font-size: 0.85rem;
font-size: 0.8rem;
}
</style>
@@ -42,6 +42,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
type ShipGroupUpgradeTech,
} from "../../../sync/order-types";
import { validateEntityName } from "$lib/util/entity-name";
import { formatFloat } from "$lib/util/number-format";
type Props = {
group: ReportLocalShipGroup;
@@ -675,9 +676,6 @@ modernize cost preview backed by `core.blockUpgradeCost`.
CARGO: "game.inspector.ship_group.action.tech.cargo",
};
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
</script>
<section class="actions" data-testid="inspector-ship-group-actions">
@@ -973,7 +971,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
{i18n.t("game.inspector.ship_group.action.modernize.cost_unavailable")}
{:else}
{i18n.t("game.inspector.ship_group.action.modernize.cost", {
cost: formatNumber(modernizeCostPreview),
cost: formatFloat(modernizeCostPreview),
})}
{/if}
</p>
@@ -1148,7 +1146,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
}
.action {
font: inherit;
font-size: 0.85rem;
font-size: 0.8rem;
padding: 0.2rem 0.55rem;
background: transparent;
color: var(--color-text-muted);
@@ -1177,7 +1175,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.form input[type="number"],
@@ -1196,7 +1194,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
font-size: 0.85rem;
font-size: 0.8rem;
}
.form .destination-readonly .label {
color: var(--color-text-muted);
@@ -1208,7 +1206,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
}
.form-actions button {
font: inherit;
font-size: 0.85rem;
font-size: 0.8rem;
padding: 0.25rem 0.65rem;
background: transparent;
color: var(--color-text-muted);
@@ -1226,18 +1224,18 @@ modernize cost preview backed by `core.blockUpgradeCost`.
}
.preview {
margin: 0;
font-size: 0.85rem;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.warning {
margin: 0;
font-size: 0.85rem;
font-size: 0.8rem;
color: var(--color-warning);
}
.locked {
margin: 0;
padding: 0.4rem 0.55rem;
font-size: 0.85rem;
font-size: 0.8rem;
color: var(--color-text-muted);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
+40
View File
@@ -0,0 +1,40 @@
// Shared numeric formatters for inspector and report views.
//
// Engine wire-format for floats is `Fixed3` quantised
// (pkg/model/report/report.go). The UI renders them as a fixed 3-decimal
// string with neither thousand separators nor locale-aware grouping —
// values stay column-aligned across rows and never reflow when the locale
// switches mid-session. Integer counts (planet count, ship `count`) are
// rendered with zero fractional digits to match their wire shape
// (uint16 / uint64). The convention mirrors the calculator-tab view
// (`useGrouping: false`, 3 decimals) so inspector and report tables read
// the same way as the calculator panel.
/**
* formatFloat renders an engine-emitted `Float` with three fractional
* digits and no thousand separators. Used for tech levels, stockpiles,
* coordinates, range, mass, votes — every report payload that is not
* an integer count or a `[0, 1]` fraction.
*/
export function formatFloat(value: number): string {
return value.toFixed(3);
}
/**
* formatInt renders an integer-ish count (planet count, ship count,
* etc.) with zero fractional digits and no thousand separators.
*/
export function formatInt(value: number): string {
return value.toFixed(0);
}
/**
* formatPercent renders a `[0, 1]` fraction as a one-decimal percent
* without a `%` suffix — the column header carries the unit. 0.1 %
* precision (the third decimal of the underlying float) matches the
* project-wide "three decimal digits" convention used elsewhere by
* `formatFloat`.
*/
export function formatPercent(fraction: number): string {
return (fraction * 100).toFixed(1);
}
+1 -1
View File
@@ -319,7 +319,7 @@ test("create / list / delete science via the table + designer", async ({
await expect(page.getByTestId("sciences-table")).toBeVisible();
const row = page.getByTestId("sciences-row");
await expect(row).toHaveAttribute("data-name", "FirstStep");
await expect(page.getByTestId("sciences-cell-drive")).toHaveText("25");
await expect(page.getByTestId("sciences-cell-drive")).toHaveText("25.0");
// The auto-sync round-trip lands as applied.
await page.getByTestId("sidebar-tab-order").click();
+1 -1
View File
@@ -293,7 +293,7 @@ test("create / list / delete ship class via the table + calculator", async ({
await expect(page.getByTestId("ship-classes-table")).toBeVisible();
const row = page.getByTestId("ship-classes-row");
await expect(row).toHaveAttribute("data-name", "Drone");
await expect(page.getByTestId("ship-classes-cell-drive")).toHaveText("1");
await expect(page.getByTestId("ship-classes-cell-drive")).toHaveText("1.000");
// The auto-sync round-trip lands as applied.
await page.getByTestId("sidebar-tab-order").click();
+2 -2
View File
@@ -87,7 +87,7 @@ describe("planet inspector", () => {
);
expect(
ui.getByTestId("inspector-planet-field-coordinates"),
).toHaveTextContent("(100.25, 200)");
).toHaveTextContent("100.250, 200.000");
expect(ui.getByTestId("inspector-planet-field-size")).toHaveTextContent(
"size",
);
@@ -240,7 +240,7 @@ describe("planet inspector", () => {
);
expect(
ui.getByTestId("inspector-planet-field-coordinates"),
).toHaveTextContent("(1,234, -5)");
).toHaveTextContent("1234.000, -5.000");
expect(ui.queryByTestId("inspector-planet-field-size")).toBeNull();
expect(ui.queryByTestId("inspector-planet-field-natural_resources")).toBeNull();
});
+9 -11
View File
@@ -175,18 +175,16 @@ describe("races table", () => {
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveAttribute("data-name", "Andori");
expect(ui.getByTestId("races-cell-name")).toHaveTextContent("Andori");
expect(ui.getByTestId("races-cell-drive")).toHaveTextContent("25");
expect(ui.getByTestId("races-cell-weapons")).toHaveTextContent("50");
expect(ui.getByTestId("races-cell-shields")).toHaveTextContent("75");
expect(ui.getByTestId("races-cell-cargo")).toHaveTextContent("100");
expect(ui.getByTestId("races-cell-population")).toHaveTextContent(
/12[,\s]345/,
);
expect(ui.getByTestId("races-cell-industry")).toHaveTextContent(
/6[,\s]?789/,
);
// drive/weapons/shields/cargo are tech LEVELS from the engine
// (see F8-08 bugfix); rendered as 3-decimal Floats, not percents.
expect(ui.getByTestId("races-cell-drive")).toHaveTextContent("0.250");
expect(ui.getByTestId("races-cell-weapons")).toHaveTextContent("0.500");
expect(ui.getByTestId("races-cell-shields")).toHaveTextContent("0.750");
expect(ui.getByTestId("races-cell-cargo")).toHaveTextContent("1.000");
expect(ui.getByTestId("races-cell-population")).toHaveTextContent("12345");
expect(ui.getByTestId("races-cell-industry")).toHaveTextContent("6789");
expect(ui.getByTestId("races-cell-planets")).toHaveTextContent("4");
expect(ui.getByTestId("races-cell-votes")).toHaveTextContent("3.5");
expect(ui.getByTestId("races-cell-votes")).toHaveTextContent("3.500");
});
test("filters rows by case-insensitive name match", async () => {