feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Tests · UI / test (push) Successful in 2m14s
Tests · Go / test (push) Successful in 2m25s

Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.

pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.

Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.

Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-21 19:52:08 +02:00
parent 00159ddf7c
commit 9ae7b88b89
53 changed files with 3748 additions and 1298 deletions
+823 -14
View File
@@ -1,29 +1,838 @@
<!--
Phase 10 stub for the Calculator sidebar tool. Phase 30 wires the
real ship/path calculator. Until then the stub renders a localised
`coming soon` paragraph with a stable testid that later phases can
replace without touching navigation.
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 holds input state and renders.
State is component-local: the sidebar keeps this tab mounted while the
player navigates between active views, so inputs persist across view
switches per the global state-preservation rule.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import { getContext } from "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";
const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];
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";
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,
);
type Mode = "ship" | "modernization";
let mode = $state<Mode>("ship");
let name = $state("");
let blocks = $state({ drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 });
let techValues = $state({ drive: 0, weapons: 0, shields: 0, cargo: 0 });
let techOverridden = $state<Record<TechKey, boolean>>({
drive: false,
weapons: false,
shields: false,
cargo: false,
});
let targetTech = $state({ drive: 0, weapons: 0, shields: 0, cargo: 0 });
let loadMode = $state<LoadMode>("full");
let customLoad = $state(0);
let lock = $state<LockableOutputId | null>(null);
let lockValue = $state(0);
let matOverridden = $state(false);
let matValue = $state(0);
let loadedExisting = $state<string | null>(null);
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 (!techOverridden[k]) 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.
let targetSeeded = false;
$effect(() => {
if (targetSeeded) return;
if (
playerTech.drive ||
playerTech.weapons ||
playerTech.shields ||
playerTech.cargo
) {
targetTech = { ...playerTech };
targetSeeded = true;
}
});
const result = $derived(
computeCalculator(
{
blocks,
driveTech: techValues.drive,
weaponsTech: techValues.weapons,
shieldsTech: techValues.shields,
cargoTech: techValues.cargo,
loadMode,
customLoad,
lock: lock === null ? null : { output: lock, value: 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 (!matOverridden) {
matValue = selectedPlanet?.materialsStockpile ?? 0;
}
});
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: 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 (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, ...result.blocks }, { existingNames }),
);
const createMessage = $derived(
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
);
const canCreate = $derived(nameValidation.ok && draft !== undefined);
const canDelete = $derived(
loadedExisting !== null &&
existingNames.includes(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: blocks.weapons,
armament: blocks.armament,
});
const rows: { key: TechKey; mass: number }[] = [
{ key: "drive", mass: blocks.drive },
{ key: "weapons", mass: weaponsMass ?? 0 },
{ key: "shields", mass: blocks.shields },
{ key: "cargo", mass: blocks.cargo },
];
const perBlock = rows.map((r) => ({
key: r.key,
cost: core.blockUpgradeCost({
blockMass: r.mass,
currentTech: techValues[r.key],
targetTech: targetTech[r.key],
}),
}));
const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
return { perBlock, total };
});
function fmt(value: number | null | undefined): string {
if (value === null || value === undefined) {
return i18n.t("game.calculator.unavailable");
}
return value.toLocaleString(undefined, { maximumFractionDigits: 3 });
}
function onTechInput(key: TechKey): void {
techOverridden[key] = true;
}
function onResetTech(key: TechKey): void {
techOverridden[key] = false;
}
function onMatInput(): void {
matOverridden = true;
}
function resetMat(): void {
matOverridden = false;
}
function lockOutput(output: LockableOutputId): void {
if (lock !== null) return;
lockValue = result.outputs?.[output] ?? 0;
lock = output;
}
function unlock(): void {
lock = null;
}
function loadExisting(clsName: string): void {
const cls = localShipClass.find((c) => c.name === clsName);
if (cls === undefined) return;
blocks = {
drive: cls.drive,
armament: cls.armament,
weapons: cls.weapons,
shields: cls.shields,
cargo: cls.cargo,
};
name = cls.name;
loadedExisting = cls.name;
lock = null;
}
function resetToNew(): void {
blocks = { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
name = "";
loadedExisting = null;
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.
let lastLoadToken = 0;
$effect(() => {
const token = calculatorLoadRequest.token;
if (token === lastLoadToken) return;
lastLoadToken = token;
mode = "ship";
if (calculatorLoadRequest.name === null) resetToNew();
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,
});
loadedExisting = created.name;
}
async function deleteClass(): Promise<void> {
if (loadedExisting === null || draft === undefined) return;
await draft.add({
kind: "removeShipClass",
id: crypto.randomUUID(),
name: loadedExisting,
});
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>
<section class="tool" data-testid="sidebar-tool-calculator">
<h3>{i18n.t("game.sidebar.tab.calculator")}</h3>
<p>{i18n.t("game.sidebar.empty.calculator")}</p>
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
{#if lock === output}
<span class="cell locked" class:infeasible={!result.lockFeasible}>
<input
type="number"
step="0.001"
bind:value={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}
<span class="cell">
<span class="val" data-testid={`calculator-out-${output}`}>{fmt(value)}</span>
<button
type="button"
class="lock"
disabled={lock !== null || value === undefined}
title={lock !== null
? i18n.t("game.calculator.lock.max")
: `${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={mode === "ship"}
data-testid="calculator-mode-ship"
onclick={() => (mode = "ship")}
>
{i18n.t("game.calculator.mode.ship")}
</button>
<button
type="button"
class:active={mode === "modernization"}
data-testid="calculator-mode-modernization"
onclick={() => (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={name}
oninput={() => (loadedExisting = null)}
onchange={() => loadExisting(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 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 mode === "ship" && canDelete}
<button
type="button"
class="delete"
data-testid="calculator-delete"
onclick={() => void deleteClass()}
>
{i18n.t("game.calculator.action.delete")} {loadedExisting}
</button>
{/if}
<ShipDesignArea
bind:blocks
resolved={result.blocks}
bind:techs={techValues}
{techOverridden}
computedInput={result.computedInput}
{onTechInput}
{onResetTech}
/>
{#if 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={loadMode === m}
data-testid={`calculator-load-${m}`}
onclick={() => (loadMode = m)}
>
{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
</button>
{/each}
</div>
{#if loadMode === "custom"}
<input
type="number"
step="0.01"
min="0"
class="custom-load"
bind:value={customLoad}
data-testid="calculator-custom-load"
/>
{/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={matValue}
oninput={onMatInput}
data-testid="calculator-planet-mat"
/>
{#if 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={targetTech[row.key]}
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></span>
<span class="cell">
<span class="val" data-testid="calculator-modern-total">
{fmt(modernCosts?.total)}
</span>
</span>
</div>
</div>
{/if}
</section>
<style>
.tool {
padding: 1rem;
.calculator {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.75rem;
font-family: system-ui, sans-serif;
}
.tool h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
.modes {
display: flex;
gap: 0.25rem;
}
.tool p {
.modes button {
flex: 1;
font: inherit;
font-size: 0.8rem;
padding: 0.25rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.modes button.active {
color: #e8eaf6;
border-color: #6d8cff;
background: #11162a;
}
.namebar {
display: flex;
gap: 0.35rem;
}
.name {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.4rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.name[aria-invalid="true"] {
border-color: #d97a7a;
}
.create,
.delete {
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.create:not(:disabled):hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete {
color: #d97a7a;
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: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.seg button.active {
color: #e8eaf6;
border-color: #6d8cff;
}
.custom-load {
width: 4rem;
font: inherit;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
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: #8890b0;
font-size: 0.7rem;
text-align: center;
}
.label {
color: #aab;
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: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.cell.locked input {
color: #9fb0ff;
border-color: #6d8cff;
}
.cell.infeasible input {
border-color: #d97a7a;
color: #f0a0a0;
}
.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 #20253a;
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hint {
margin: 0;
color: #888;
font-size: 0.8rem;
}
.planet-name {
margin: 0;
font-size: 0.8rem;
color: #cdd3f0;
}
.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: #aab;
font-size: 0.8rem;
}
.planet-stats dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
}
.rrow.total .label {
color: #cdd3f0;
}
</style>