fix(ui): F8-06 calculator polish — input steps, lock idiom, tech floor, speed-lock fix #61

Merged
developer merged 4 commits from feature/issue-49-calculator-polish into development 2026-05-26 17:23:27 +00:00
5 changed files with 56 additions and 41 deletions
Showing only changes of commit b01a60e42b - Show all commits
+11 -8
View File
@@ -49,7 +49,10 @@ in as a per-ship result rather than a separate mode.
units) that the loaded-column results use. At **full** the toggle units) that the loaded-column results use. At **full** the toggle
shows the ship's cargo capacity; a **custom** load over that capacity shows the ship's cargo capacity; a **custom** load over that capacity
is flagged as an error. With a zero cargo block there is no hold, so is flagged as an error. With a zero cargo block there is no hold, so
the load is pinned to empty and the toggle is disabled. the load is pinned to empty and the toggle is disabled. The bombing
and cargo-capacity rows have no goal-seek lock, but they still
reserve a hidden lock-slot placeholder so the value column stays
vertically aligned with the lockable rows above.
3. **Planet area** — when an own planet is selected on the map, shows 3. **Planet area** — when an own planet is selected on the map, shows
its MAT (overridable) and the single-turn build rate (ships per turn, its MAT (overridable) and the single-turn build rate (ships per turn,
turns per ship). The MAT follows the same lock idiom as the tech turns per ship). The MAT follows the same lock idiom as the tech
@@ -125,17 +128,17 @@ fourth decimal as the user types: typing `1.2345` clamps the input to
`util.Fixed*`; `Ceil3` is a display-only helper that lives in `pkg/calc` `util.Fixed*`; `Ceil3` is a display-only helper that lives in `pkg/calc`
so the UI and Go share one implementation. so the UI and Go share one implementation.
## Create / load / delete ## Create / load
The name field is a combobox over the player's existing classes. Picking The name field is a combobox over the player's existing classes. Picking
an existing class loads it as a template (so you can tweak and Create a an existing class loads it as a template (so you can tweak and Create a
new one); Create is disabled while the name is invalid or duplicate new one); Create is disabled while the name is invalid or duplicate
(reusing `lib/util/ship-class-validation.ts`). When a saved class is (reusing `lib/util/ship-class-validation.ts`). Create reuses the existing
loaded, a Delete affordance appears. Create / Delete reuse the existing `createShipClass` order-draft flow, so the optimistic overlay reflects
`createShipClass` / `removeShipClass` order-draft flow, so the optimistic the change immediately. Ship classes are immutable after creation (per
overlay reflects the change immediately. Ship classes are immutable after `game/rules.txt`), so there is no edit — only Create-new. Delete-class
creation (per `game/rules.txt`), so there is no edit — only Create-new lives in the ship-classes table (`lib/active-view/table-ship-classes.svelte`),
and Delete. not the calculator.
Selecting a class from the dropdown loads it **immediately**, the Selecting a class from the dropdown loads it **immediately**, the
moment the option is clicked. (Native `change` only fires on blur in moment the option is clicked. (Native `change` only fires on blur in
-1
View File
@@ -364,7 +364,6 @@ const en = {
"game.calculator.name.placeholder": "new class name", "game.calculator.name.placeholder": "new class name",
"game.calculator.name.existing": "your ship classes", "game.calculator.name.existing": "your ship classes",
"game.calculator.action.create": "create", "game.calculator.action.create": "create",
"game.calculator.action.delete": "delete",
"game.calculator.col.ship": "ship", "game.calculator.col.ship": "ship",
"game.calculator.col.tech": "tech", "game.calculator.col.tech": "tech",
"game.calculator.field.drive": "drive", "game.calculator.field.drive": "drive",
-1
View File
@@ -365,7 +365,6 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.name.placeholder": "имя нового класса", "game.calculator.name.placeholder": "имя нового класса",
"game.calculator.name.existing": "ваши классы кораблей", "game.calculator.name.existing": "ваши классы кораблей",
"game.calculator.action.create": "создать", "game.calculator.action.create": "создать",
"game.calculator.action.delete": "удалить",
"game.calculator.col.ship": "корабль", "game.calculator.col.ship": "корабль",
"game.calculator.col.tech": "технологии", "game.calculator.col.tech": "технологии",
"game.calculator.field.drive": "двигатель", "game.calculator.field.drive": "двигатель",
@@ -204,11 +204,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]), nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
); );
const canCreate = $derived(nameValidation.ok && draft !== undefined); const canCreate = $derived(nameValidation.ok && draft !== undefined);
const canDelete = $derived(
cs.loadedExisting !== null &&
existingNames.includes(cs.loadedExisting) &&
draft !== undefined,
);
// Per-block modernization upgrade cost (current tech → target tech). // Per-block modernization upgrade cost (current tech → target tech).
const modernCosts = $derived.by(() => { const modernCosts = $derived.by(() => {
@@ -489,16 +484,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.loadedExisting = created.name; cs.loadedExisting = created.name;
} }
async function deleteClass(): Promise<void> {
if (cs.loadedExisting === null || draft === undefined) return;
await draft.add({
kind: "removeShipClass",
id: crypto.randomUUID(),
name: cs.loadedExisting,
});
cs.loadedExisting = null;
}
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({ const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
emptyMass: i18n.t("game.calculator.out.mass"), emptyMass: i18n.t("game.calculator.out.mass"),
loadedMass: i18n.t("game.calculator.out.mass"), loadedMass: i18n.t("game.calculator.out.mass"),
@@ -608,16 +593,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
</button> </button>
{/if} {/if}
</div> </div>
{#if cs.mode === "ship" && canDelete}
<button
type="button"
class="delete"
data-testid="calculator-delete"
onclick={() => void deleteClass()}
>
{i18n.t("game.calculator.action.delete")} {cs.loadedExisting}
</button>
{/if}
<ShipDesignArea <ShipDesignArea
bind:blocks={cs.blocks} bind:blocks={cs.blocks}
@@ -704,6 +679,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="val" data-testid="calculator-out-bombing"> <span class="val" data-testid="calculator-out-bombing">
{fmt(result.outputs?.bombing)} {fmt(result.outputs?.bombing)}
</span> </span>
<span class="lock-slot" aria-hidden="true">🔓</span>
</span> </span>
<span></span> <span></span>
</div> </div>
@@ -713,6 +689,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="val" data-testid="calculator-out-cargo-capacity"> <span class="val" data-testid="calculator-out-cargo-capacity">
{fmt(result.outputs === null ? null : result.cargoCapacity)} {fmt(result.outputs === null ? null : result.cargoCapacity)}
</span> </span>
<span class="lock-slot" aria-hidden="true">🔓</span>
</span> </span>
<span></span> <span></span>
</div> </div>
@@ -893,8 +870,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
.name[aria-invalid="true"] { .name[aria-invalid="true"] {
border-color: var(--color-danger); border-color: var(--color-danger);
} }
.create, .create {
.delete {
font: inherit; font: inherit;
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.25rem 0.55rem; padding: 0.25rem 0.55rem;
@@ -912,10 +888,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.delete {
color: var(--color-danger);
align-self: flex-start;
}
.load { .load {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1036,6 +1008,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cursor: not-allowed; cursor: not-allowed;
opacity: 0.2; opacity: 0.2;
} }
.lock-slot {
flex: none;
font-size: 0.7rem;
line-height: 1;
visibility: hidden;
}
.planet { .planet {
border-top: 1px solid var(--color-border-subtle); border-top: 1px solid var(--color-border-subtle);
padding-top: 0.5rem; padding-top: 0.5rem;
+36
View File
@@ -628,4 +628,40 @@ describe("calculator-tab", () => {
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3); expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
confirm.mockRestore(); confirm.mockRestore();
}); });
test("does not render a delete-class button after loading a class", async () => {
const ui = mount({
report: makeReport({
localShipClass: [
{
name: "Scout",
drive: 3,
armament: 0,
weapons: 0,
shields: 2,
cargo: 1,
},
],
} as unknown as GameReport),
});
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
// The loaded class state used to render a `delete <name>` button;
// the calculator no longer owns delete-class — issue #53 will.
expect(ui.queryByTestId("calculator-delete")).toBeNull();
});
test("bombing and cargo-capacity rows reserve the lock slot for column alignment", () => {
const ui = mount();
for (const id of ["calculator-out-bombing", "calculator-out-cargo-capacity"]) {
const cell = ui.getByTestId(id).parentElement;
expect(cell).not.toBeNull();
// A hidden placeholder occupies the same width as the lock button
// on the mass/speed/attack/defence rows, so the value column does
// not drift right on the rows without a lock.
expect(cell!.querySelector(".lock-slot")).not.toBeNull();
}
});
}); });