# Ship Class Calculator — UX

The ship-class designer and calculator are fused into one sidebar
tool (`lib/sidebar/calculator-tab.svelte`). The standalone designer
view/route was replaced by this combined tool. All numeric math lives in
`pkg/calc` and is reached through the `Core` WASM bridge; the calculator
holds input state and orchestrates, it never computes.

## Modes

- **Calculator** (`ship`): the full tool — design area, derived results,
  planet build, goal-seek.
- **Modernization**: reuses the design area and shows per-block and
  total `BlockUpgradeCost` from the current tech to an editable target
  tech. The design-area component is extracted
  (`lib/calculator/ship-design-area.svelte`) so the future ship-group
  upgrade flow can reuse it.

The `path` mode from the original plan was dropped (MVP path-finding is
brute force); reach circles on the map replace it. `bombing` is folded
in as a per-ship result rather than a separate mode.

## Areas

1. **Ship Class design area** — five blocks (drive, armament, weapons,
   shields, cargo) and four tech levels (drive, weapons, shields,
   cargo). Tech defaults to the player's current tech: the cell renders
   the inherited number with an open padlock; clicking the open lock
   activates an input (closed padlock), where the player may type an
   override at or above their current tech. Clicking the closed
   padlock resets to the default. The padlock slot is always reserved,
   so the column width does not shift as the lock state toggles. The
   inherited tech value reads through the same 3-decimal `Ceil3`
   formatter the report uses, so the column lines up with derived
   values. **Every numeric input in the calculator hides the native
   spinner and drives stepping through ArrowUp / ArrowDown.** This keeps
   the column widths stable, makes the inputs read consistently, and
   gives each row a step that matches its purpose. The four ship-class
   blocks (drive, weapons, shields, cargo) use a smart step that
   respects the engine value rule (`0` or `≥ 1`): ArrowUp from 0 jumps
   straight to 1, otherwise +0.1; ArrowDown from 1 collapses to 0,
   otherwise −0.1, never producing an invalid value in `(0, 1)`.
   Armament steps ±1 (clamped at 0). Tech, planet MAT, custom load,
   lock value, and modernization target tech each step by their natural
   grain (±0.001 for tech and lock values, ±0.01 for MAT and load).
2. **Calculator area** — derived results: empty/loaded mass, empty/
   loaded speed, attack, defence, bombing (per ship), cargo capacity.
   A load toggle (empty / full / custom) sets the cargo load (in cargo
   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 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
   cells: the planet number renders with an open padlock, clicking
   opens an input with a closed padlock, and the closed padlock resets
   to the planet value. The MAT label reads through the same 3-decimal
   `Ceil3` formatter, matching the rest of the calculator's label
   values. The realistic multi-turn forecast with CAP/COL supply is
   planned (see ../ROADMAP.md).

## Locks and goal-seek

Two distinct lock semantics share one padlock affordance. Both follow
the same idiom — an open padlock (🔓) means *value is inherited /
derived, click to override*; a closed padlock (🔒) means *value is
pinned by the player, click to reset*:

- **Override locks** on inputs that have a default — the four techs and
  the planet MAT. By default the cell shows the inherited number plus
  an open padlock; clicking it switches to an input plus a closed
  padlock for typing the override. Closing (clicking the closed
  padlock) resets to the default. Any number may be overridden at once.
  Tech overrides are floored at the player's current tech on this
  turn — a lower value is flagged as invalid. The same floor applies
  to the modernization target tech.
- **Goal-seek locks** on derived results. Pinning a result back-solves
  the single input it claims, which then renders read-only (computed):

  | result        | claims        |
  | ------------- | ------------- |
  | attack        | weapons block |
  | defence       | shields block |
  | empty speed   | drive block   |
  | loaded speed  | drive block   |
  | empty mass    | cargo block   |
  | loaded mass   | cargo load    |

  Only **one** result may be locked at a time (the others' lock
  affordances disable with a tooltip). An unreachable target — e.g. a
  speed above the stripped-hull ceiling `20 × driveTech`, or a solved
  block that fails the value rules (a DWSC value in the `(0, 1)` gap)
  — leaves the locked cell in a red error state and does not apply.
  When that happens the claimed block is **not** back-solved into the
  invalid range; the design preview keeps reading the user's typed
  values, so the row never silently shows a sub-1 block. Inverse
  solving lives in `pkg/calc/solve.go`; the bisection for defence →
  shields is the only non-analytic case. Locking a speed is disabled
  when the drive block is zero (a deliberately immobile ship has no
  speed to back-solve). With the drive block as the only non-zero mass
  the displayed speed equals the ceiling exactly (every positive drive
  gives the same speed), so the solver accepts that ceiling target as
  a feasible lock and any positive drive solves it.

## Validation and display

Every numeric input is validated independently and an offending one gets
a red border and a hover/tap tooltip with the reason: no value may be
negative, the five blocks follow the engine value rules
(`pkg/calc/validator.go`, surfaced per-field by
`shipClassFieldErrors`), and a custom load may not exceed cargo capacity.

Every displayed number — the derived results, the inherited tech /
planet MAT labels, and the goal-seek back-solved input — is rounded
**up** to three decimals through the shared `pkg/calc/number.go.Ceil3`
(bridged as `core.ceil3`) and always padded to three decimals so the
column reads the same on integers and fractions alike (a speed of 20
shows as `20.000`, of 5.0003 as `5.001`). Labels and inputs use the
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

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`). 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
Firefox; switching the load trigger to `input` makes the load
synchronous everywhere, since the `InputEvent.inputType` flags a
datalist replacement as `"insertReplacementText"` in Chromium / WebKit
or `undefined` in Firefox — keyboard typing always carries a typing
`inputType`.) If the live blocks differ from the previously loaded
class (or, when nothing is loaded, from the empty defaults), the
calculator first asks `Discard unsaved changes and load class «…»?`
through a `window.confirm`; declining reverts the name field and
leaves the current blocks untouched.

## Reach circles

When an own planet is selected in calculator mode, the calculator
publishes the planet origin and the design's loaded speed to a shared
store (`lib/calculator/reach.svelte`). The map view
(`lib/active-view/map.svelte`) reads it and draws 1–3 thin concentric
reach circles (`map/reach-circles.ts`) for 1/2/3 turns. The ring count
shrinks as speed grows: a ring is dropped once the previous one reaches
the torus wrap-midpoint (half the shorter side) or the no-wrap map edge
(farthest corner). The circles clear when the selection clears or the
design is invalid.

## State preservation and history

Calculator inputs are component-local state. The sidebar keeps the tab
mounted while the player navigates between active views, so inputs
persist across view switches per the global state-preservation rule
(`ui/docs/navigation.md`). Tech levels track the rendered report, so in
history mode the calculator computes against the viewed snapshot's tech.

The ship-classes table and the view/bottom menus open the calculator via
a shared request store (`lib/calculator/load-request.svelte`): the
in-game layout flips the sidebar to the calculator tab and the
calculator loads the requested class (or starts a fresh design).

## Layout and mobile

Everything stacks vertically to fit the 18 rem sidebar; the design and
result rows use compact two-column (ship/tech, empty/loaded) grids. On
mobile the sidebar is the existing bottom-sheet/overlay; the calc bottom
tab opens it.

## Runtime note

The new bridge functions are only present after `make wasm` rebuilds
`ui/frontend/static/core.wasm` (needs TinyGo). Vitest injects a fake
`Core` (`tests/fake-core.ts`) mirroring `pkg/calc`, so unit/component
tests do not need the rebuild; the Playwright suite and the live app do.
