# UI polish — branch `ui-polish` (2026-09-13)

The UI/kernel split is merged (PR #53, squashed) — see `docs/ui-kernel-split-completion-playbook.md`.
This branch works through the items in `todo.md` under "After shell-on-kernel — UI nits".

## Steps

The steps run in waves. Within a wave, steps run in parallel worktrees and are rebased onto the branch one after another. Every commit passes `cargo xtask ci`.

- **P1 Rename `Paint` to `DrawOp`, and the list to `DrawList`** (wave 1, alone). The rename is mechanical but touches every crate, so it lands before anything else.
- **P2 Stamps** (wave 2).
  - A click with the text tool stamps a box, the same way a drop from the cluster does, and opens its editor. The box grows with its text, capped at 132 characters wide and 512 lines high, with the rest elided.
  - The add-port tool stamps too. A click or a drop places a port at twice today's default size.
- **P5 The overlay and the file doors** (wave 2).
  - The selection overlay shows up to five controls, and the rest go behind `…`.
  - "Open shared diagram" (the `.bwx.zip` door) is removed, along with the JSON export and import commands.
- **P4 Pins and routing** (wave 3).
  - The new-pin tool returns to the toolbar. While it is armed, pin-add points show on every unlocked block in scope. It wears the `|-o` icon, and the new-port tool gets a pentagon.
  - The tool that routes and creates a port in one motion is retired, with its drag-to-start animation. A pin made from the "(+)" opens its rename editor and nothing else.
  - A route dragged onto a block's edge still mints the pin it lands on, and a test guards that.
- **P3 Handles** (wave 3).
  - Resize handles and their hit regions grow together on every resizable shape.
  - A selected text box offers the same corner handles a block has.

Caret blink is already done: E3 made the blink the editor's.

## Decisions

Decisions are recorded here as the steps land.

- **P1 `DrawList` is an alias, not a newtype.** `pub type DrawList = Vec<DrawOp>`. A newtype would re-export `Vec`'s surface (iteration, `retain`, `len`, serde) for no invariant: any sequence of ops is a valid list. The list is `draw_list` wherever it is a field or a parameter (`View`, `Recorded`, `Diagram`, `replay`). The crate and its `Renderer`/`Canvas` traits keep their names; "paint" as a verb stays in prose.
- **P2 A click is a drop.** The armed text and port tools answer a click with `Action::StampTool { tool, at }` (`stamp::on_click`), which runs through `apply_transition` and `apply_scripted` into `stamp::stamp`, the resolver a cluster drop reaches. Neither tool keeps drag state. The locked-level refusal for a port lives in `stamp` too, so a click and a drop refuse alike.
- **P2 Only a port's width doubles.** `STAMPED_PORT_WIDTH` goes from 4 cells to 8. The height is `PORT_HEIGHT`, which `create::port` forces on every port.
- **P2 Text boxes keep wrapping.** A box wraps at the smaller of its own width and the column cap: 132 columns (`TEXT_BOX_MAX_COLUMNS`), each as wide as a `0` in the title font measured through the renderer's text layout. Rows past `TEXT_BOX_MAX_ROWS` (512) are elided, with `…` on the last row shown. `ShownText::lay_out` builds the shown text from `Layout.rows` through the pure `elided`, and both `measure_box_size` and `draw_text` read it, so the extent and the drawing cannot disagree. The document keeps the full text. Wrapping stays because P3 gives a text box a width the user resizes. `Renderer::text_layout` is new, so the render path can read rows.
- **P2 The box's width is an input, `BoxWidth`.** Today it has one variant, `FitsText`, and `BoxWidth::of(&Text)` is the one place a box's width is decided. The box measurement, the drawing and the editor all take the width from there. P3 adds a variant carrying the user's width, sets it in `of`, and gives it a wrap width in `BoxWidth::wrap_width` and an extent in `measure_box_size`.
- **P2 The box fits its text on commit.** The editor field keeps its opening size while typing, sized once by `EditText::fitted` and wrapping at the box's wrap width. Growing the field while typing would need the front end to re-measure every frame.
- **P5 `Placement` became `Precedence`, and the cut is a count.** The bar orders a selection's controls by `Precedence { Own, Clerical }` (stable, so the registry's order holds within a rank) and shows the first `INLINE_CONTROLS` (5) of that order; the rest go behind `…`, and a selection with five or fewer has no ellipsis. `Placement { Inline, Overflow }` no longer decided where a verb rides, only which verbs come first, so its variant names would have lied. The right-click menu reads the same ordered list. The flips moved after the block verbs in the registry, so a block's row is the user's named order: Accent, Expand, Lock, Icon, Rip. No selection offers exactly six controls today (a pin offers five, a port seven), so the frame tests use those two and a `Bar` unit test pins the five/six boundary.
- **P5 The JSON doors went with the bundle, the rev export included.** `ExportFormat::Json` and `ExportContent::Json` are gone, and with them every door that wrote or read a flattened document as "the diagram": Export ▸ JSON (view and selection), the history card's **Copy** and **Export rev…** (`Action::ExportRev`, `ExportTo`), **Import** of a `.json` (`Imported::Document`, `Insert`, `Source`), and the clipboard paste of an export (`import::from_clipboard`, `block_from_document`). The rev export is a JSON export under another name, and pasting a copied rev is the import-as-block the todo defers to its own design, so leaving either would have kept half a door. **Import** keeps PNG and SVG, so `interpret` returns an `Asset`. The bundle door (`store::bundle`, `open_bundle`, `unpack_and_open`, `share_bundle`, `FileRequest::{OpenBundle, UnpackBundle, ShareBundle}`, `FilePick::{Bundle, UnpackedInto, NewBundle}`, the chrome model's `home`) is gone, and `zip` left `blockworx-store` since nothing else used it.
- **P5 The command line's courtesy open of a `.json` stays, and so does provenance.** A stamped document named on the command line still opens as a scratch session whose title block says where it came from, and the PDF export still names its source. Nothing in the app writes a provenance-stamped JSON any more; `projection::export_text` is reached only from the test that proves the reader, so it and the title block's `From:` row are candidates to go with the import-as-block design.
- **P4 The tool is `AddPin`, and a click on a marker is a stamp.** `ToolName::AddPin` ("Add Pin", `add-pin`) sits in cell 5 beside Add Port, bound to 5 and ⌘N, so Add Image, Add Text and Route move to 6, 7 and 8 (`Key` gains `Num8` and `N`). The armed tool answers a click with `Action::StampTool`, and `stamp` adds the pin only where the click lands on a marker, so a cell dropped on a marker adds the same pin and a click or drop anywhere else writes nothing. `arming_writes_the_document` refuses it in a read-only session. A locked *level* does not refuse it, because a pin goes on a child block and each child carries its own lock.
- **P4 The markers are encoded once, in `new_pin`.** `SlotMarker { block, loc, center }` with `markers_on` (geometry), `offered_markers`/`markers_in_scope` (the lock and read-only policy, through `Drawing::authoring_of`), `marker_at` (the hit test) and `add_pin` (add, then open the name). `draw_markers` draws the buttons, and the "(+)" of a selected block and the armed tool call it with different block sets. The route tool keeps its own visibility rule, where dots show only near the pointer and only on the nearest block, but it draws each marker through the same primitive and ends on `marker_at` over `markers_in_scope`. The route tool used to test `!block.locked`; it now reads the same `authoring_of` as the others.
- **P4 The tool does not stay armed.** A click settles on the new pin's name editor, and a commit selects the pin. That matches the port and text stamps, which settle on their editors too.
- **P4 A drag from the "(+)" does nothing.** A press held on a marker still owns the gesture and draws the marker inverted, so the drag neither moves nor marquees. Nothing is created until a click.
- **P4 Retired: the pull off the "(+)" and the drag hint.** The removals are `RouteTool::routing_from_new_pin`, `edit_start_on_commit`, `pending_name_edit`, `draw_route_hint`, `route_hint_phase`, `hint_offset` and the `HINT_*` constants, plus `Drawing::anchor_side`, which only the hint used. No easing entry or `AnimKey` belonged to the hint. It ran on `Canvas::now`, which the spotlight still uses.
- **P4 The green route-start target stays.** It is how a route starts from an existing pin in the select tool, and how the route tool marks the pin a route will end on. What went is its held-press pull: a press that owned the frame and started a route once the pointer had moved 1.5 cells. That threshold existed so the hint kept cycling through a jittery hold. Now a `DragStarted` on the target starts the route, on the same drag classification as every other drag.
- **P4 No tutorial used the retired flow.** `fixtures/tutorials` holds no scripts, and the plan in `docs/tutorials.md` only drags routes from existing pins and ends them on markers. Chapter 7 gains the Add Pin tool.
- **P3 Handle geometry is defined once, in world units.** `render::selection` holds `HANDLE_RADIUS` (0.45 of a cell; the handles drew at `PORT_RADIUS`, 0.3) and `HANDLE_GRAB` (twice the radius; the handles grabbed from the shared control-point `HIT_RADIUS`, 0.6), with `resize_handles(bbox)` and `resize_handle_at(bbox, pos)`: the one list of corners and the one hit test that the handle drawing, the resize tool's grab and the debug hit-target overlay read. Both grew by half, so the grab stays twice what is drawn. World units, like every other hit radius on the canvas (`HIT_RADIUS`, `ROUTE_HIT_MARGIN`, the new-pin grab): a handle sized in screen pixels would be the one affordance that does not scale with zoom. The new-pin grab's clearance from a corner now reads `HANDLE_GRAB`; the grab itself is unchanged, since the block body one cell away is still its tightest clearance. A handle still grows on hover within `NEW_PIN_GROW_RANGE` (one cell), which stays wider than the grab.
- **P3 A text box stores its width; its height follows its rows.** `Text` gains a `width` register, `Option<NonZeroU32>` grid cells, written by a resize. `None`, omitted from the JSON, is the box that fits its text, so every existing document reads unchanged and the format version stays 3. `BoxWidth::Cells` carries it into the render path: the text wraps at the smaller of the box's inner width and the column cap, and `measure_box_size` keeps the width and takes the height from the wrapped rows. No height is stored: a height the user set would either clip the text or be overridden by it. The box offers the four corner handles a block has, and each drags the width only (`constrain_resize_delta` zeroes the height, as a port's does). A left corner moves the anchor too, so a resize writes `pos` and `width` in one commit.
- **P3 A text box cannot be resized past the column cap.** The resize tool takes `ResizeBounds` when the drag begins: the old size floor, and for a text box `BoxWidth::widest`, the column cap plus the inset rounded up to a cell. That is exactly as wide as a box fitting a capped line, so a fitted box and a widest resized one agree. A box may not be narrowed below `MIN_BLOCK_WIDTH` (4 cells). A width stored under a wider font draws clamped to the cap. The extent cache is keyed on the text and the width, so a resize or its undo re-measures. The selected box's anchor dot gave way to the corner handle on the same spot; a moved box still shows it. Nothing returns a resized box to "fits its text" except undo.
