fix(ui): calculator polish — smart input steps, unified tech/MAT lock idiom, tech floor, speed-lock ceiling fix
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m41s
Tests · Go / test (pull_request) Successful in 3m14s
Tests · UI / test (pull_request) Successful in 2m32s

- pkg/calc: DriveForSpeed treats restMass==0 as a valid ceiling-only
  case (every positive drive solves it), so locking the displayed
  speed of a D=1, W=A=S=C=0 ship is no longer a phantom "infeasible".
- ship-design-area: drive/weapons/shields/cargo inputs use a JS-driven
  smart step on ArrowUp/ArrowDown (0↔1 jump, otherwise ±0.1) and hide
  the native spinner so it cannot produce invalid (0, 1) values;
  armament keeps its native step 1.
- Tech and planet MAT cells follow the same lock idiom as goal-seek
  locks: open padlock (🔓) over the inherited value → click to open
  an input with a closed padlock (🔒). The padlock slot is always
  reserved, so the column width is stable.
- Tech overrides (design area and modernization target) are floored
  at the player's current tech on this turn — a lower value is
  flagged as invalid.
This commit is contained in:
Ilia Denisov
2026-05-26 14:30:43 +02:00
parent 793b709d8f
commit e9b904332e
11 changed files with 384 additions and 55 deletions
@@ -14,7 +14,7 @@ switch (the inspector auto-opens on a planet click) — the calculator is a
long-lived planning tool. `ensureGame` resets it when the game changes.
-->
<script lang="ts">
import { getContext } from "svelte";
import { getContext, tick } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -273,6 +273,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
);
// Modernization target tech mirrors the design-area floor: a target
// below the player's current tech on this turn is meaningless (no
// upgrade), so flag it the same way.
function targetTechError(key: TechKey): string {
const value = cs.targetTech[key];
if (value < 0) return i18n.t("game.calculator.invalid.negative");
if (value < playerTech[key]) {
return i18n.t("game.calculator.invalid.tech_below_current");
}
return "";
}
// Locking a speed back-solves the drive block; with a zero drive the
// ship is deliberately immobile, so disallow it.
function lockDisabledReason(output: LockableOutputId): string {
@@ -291,8 +303,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
function onResetTech(key: TechKey): void {
cs.techOverridden[key] = false;
}
function onMatInput(): void {
const matInputRef: { el?: HTMLInputElement } = {};
async function activateMatOverride(): Promise<void> {
cs.matOverridden = true;
await tick();
matInputRef.el?.focus();
matInputRef.el?.select();
}
function resetMat(): void {
cs.matOverridden = false;
@@ -485,6 +501,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
resolved={resolvedCeil}
bind:techs={cs.techValues}
techOverridden={cs.techOverridden}
techFloor={playerTech}
computedInput={result.computedInput}
{onTechInput}
{onResetTech}
@@ -589,17 +606,17 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<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={cs.matValue}
oninput={onMatInput}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
{#if cs.matOverridden}
<input
bind:this={matInputRef.el}
type="number"
step="0.01"
min="0"
bind:value={cs.matValue}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
<button
type="button"
class="lock active"
@@ -610,6 +627,23 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
>
🔒
</button>
{:else}
<span
class="mat-val"
data-testid="calculator-planet-mat-value"
>
{cs.matValue}
</span>
<button
type="button"
class="lock"
title={i18n.t("game.calculator.mat.override")}
aria-label={i18n.t("game.calculator.mat.override")}
data-testid="calculator-mat-override"
onclick={() => void activateMatOverride()}
>
🔓
</button>
{/if}
</span>
<span></span>
@@ -638,18 +672,17 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
</div>
{#each modernCosts?.perBlock ?? [] as row (row.key)}
{@const targetError = targetTechError(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"
min={playerTech[row.key]}
bind:value={cs.targetTech[row.key]}
aria-invalid={cs.targetTech[row.key] < 0 ? "true" : "false"}
title={cs.targetTech[row.key] < 0
? i18n.t("game.calculator.invalid.negative")
: ""}
aria-invalid={targetError !== "" ? "true" : "false"}
title={targetError}
data-testid={`calculator-target-${row.key}`}
/>
</span>
@@ -899,4 +932,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
font-size: 0.8rem;
color: var(--color-accent);
}
/* Plain-text view of the planet MAT (mirrors `.tech-val` in the
design area) so the cell width stays the same whether the value is
the inherited planet number or the player's override. */
.mat-val {
flex: 1;
min-width: 0;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.15rem 0.3rem;
}
</style>