fix(ui): F8-06 calculator polish — drop delete-class button, reserve lock slot
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · UI / test (pull_request) Successful in 2m34s

- Remove the `delete <ship_class_name>` button (and `deleteClass`,
  `canDelete`, `.delete` CSS, `game.calculator.action.delete` i18n key)
  from the calculator. Delete-class lives in the ship-classes table —
  the broader rework will land under #53.
- Bombing and cargo-capacity rows now reserve a hidden lock-slot
  placeholder so their value column lines up vertically with the
  mass/speed/attack/defence rows (which carry a lock button).
This commit is contained in:
Ilia Denisov
2026-05-26 19:10:59 +02:00
parent cc4727a32e
commit b01a60e42b
5 changed files with 56 additions and 41 deletions
+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
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
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
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
@@ -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`
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
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
(reusing `lib/util/ship-class-validation.ts`). When a saved class is
loaded, a Delete affordance appears. Create / Delete reuse the existing
`createShipClass` / `removeShipClass` order-draft flow, so the optimistic
overlay reflects the change immediately. Ship classes are immutable after
creation (per `game/rules.txt`), so there is no edit — only Create-new
and Delete.
(reusing `lib/util/ship-class-validation.ts`). Create reuses the existing
`createShipClass` order-draft flow, so the optimistic overlay reflects
the change immediately. Ship classes are immutable after creation (per
`game/rules.txt`), so there is no edit — only Create-new. Delete-class
lives in the ship-classes table (`lib/active-view/table-ship-classes.svelte`),
not the calculator.
Selecting a class from the dropdown loads it **immediately**, the
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.existing": "your ship classes",
"game.calculator.action.create": "create",
"game.calculator.action.delete": "delete",
"game.calculator.col.ship": "ship",
"game.calculator.col.tech": "tech",
"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.existing": "ваши классы кораблей",
"game.calculator.action.create": "создать",
"game.calculator.action.delete": "удалить",
"game.calculator.col.ship": "корабль",
"game.calculator.col.tech": "технологии",
"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]),
);
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).
const modernCosts = $derived.by(() => {
@@ -489,16 +484,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
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({
emptyMass: 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>
{/if}
</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
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">
{fmt(result.outputs?.bombing)}
</span>
<span class="lock-slot" aria-hidden="true">🔓</span>
</span>
<span></span>
</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">
{fmt(result.outputs === null ? null : result.cargoCapacity)}
</span>
<span class="lock-slot" aria-hidden="true">🔓</span>
</span>
<span></span>
</div>
@@ -893,8 +870,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
.name[aria-invalid="true"] {
border-color: var(--color-danger);
}
.create,
.delete {
.create {
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
@@ -912,10 +888,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
opacity: 0.5;
cursor: not-allowed;
}
.delete {
color: var(--color-danger);
align-self: flex-start;
}
.load {
display: flex;
align-items: center;
@@ -1036,6 +1008,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cursor: not-allowed;
opacity: 0.2;
}
.lock-slot {
flex: none;
font-size: 0.7rem;
line-height: 1;
visibility: hidden;
}
.planet {
border-top: 1px solid var(--color-border-subtle);
padding-top: 0.5rem;
+36
View File
@@ -628,4 +628,40 @@ describe("calculator-tab", () => {
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
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();
}
});
});