fix(e10): review round 1, part 2 — the docs the epic invalidated, and three contracts made real

Finishes round 1. Part 1 took the runtime bugs; this takes the prose that
had gone false and the three "the comment says so" gaps behind it.

CLAUDE.md is auto-loaded into every agent session and ui/README.md is
not, so an architecture paragraph that still described a flat components
bag was the most expensive stale sentence in the repo: the next agent
adding a popover reads it, sees no platform layer, and writes the generic
component into components/ — the invisible-layer failure this epic exists
to end. It now names components/ui/ and says the boundary is lint-enforced.
The canonical-patterns table gained the three rows the epic established:
ui/Button over a hand-rolled <button>, a keymap row over a private keydown
listener, and disabled-when-busy vs inert-when-inapplicable.

ui/README.md's inventory was written in this branch's first commit and
never learned about CommandPalette, ContextMenu/useContextMenu,
KeyboardHelp or gridColumns — in a section whose own closing rule is that
a second component painting the same thing is how the last inconsistency
happened. It also claimed "everything is rem-derived" while VirtualGrid's
minCol/GRID_GAP are deliberately px; that is now stated as the exception
it is, and a new section records which promises here are tests rather
than sentences.

Three contracts stopped being prose:

- A text-less Button must carry a name. "Required for iconOnly" was a doc
  comment, which is why the migration shipped two nameless icon buttons
  through eight green lanes. It is a union now, covering both spellings
  (iconOnly, and size="icon"/"icon-sm"), satisfied by label, aria-label or
  title. It caught one more on the way in: Banner's dismiss took an
  optional dismissTitle, so it could render unnamed — it falls back to
  common.close now.
- gridColumns' gutter arithmetic. Every existing assertion survived
  replacing floor((w + GAP) / (minCol + GAP)) with the naive
  floor(w / (minCol + GAP)): the fixed widths agree, the capped cases are
  cap-bound, and the loops compare columnsFor against itself. Pinned at
  the exact-fit boundaries where the two first disagree (536 and 828).
- ShareDialog's link row. Only Copy was migrated in S1, leaving one 2rem
  button between two 1.75rem ones — and on a coarse pointer .touch-target
  grew only the migrated one to 2.75rem while the destructive Revoke
  stayed small.

Also: the maxCols docstring said 8 columns of 384px where the test it
shipped with asserts 369.
This commit is contained in:
2026-09-06 01:30:40 +02:00
parent 20e17c0ccb
commit 80dea4f307
7 changed files with 107 additions and 34 deletions
+26 -10
View File
@@ -29,13 +29,13 @@ domain-free.
## What lives here
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` |
| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader`, `Pager`, `VirtualGrid`, `readAhead`, `Carousel`, `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
| area | components |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster`, `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader`, `Pager`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
## The size contract (E10 S1c)
@@ -46,9 +46,11 @@ somewhere:
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` are the rungs, derived from what
the app actually used. Pick a rung; do not hand-roll padding on a primitive.
- **Everything is rem-derived**, so the Settings text-size slider (`--font-scale` on the root
font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an eslint error for
exactly this reason.
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
eslint error for exactly this reason. The deliberate exception is `VirtualGrid`'s `minCol` /
`colMaxWidth` / `GRID_GAP`, which are px: those are layout-engine measurements compared against a
container's own pixel width, not text boxes.
- **Minimum target: 2.75rem, on coarse pointers only** (`.touch-target`, index.css). A 32px icon
button is fine with a mouse and not with a thumb, so the box grows where there is no precise
pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas
@@ -58,6 +60,20 @@ somewhere:
Controls outside this directory (chips, list rows, the player's overlay buttons) are **not** covered
yet -- they are the next thing to bring under the contract, not evidence that there isn't one.
## Keeping it honest
Two of this directory's promises are load-bearing enough that prose was not enough, so each has a
test rather than a paragraph:
- the import boundary above → `lib/uiBoundary.contract.test.ts` runs eslint over fixture imports.
It exists because the first cut of that rule wrote its regexes in plain `"..."` literals, where
`\.` collapses to the any-char `.` — it read like a boundary and let the two commonest violations
through, with a green gate saying nothing (E10 review round 1).
- the chip lanes' clip → `lib/chipLane.contract.test.ts` counts measurers against lanes per file.
If you add a rule here, ask what would fail if it stopped being true — and if the answer is
"nothing", write the test instead of the sentence.
## Adding one
Move a component in when a second module needs it and it is domain-free — not on speculation. Adding