fix(ui): F8-06 calculator polish — always 3-decimal display, mono font, input cap
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m3s
Tests · UI / test (pull_request) Successful in 2m28s

Owner feedback round 2 on PR #61:

- Pad every read-only calculator value to three decimals: tech labels,
  derived results (mass, speed, attack, defence, bombing, cargo
  capacity), planet MAT, planet build-rate, modernization cost, and
  the full-cargo capacity label all read as "1.000" instead of "1",
  matching the goal-seek back-solved input and the report. Drops
  thousands grouping so the same `fmt()` string also embeds cleanly
  in the read-only `<input type="number">` cell.
- Switch label and input styling onto the existing `--font-mono`
  token (right-aligned, tabular-nums) so columns line up vertically
  across rows like a financial table.
- Refuse a fourth decimal as the user types in every calculator
  number input (DWSC blocks, tech, MAT, custom load, lock value,
  modernization target tech): the `oninput` truncates the input text
  past three decimal digits and explicitly writes the truncated
  value back through `bind:value`, so Svelte's later reactive flush
  cannot undo the cap.
- Doc + tests follow the rule (five new vitest cases covering the
  3-decimal label format, the input cap on each input class, and
  the integer-padding rule for derived results).
This commit is contained in:
Ilia Denisov
2026-05-26 18:43:32 +02:00
parent cbf7f65916
commit cc4727a32e
4 changed files with 150 additions and 23 deletions
@@ -139,6 +139,20 @@ calculator math — so the ship-group upgrade flow can reuse it later.
const floor = techFloor[key];
techs[key] = next < floor ? floor : next;
}
// Refuse a fourth decimal as typing happens: keeps the calculator
// from ever displaying a >3-decimal fraction the user could not
// have intended (the calculator math is `Ceil3`-rounded for display
// anyway). Pairs with `bind:value` — `apply` overwrites the bound
// state when Svelte's own bind handler has already read the
// over-precise number.
function capDecimals(event: Event, apply: (next: number) => void): void {
const el = event.currentTarget as HTMLInputElement;
const txt = el.value;
const dot = txt.indexOf(".");
if (dot < 0 || txt.length - dot - 1 <= 3) return;
el.value = txt.slice(0, dot + 4);
apply(el.valueAsNumber);
}
const BLOCK_ROWS: {
key: keyof DesignBlocksState;
@@ -196,6 +210,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
title={blockError(row.key)}
data-testid={`calculator-block-${row.key}`}
onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)}
oninput={(e) => capDecimals(e, (v) => (blocks[row.key] = v))}
/>
{/if}
{#if row.tech !== null}
@@ -213,6 +228,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
title={techError(techKey)}
data-testid={`calculator-tech-${techKey}`}
onkeydown={(e) => bumpTech(e, techKey)}
oninput={(e) => capDecimals(e, (v) => (techs[techKey] = v))}
/>
<button
type="button"
@@ -274,7 +290,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
font-size: 0.8rem;
}
input {
font: inherit;
font-family: var(--font-mono);
font-size: 0.8rem;
width: 100%;
min-width: 0;
@@ -284,6 +300,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
border: 1px solid var(--color-border);
border-radius: 3px;
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Hide native spinners across the design area — the row drives
every numeric edit through ArrowUp/ArrowDown so the column
@@ -313,6 +330,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
.tech-val {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: right;
@@ -237,12 +237,35 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
// Display every computed number rounded up to three decimals via the
// shared `Ceil3` bridge, so a value is never shown lower than it is.
// Always three decimals (`1` → `1.000`) for column-aligned readability,
// and without thousands grouping so the same string also embeds in the
// read-only goal-seek `<input type="number">` cell.
function fmt(value: number | null | undefined): string {
if (value === null || value === undefined) {
return i18n.t("game.calculator.unavailable");
}
const rounded = core !== null ? core.ceil3({ value }) : value;
return rounded.toLocaleString(undefined, { maximumFractionDigits: 3 });
return rounded.toLocaleString(undefined, {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
useGrouping: false,
});
}
// Cap typed precision at three decimal digits. Number inputs use
// `step="any"`, which lets the browser accept arbitrary precision; the
// owner asked us to refuse a fourth decimal as typing happens so the
// calculator never displays a longer-than-three-digit fraction. Pairs
// with `bind:value`: if Svelte's bind handler has already read the
// over-precise number, `apply` overwrites the state with the truncated
// value so the next reactive flush does not undo our truncation.
function capDecimals(event: Event, apply: (next: number) => void): void {
const el = event.currentTarget as HTMLInputElement;
const txt = el.value;
const dot = txt.indexOf(".");
if (dot < 0 || txt.length - dot - 1 <= 3) return;
el.value = txt.slice(0, dot + 4);
apply(el.valueAsNumber);
}
// The goal-seek back-solved block, shown in its read-only cell, is
@@ -496,6 +519,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
bind:value={cs.lockValue}
onkeydown={(e) =>
onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
oninput={(e) => capDecimals(e, (v) => (cs.lockValue = v))}
data-testid={`calculator-locked-${output}`}
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
/>
@@ -632,6 +656,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
bind:value={cs.customLoad}
onkeydown={(e) =>
onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
oninput={(e) => capDecimals(e, (v) => (cs.customLoad = v))}
aria-invalid={customLoadError !== "" ? "true" : "false"}
title={customLoadError}
data-testid="calculator-custom-load"
@@ -718,6 +743,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
bind:value={cs.matValue}
onkeydown={(e) =>
onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
oninput={(e) => capDecimals(e, (v) => (cs.matValue = v))}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
@@ -795,6 +821,8 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
playerTech[row.key],
(v) => (cs.targetTech[row.key] = v),
)}
oninput={(e) =>
capDecimals(e, (v) => (cs.targetTech[row.key] = v))}
aria-invalid={targetError !== "" ? "true" : "false"}
title={targetError}
data-testid={`calculator-target-${row.key}`}
@@ -913,13 +941,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
}
.custom-load {
width: 4rem;
font: inherit;
font-family: var(--font-mono);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
text-align: right;
}
.results,
.modern {
@@ -949,6 +979,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
justify-content: flex-end;
}
.cell .val {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
@@ -956,7 +987,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
.cell input {
width: 100%;
min-width: 0;
font: inherit;
font-family: var(--font-mono);
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
@@ -1038,6 +1069,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
}
.planet-stats dd {
margin: 0;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
@@ -1055,6 +1087,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cursor: not-allowed;
}
.full-capacity {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
color: var(--color-accent);
@@ -1065,6 +1098,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
.mat-val {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
text-align: right;