fix(ui): F8-06 calculator polish — always 3-decimal display, mono font, input cap
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:
@@ -111,13 +111,19 @@ negative, the five blocks follow the engine value rules
|
|||||||
(`pkg/calc/validator.go`, surfaced per-field by
|
(`pkg/calc/validator.go`, surfaced per-field by
|
||||||
`shipClassFieldErrors`), and a custom load may not exceed cargo capacity.
|
`shipClassFieldErrors`), and a custom load may not exceed cargo capacity.
|
||||||
|
|
||||||
Every displayed number — the derived results and the goal-seek
|
Every displayed number — the derived results, the inherited tech /
|
||||||
back-solved input — is rounded **up** to three decimals through the
|
planet MAT labels, and the goal-seek back-solved input — is rounded
|
||||||
shared `pkg/calc/number.go.Ceil3` (bridged as `core.ceil3`), so a value
|
**up** to three decimals through the shared `pkg/calc/number.go.Ceil3`
|
||||||
is never shown lower than it is (a speed of 5.0003 reads 5.001). The
|
(bridged as `core.ceil3`) and always padded to three decimals so the
|
||||||
engine keeps its own round-to-nearest `util.Fixed*`; `Ceil3` is a
|
column reads the same on integers and fractions alike (a speed of 20
|
||||||
display-only helper that lives in `pkg/calc` so the UI and Go share one
|
shows as `20.000`, of 5.0003 as `5.001`). Labels and inputs use the
|
||||||
implementation.
|
monospace stack from the design tokens (`--font-mono`) with
|
||||||
|
right-aligned, tabular numerals so values line up vertically across
|
||||||
|
rows. To match the display rule, every number input also refuses a
|
||||||
|
fourth decimal as the user types: typing `1.2345` clamps the input to
|
||||||
|
`1.234` on input. The engine keeps its own round-to-nearest
|
||||||
|
`util.Fixed*`; `Ceil3` is a display-only helper that lives in `pkg/calc`
|
||||||
|
so the UI and Go share one implementation.
|
||||||
|
|
||||||
## Create / load / delete
|
## Create / load / delete
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
const floor = techFloor[key];
|
const floor = techFloor[key];
|
||||||
techs[key] = next < floor ? floor : next;
|
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: {
|
const BLOCK_ROWS: {
|
||||||
key: keyof DesignBlocksState;
|
key: keyof DesignBlocksState;
|
||||||
@@ -196,6 +210,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
title={blockError(row.key)}
|
title={blockError(row.key)}
|
||||||
data-testid={`calculator-block-${row.key}`}
|
data-testid={`calculator-block-${row.key}`}
|
||||||
onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)}
|
onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)}
|
||||||
|
oninput={(e) => capDecimals(e, (v) => (blocks[row.key] = v))}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if row.tech !== null}
|
{#if row.tech !== null}
|
||||||
@@ -213,6 +228,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
title={techError(techKey)}
|
title={techError(techKey)}
|
||||||
data-testid={`calculator-tech-${techKey}`}
|
data-testid={`calculator-tech-${techKey}`}
|
||||||
onkeydown={(e) => bumpTech(e, techKey)}
|
onkeydown={(e) => bumpTech(e, techKey)}
|
||||||
|
oninput={(e) => capDecimals(e, (v) => (techs[techKey] = v))}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -274,7 +290,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
input {
|
input {
|
||||||
font: inherit;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
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: 1px solid var(--color-border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
/* Hide native spinners across the design area — the row drives
|
/* Hide native spinners across the design area — the row drives
|
||||||
every numeric edit through ArrowUp/ArrowDown so the column
|
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 {
|
.tech-val {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
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
|
// Display every computed number rounded up to three decimals via the
|
||||||
// shared `Ceil3` bridge, so a value is never shown lower than it is.
|
// 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 {
|
function fmt(value: number | null | undefined): string {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return i18n.t("game.calculator.unavailable");
|
return i18n.t("game.calculator.unavailable");
|
||||||
}
|
}
|
||||||
const rounded = core !== null ? core.ceil3({ value }) : value;
|
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
|
// 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}
|
bind:value={cs.lockValue}
|
||||||
onkeydown={(e) =>
|
onkeydown={(e) =>
|
||||||
onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
|
onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
|
||||||
|
oninput={(e) => capDecimals(e, (v) => (cs.lockValue = v))}
|
||||||
data-testid={`calculator-locked-${output}`}
|
data-testid={`calculator-locked-${output}`}
|
||||||
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
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}
|
bind:value={cs.customLoad}
|
||||||
onkeydown={(e) =>
|
onkeydown={(e) =>
|
||||||
onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
|
onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
|
||||||
|
oninput={(e) => capDecimals(e, (v) => (cs.customLoad = v))}
|
||||||
aria-invalid={customLoadError !== "" ? "true" : "false"}
|
aria-invalid={customLoadError !== "" ? "true" : "false"}
|
||||||
title={customLoadError}
|
title={customLoadError}
|
||||||
data-testid="calculator-custom-load"
|
data-testid="calculator-custom-load"
|
||||||
@@ -718,6 +743,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
bind:value={cs.matValue}
|
bind:value={cs.matValue}
|
||||||
onkeydown={(e) =>
|
onkeydown={(e) =>
|
||||||
onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
|
onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
|
||||||
|
oninput={(e) => capDecimals(e, (v) => (cs.matValue = v))}
|
||||||
aria-invalid={matError !== "" ? "true" : "false"}
|
aria-invalid={matError !== "" ? "true" : "false"}
|
||||||
title={matError}
|
title={matError}
|
||||||
data-testid="calculator-planet-mat"
|
data-testid="calculator-planet-mat"
|
||||||
@@ -795,6 +821,8 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
playerTech[row.key],
|
playerTech[row.key],
|
||||||
(v) => (cs.targetTech[row.key] = v),
|
(v) => (cs.targetTech[row.key] = v),
|
||||||
)}
|
)}
|
||||||
|
oninput={(e) =>
|
||||||
|
capDecimals(e, (v) => (cs.targetTech[row.key] = v))}
|
||||||
aria-invalid={targetError !== "" ? "true" : "false"}
|
aria-invalid={targetError !== "" ? "true" : "false"}
|
||||||
title={targetError}
|
title={targetError}
|
||||||
data-testid={`calculator-target-${row.key}`}
|
data-testid={`calculator-target-${row.key}`}
|
||||||
@@ -913,13 +941,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
}
|
}
|
||||||
.custom-load {
|
.custom-load {
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
font: inherit;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
padding: 0.15rem 0.3rem;
|
padding: 0.15rem 0.3rem;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.results,
|
.results,
|
||||||
.modern {
|
.modern {
|
||||||
@@ -949,6 +979,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.cell .val {
|
.cell .val {
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -956,7 +987,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
.cell input {
|
.cell input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font: inherit;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.15rem 0.3rem;
|
padding: 0.15rem 0.3rem;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
@@ -1038,6 +1069,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
}
|
}
|
||||||
.planet-stats dd {
|
.planet-stats dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -1055,6 +1087,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.full-capacity {
|
.full-capacity {
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
@@ -1065,6 +1098,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
.mat-val {
|
.mat-val {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|||||||
@@ -401,15 +401,12 @@ describe("calculator-tab", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("renders unoverridden tech as a 3-decimal label (matches the report)", () => {
|
test("renders unoverridden tech as a 3-decimal label (matches the report)", () => {
|
||||||
// Player drive tech 1.2 → "1.200" via the shared ceil3 formatter.
|
// Player drive tech 1.2 → "1.200" via the shared ceil3 formatter,
|
||||||
|
// always padded to three decimals (calculator labels are column-
|
||||||
|
// aligned with the report).
|
||||||
const ui = mount();
|
const ui = mount();
|
||||||
expect(ui.getByTestId("calculator-tech-value-drive")).toHaveTextContent(
|
|
||||||
"1.2",
|
|
||||||
);
|
|
||||||
// Stable column-aligned formatting (3 decimals) is what the report
|
|
||||||
// uses, so the tech labels read consistently.
|
|
||||||
const tech = ui.getByTestId("calculator-tech-value-drive");
|
const tech = ui.getByTestId("calculator-tech-value-drive");
|
||||||
expect(tech.textContent ?? "").toMatch(/^1\.20?0?$/);
|
expect((tech.textContent ?? "").trim()).toBe("1.200");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("planet MAT label renders through the 3-decimal formatter", () => {
|
test("planet MAT label renders through the 3-decimal formatter", () => {
|
||||||
@@ -419,12 +416,84 @@ describe("calculator-tab", () => {
|
|||||||
report: makeReport({ planets: [LOCAL_PLANET] }),
|
report: makeReport({ planets: [LOCAL_PLANET] }),
|
||||||
selection,
|
selection,
|
||||||
});
|
});
|
||||||
// Planet MAT is 100 → "100" through the shared formatter; the
|
// Planet MAT is 100 → "100.000" through the shared formatter; the
|
||||||
// label is monospaced + right-aligned via the existing `.mat-val`
|
// label is monospaced + right-aligned via the existing `.mat-val`
|
||||||
// rule. Formatting check: no stray fractional digits on integers.
|
// rule. Integer MAT pads to three decimals like every other label.
|
||||||
expect(
|
const mat = ui.getByTestId("calculator-planet-mat-value");
|
||||||
ui.getByTestId("calculator-planet-mat-value"),
|
expect((mat.textContent ?? "").trim()).toBe("100.000");
|
||||||
).toHaveTextContent("100");
|
});
|
||||||
|
|
||||||
|
test("derived results pad to three decimals (integer empty mass)", async () => {
|
||||||
|
// Integer-valued outputs read with the same trailing zeros as
|
||||||
|
// fractional ones — column-aligned tabular display.
|
||||||
|
const ui = mount();
|
||||||
|
await setBlock(ui, "drive", 10);
|
||||||
|
await setBlock(ui, "shields", 5);
|
||||||
|
await setBlock(ui, "cargo", 5);
|
||||||
|
const mass = ui.getByTestId("calculator-out-emptyMass");
|
||||||
|
expect((mass.textContent ?? "").trim()).toBe("20.000");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("number inputs refuse a fourth decimal as the user types", async () => {
|
||||||
|
const selection = new SelectionStore();
|
||||||
|
selection.selectPlanet(17);
|
||||||
|
const ui = mount({
|
||||||
|
report: makeReport({ planets: [LOCAL_PLANET] }),
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
// MAT input: typed "12.3456" must clamp to "12.345" on input.
|
||||||
|
await fireEvent.click(ui.getByTestId("calculator-mat-override"));
|
||||||
|
const mat = ui.getByTestId("calculator-planet-mat") as HTMLInputElement;
|
||||||
|
await fireEvent.input(mat, { target: { value: "12.3456" } });
|
||||||
|
expect(mat.value).toBe("12.345");
|
||||||
|
expect(mat.valueAsNumber).toBeCloseTo(12.345, 9);
|
||||||
|
|
||||||
|
// Custom-load input on a ship with a non-zero cargo: typed
|
||||||
|
// "1.2345" must clamp to "1.234".
|
||||||
|
await setBlock(ui, "drive", 10);
|
||||||
|
await setBlock(ui, "shields", 5);
|
||||||
|
await setBlock(ui, "cargo", 5);
|
||||||
|
await fireEvent.click(ui.getByTestId("calculator-load-custom"));
|
||||||
|
const load = ui.getByTestId("calculator-custom-load") as HTMLInputElement;
|
||||||
|
await fireEvent.input(load, { target: { value: "1.2345" } });
|
||||||
|
expect(load.value).toBe("1.234");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tech and target-tech inputs cap at three decimals", async () => {
|
||||||
|
const ui = mount();
|
||||||
|
// Tech override input.
|
||||||
|
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
|
||||||
|
const tech = ui.getByTestId("calculator-tech-drive") as HTMLInputElement;
|
||||||
|
await fireEvent.input(tech, { target: { value: "2.9999" } });
|
||||||
|
expect(tech.value).toBe("2.999");
|
||||||
|
|
||||||
|
// Modernization target tech input.
|
||||||
|
await fireEvent.click(ui.getByTestId("calculator-mode-modernization"));
|
||||||
|
const target = ui.getByTestId(
|
||||||
|
"calculator-target-drive",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await fireEvent.input(target, { target: { value: "3.1416" } });
|
||||||
|
expect(target.value).toBe("3.141");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lock value input caps at three decimals", async () => {
|
||||||
|
const ui = mount();
|
||||||
|
await setBlock(ui, "drive", 10);
|
||||||
|
await setBlock(ui, "shields", 5);
|
||||||
|
await setBlock(ui, "cargo", 5);
|
||||||
|
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
|
||||||
|
const lock = ui.getByTestId(
|
||||||
|
"calculator-locked-attack",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await fireEvent.input(lock, { target: { value: "0.1234" } });
|
||||||
|
expect(lock.value).toBe("0.123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ship-block input caps at three decimals", async () => {
|
||||||
|
const ui = mount();
|
||||||
|
const drive = ui.getByTestId("calculator-block-drive") as HTMLInputElement;
|
||||||
|
await fireEvent.input(drive, { target: { value: "1.2345" } });
|
||||||
|
expect(drive.value).toBe("1.234");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lock spinner step is replaced by ArrowUp/ArrowDown (±0.001)", async () => {
|
test("lock spinner step is replaced by ArrowUp/ArrowDown (±0.001)", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user