fix(ui): F8-06 calculator polish — input steps, lock idiom, tech floor, speed-lock fix #61
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user