@lotics/ui 44.7.1 → 44.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -17,7 +17,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
- | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
20
+ | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
21
21
  | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 10 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
22
22
  | [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
23
23
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
@@ -42,6 +42,16 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
42
42
  panel a few hundred pixels wide lays its contents out for the whole display. (A component that
43
43
  needs a number rather than a bucket — how many columns fit — measures its own box; `Table`
44
44
  does exactly that.) → [catalog.md §Utility hooks](./docs/catalog.md).
45
+ - **No user-facing string is a literal in `src/` when a locale slice already names it.** Every
46
+ string a component renders resolves prop → `useLoticsLocale()` slice → the pack's English
47
+ default. Write it as an allowlist, not a hunt: a literal typechecks, renders, and passes a
48
+ render test in the language it happens to be written in — the kit shipped a Vietnamese ⓘ label
49
+ to every tenant that way, invisible to a whole suite of vi-pack assertions. Assert a wired
50
+ string under the OTHER language. → [composition.md §Microcopy](./docs/composition.md).
51
+ - **Annotate every droppable table column, or none.** A partial `priority` is the trap: an
52
+ unannotated column sorts above the whole hand-written range, so one annotation makes every
53
+ other column drop first, and one unannotated column among annotated ones goes first however
54
+ important it is. → [composition.md §The register's own craft](./docs/composition.md).
45
55
  - **A section's ADD rides its heading row, right edge** — Add files, Add fee, New line: a
46
56
  `primary` `Button` beside `SectionHeadingTitle`, rendered empty or full. Under the rows it
47
57
  extends, the verb MOVES with the row count and vanishes off-screen on a long list; a heading is
package/MIGRATION.md CHANGED
@@ -4,6 +4,112 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 44.8.0 — a value column gets its own floor, and eight defaults stop fighting their own docs
8
+
9
+ No API is removed. Three of these change what an existing screen renders, so read them even
10
+ though nothing will fail to compile.
11
+
12
+ **`StepProgress`'s built-in caption is locale-wired, and `LoticsLocale` gains a `stepProgress`
13
+ slice.** A hand-built pack must supply it (both shipped packs already do):
14
+
15
+ ```ts
16
+ stepProgress: {
17
+ complete: (total) => `Hoàn tất (${total}/${total})`,
18
+ stage: (name, index, total) => `${name} (${index}/${total})`,
19
+ none: (total) => `0/${total}`,
20
+ position: (index, total) => `${index} trên ${total}`,
21
+ },
22
+ ```
23
+
24
+ The caption over named stages was built as `Complete (7/7)` / `0/7` in the component, and the
25
+ screen-reader fallback as `4 of 7` — hardcoded English inside a component the kit requires to
26
+ resolve prop → pack → English default. A Vietnamese board read `Complete (7/7)` in its own
27
+ register. Same defect class as the `fileRow`/`sources`/`counter` leak below, and it hid the same
28
+ way: every state renders correctly under `en`, because English is what the bug produces.
29
+
30
+ Each entry is a FUNCTION rather than a word to concatenate, for the reason the earlier entry
31
+ gives — where the number sits in the sentence is a property of the language, and `position`
32
+ is `4 of 7` against `4 trên 7`. Passing `label` still overrides the caption entirely; nothing
33
+ that already passed one changes.
34
+
35
+ **`Finding` stacks its `readings` one per line and drops the ` · ` between them.** No prop
36
+ changes; a two-reading finding is now two lines instead of one.
37
+
38
+ The separator failed twice over. `docs/reviewing.md` bans ` · ` outright — it claims a
39
+ relationship while refusing to name it, and it is the loudest templated-metadata tell there is —
40
+ so an app reaching for the kit's own ranked-insight component failed the kit's own microcopy
41
+ probe with nothing it could do about that. And a middot is the one mark a screen reader drops,
42
+ so the character carrying "these two disagree" never survived being read aloud: exactly the
43
+ defect `CommentsButton` was fixed for. It wrapped badly too — inline + `flexWrap` could start a
44
+ phone-width line WITH the separator, or split a source from its value across lines.
45
+
46
+ Stacked, each `source value` pair is a row at every width and needs no separator to relate them.
47
+ Pass `readings` shorter values, or move a long one to `detail`, if the extra line costs you
48
+ somewhere tight.
49
+
50
+ **A `SequenceItem` with no handlers renders no control column.** Reorder/remove controls still
51
+ render disabled where they cannot act — first item, last item, a row that can be removed but not
52
+ moved — because that is what holds the right edge steady between items that are editable. What
53
+ changed is the item that declares NO editing at all: it used to get three permanently dead
54
+ buttons, which is a dead affordance by `reviewing.md`'s own gates, and a read-only sequence (legs
55
+ already run, a route as recorded) is a common shape rather than an edge case. A list where some
56
+ rows are fully locked and others are editable will now step in the right margin; give the locked
57
+ rows a handler if that matters more than the dead controls did.
58
+
59
+ **`DateStamp` takes `compact`.** Additive, default `false`. It forwards the option `formatDate`
60
+ already had, dropping the year (`08/09`) so the stamp can agree with a neighbouring column the
61
+ caller formats itself. The format stays in `formatDate` — this is a passthrough, not a second
62
+ implementation.
63
+
64
+ **A `DetailTable` stacks earlier, because a value column's floor is no longer a control's.**
65
+ `minValueWidth` defaulted to `MIN_CONTROL_WIDTH` (160) — the width below which a CONTROL stops
66
+ being operable, which is a different question from the width below which a VALUE stops being
67
+ readable. A value column holds company names and addresses. At 150/160 the stack threshold was
68
+ 334, so a 375px screen kept two columns and handed the value ~170px: a name needing 221 got 104.
69
+ The new `MIN_VALUE_WIDTH` (200) is the default; pass `minValueWidth` explicitly to pin the old
70
+ behaviour on a table whose values really are short.
71
+
72
+ **An unannotated table column's drop priority is `columns.length + index`, not `index`.** An
73
+ unannotated register still sheds right-to-left — that is unchanged and covered by a test — but a
74
+ MIXED table now behaves the way its author meant. Annotating your most important column
75
+ `priority: 1` used to TIE it with the unannotated column at index 1, and the right-to-left
76
+ tie-break then dropped yours first: the amount column, the fact the register exists for,
77
+ disappearing at narrow widths on a screen that reviewed as correctly annotated. **Annotate every
78
+ droppable column or none** — a partial annotation is what the old default punished.
79
+
80
+ **A selected `CardSelectItem` carries a GROUND**, not the same ring as hover. It was
81
+ `selected || hovered || pressed || focusVisible` → one style, so the card you had CHOSEN was
82
+ pixel-identical to the card under the pointer. Selection is now `accent_wash` + an accent edge,
83
+ the same "you are here" a selected table row and an active chip carry. This is the fix 43.5.0
84
+ made for `Table`'s selected row, one release late.
85
+
86
+ **`DataGrid`'s flexible column has a floor** (`FLEX_MIN_WIDTH`, 120) instead of `minWidth: 0`.
87
+ This grid sheds no columns by design — every column is a measure you came to compare — so the
88
+ fixed columns held their widths and the flexible identity column absorbed the whole squeeze,
89
+ all the way to zero: a narrow container rendered rows of unlabelled money, silently. Past the
90
+ floor the grid now overflows, and a narrow container needs to scroll it.
91
+
92
+ **`AgentRunLike.error` is OPTIONAL.** It was declared required while its own docblock promised
93
+ it accepted an SDK run's optional `error` — and an optional property is not assignable to a
94
+ required one however wide the required one's type, so the three-line usage printed in
95
+ `ai_patterns.md` did not compile. **If you worked around it** with a `useMemo` reshape or a cast
96
+ (`{ ...run, error: run.error ?? null }`), delete that: hand the SDK's run straight over.
97
+
98
+ **`Select` takes the `D` data generic** its siblings already had, so `optionPicker(...)` spreads
99
+ into it without a cast. Additive.
100
+
101
+ Also additive, nothing to change: `MIN_VALUE_WIDTH` and `FLEX_MIN_WIDTH` are exported (the
102
+ latter via a new `./table_fit` entry point, which also exports `ROW_HEIGHT` and `COLUMN_GAP` so
103
+ a skeleton can be built from the register's own constants); `formatMoney` takes
104
+ `maxFractionDigits` for a genuine per-unit price (`Intl`'s currency style floors at the
105
+ currency's minor unit, so an LCL rate of 0,019 US$/kg printed as `0,02`); `InlineSelect`
106
+ forwards `getOptionDescription` to the two-line option row `OptionList` already renders;
107
+ `NumberInput` takes a `placeholder`; `EmptyState` and `ErrorState` centre their TEXT and not
108
+ only their box (they swap into one another as a read settles, so only one of the two centred
109
+ would have shown up as the copy jumping alignment at the moment a region fails); a
110
+ heading row WRAPS rather than crushing its title; `Accordion`'s meta yields before its title;
111
+ `CardHeaderTitle` reads the ⓘ's name from the locale pack; and `ReferenceField`'s peek footer
112
+ gives its clear the same resting surface as the verb beside it.
7
113
  ## 44.5.0 — `Pagination` drops the page index
8
114
 
9
115
  The pager rendered a range AND a page index — `16–30 of 45` beside `Page 2 of 3`. Both answer
@@ -94,11 +200,10 @@ No API is removed and nothing throws, but four DEFAULTS moved, so every register
94
200
  in your app renders differently on upgrade. Each is reversible at the call site if you need the
95
201
  old look while you catch up.
96
202
 
97
- **Table column headers are sentence case, not all-caps** and one ink step lighter
98
- (`zinc-500`). `Table` and `SortHeader` were uppercasing every column label, which the heading ramp
99
- bans and names the #1 templated tell. The ink step is not cosmetic and cannot be skipped: at
100
- `muted` the header matched a row's supporting line in size, weight AND colour, so dropping the
101
- case alone leaves it reading as a stray label rather than as the band naming the columns.
203
+ **Table column headers are sentence case, not all-caps.** `Table` and `SortHeader` were
204
+ uppercasing every column label, which the heading ramp bans and names the #1 templated tell. What
205
+ keeps the band from reading as a stray label once the case is gone is WEIGHT: the header is
206
+ `sm muted medium`, and a row's supporting line is `sm muted` regular, so the two never collide.
102
207
 
103
208
  **Row separators are lighter than the header rule.** The band capping a table keeps
104
209
  `colors.border`; the hairlines between rows are `zinc-100`. Every rule used to be `border`, which
package/docs/catalog.md CHANGED
@@ -222,6 +222,13 @@ Two columnar shapes, and the choice is about data size:
222
222
  heading, so digits line up under their label; **`DataGridColumn.footer` gives that column its
223
223
  cell in the group's footer row** (a subtotal, a resolved measure) — the grid lays it out in
224
224
  the column's own box, so a total can never drift from what it sums and no consumer rebuilds
225
+ **`DataGrid` SHEDS NOTHING at narrow widths, and that is deliberate** — every column is a
226
+ measure you came to compare, unlike `Table`'s register of objects where the row is an index
227
+ and the record holds the rest. So it has no `priority` and no stacked mode: fixed columns keep
228
+ their widths, the flexible column holds a `FLEX_MIN_WIDTH` floor, and past that the grid
229
+ OVERFLOWS. A narrow container must scroll it horizontally; the floor exists because without
230
+ one the flexible identity column absorbed the entire squeeze down to zero and the grid
231
+ rendered rows of unlabelled numbers, silently. If you want columns to drop, you want `Table`.
225
232
  the row. `renderGroupFooter` remains for content that does NOT sit in a column (a per-group
226
233
  add row). The grid takes exactly its container's width — no outdent to fight, so it needs no
227
234
  scroller unless YOU add one. It renders ALL rows (no
@@ -376,7 +383,9 @@ your screen. See the AI-patterns doc indexed in [AGENTS.md](../AGENTS.md) for th
376
383
  `ChoiceList` options), `Sources` (provenance chips for AI output — at review scale,
377
384
  `label={null}` slots the chips at a section's bottom), `Finding` (one ranked insight from an
378
385
  AI check, laid out HORIZONTALLY: a severity dot, the title, and the `delta` right-aligned on
379
- that same line, over one dim line of `readings` `source value · source value`, both kept on
386
+ that same line, over the `readings` stacked ONE PER LINE — each `source value`, no separator
387
+ joining them (a middot claims a relation it will not name, and is the one mark a screen reader
388
+ drops), both kept on
380
389
  screen with NEITHER marked as the wrong one, since a finding reports that two sources disagree
381
390
  and which is right is the reader's call. `detail` is the optional consequence. A reader scanning
382
391
  a stack gets severity down the left edge and magnitude down the right. The readings ARE the
@@ -542,6 +551,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
542
551
  one interaction it signals; the padding still comes off the 8-grid, the ring is only
543
552
  the floor), `HOVER_BORDER`, `CONTROL_TRANSITION`, `chipSurfaceStyle` — the shared
544
553
  control-surface tokens.
554
+ - **`table_fit`** — the register's shared geometry, importable so a skeleton or a
555
+ hand-built band derives from the same numbers `Table` lays out with: `ROW_HEIGHT` (72),
556
+ `COLUMN_GAP`, `ROW_GUTTER`, `FLEX_MIN_WIDTH` (120 — what a flexible column needs to stay
557
+ usable), plus `computeTableFit` itself. `MIN_VALUE_WIDTH` (200 — a record VALUE column's
558
+ floor, deliberately above a control's `MIN_CONTROL_WIDTH`) lives in `control_surface`.
545
559
  - **`fonts.css`** — the Inter sheet (400/500/600, served by absolute URL so it resolves on
546
560
  every origin an app runs from); the app entry imports it ONCE or every `Text` falls back
547
561
  to system fonts.
@@ -1150,7 +1164,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1150
1164
  `Destination`) cannot hold a second middle entry and cannot say the entries are ordered.
1151
1165
  `SequenceItem` takes the content, an optional `role` (DERIVE it from index — a stored role lies
1152
1166
  the moment the list is reordered) and `onMoveUp`/`onMoveDown`/`onRemove`; the controls render
1153
- even where they cannot act so the right edge never shifts between items. Reorder is BUTTONS,
1167
+ even where they cannot act so the right edge never shifts between items but an item passing
1168
+ NO handler at all renders no control column, so a read-only sequence (legs already run, a route
1169
+ as recorded) is a first-class shape rather than three dead buttons per row. Reorder is BUTTONS,
1154
1170
  not a drag handle — dragging is invisible to the keyboard and fiddly on a phone, and a
1155
1171
  three-to-six position list does not need it. Indent anything that follows the list (an "Add"
1156
1172
  link) by `SEQUENCE_INSET` so it lands on the rail's column. Distinct from `Timeline` (an
@@ -1182,8 +1198,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1182
1198
  before acting (amber, announced); **`error`** = field-level failure under the value, danger +
1183
1199
  alert semantics (the `Inline*` editors already render their own transient save errors — don't
1184
1200
  wire both); invalid/cross-field STATE = a co-located `Callout`; **`flat`**
1185
- on a flat-text value row (`InlineStatic`, plain `Text`) tucks the annotation up by the
1186
- control band's slack so its gap matches a chip row's.
1201
+ on a value sitting OFF the inline-control grid (a raw node NOT `InlineStatic` / `InlineSlot`,
1202
+ which are on it) aligns the annotation to it on both axes: drops the control text inset and
1203
+ tucks up by the band's slack.
1187
1204
  The three annotations are ONE anatomy shared with a checklist row's `ChecklistNote` — same
1188
1205
  order, same alert semantics — because a record's field and a task's field are the same thing
1189
1206
  on two surfaces.
@@ -1257,7 +1274,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1257
1274
  panel narrower than the control that opened it reads as belonging to something else. So the
1258
1275
  field truncates its name to one line and the peek's header WRAPS the whole of it: the column
1259
1276
  stays scannable, the full value is one press away. Between the floor and `labelWidth +
1260
- MIN_CONTROL_WIDTH + 24` a `DetailTable` stacks, which is the right grammar at that width.
1277
+ MIN_VALUE_WIDTH + 24` a `DetailTable` stacks, which is the right grammar at that width — the
1278
+ VALUE floor (200), not the control floor, because that column holds names as well as editors.
1261
1279
  The verbs live INSIDE the
1262
1280
  peek, never on the field, and they are ALL `Button`s at one altitude — a peek is
1263
1281
  dialog-scale and a dialog's verbs are buttons, so mixing weights only made the reader rank
@@ -1374,6 +1392,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1374
1392
  position (a group band, a totals line) and the gutter stays blank so the cells beside it stay
1375
1393
  in line. A position is NEVER an identity: sort or filter and row 12 is a different record —
1376
1394
  a stable handle is a column, and it leads the cells. Worked screen: `tpl_item_list`.
1395
+ - **`tabs`** — a tab may state its band's size with `TabOption.count`, and needs attention with
1396
+ `TabOption.status` (a `ColorName` dot). Pressing the tab IS the filter, so the count is also the
1397
+ reason to press it. → [composition.md](./composition.md) §"A partitioning control states its band's size".
1377
1398
  - **`table`** rhythm — rows are separated by their own height and the hover wash, never by a hairline; the register's one line is the band capping the columns. 72px rows, overridable per row with `TableRow`'s `minHeight` when a surface genuinely needs density. There is no `spacing` prop: a table of VALUES rather than of objects is a different component (`Matrix`, `DataGrid`). → [composition.md](./composition.md) §"The register's rhythm".
1378
1399
  - **`table` › `TableGroup`** — a titled BAND of rows inside a `Table` (`label`, `count?`, `color?` for a single valence dot). Turns a register that presents records into one that triages them: group by what implies a different ACTION, order the bands by what needs the reader now, and let air rather than a rule separate them. → [composition.md](./composition.md) §"A register that TRIAGES".
1379
1400
  - **`sort_header`** — `SortHeader` + `SortState`/`SortDir` + `cycleSort` + `sortBy` +
@@ -1435,6 +1456,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1435
1456
  empty — and a ladder of fields is a form with invisible borders. Reach for `InlineDatePicker`
1436
1457
  when a person is expected to ENTER the date, and this when the system already knows it.
1437
1458
  Empty renders a thin rule, not a placeholder; read-only (omit `onChange`) renders text alone.
1459
+ `compact` drops the year (`08/09`) — the stamp shares a row with dates the caller formats, and
1460
+ the option goes straight through to `formatDate`, which stays the one place deciding what a
1461
+ date looks like.
1438
1462
  `onChange` fires on CLOSE, not on each pick — one commit per visit, and a dismissed panel
1439
1463
  still writes whatever was picked in it, which is what makes a single tap enough.
1440
1464
  Worked screen: `examples/tpl_record.tsx` § Progress.
@@ -1606,7 +1630,9 @@ component rather than showing it at zero.
1606
1630
  status mix on one bar).
1607
1631
  - **`step_progress`** — `StepProgress`: N-of-M dots/segments progress. A real `progressbar`
1608
1632
  (valuenow/min/max) because its segments are decoration over ONE quantity — the opposite
1609
- case from `stepper`, whose steps carry content a `progressbar` would hide.
1633
+ case from `stepper`, whose steps carry content a `progressbar` would hide. Pass the stage
1634
+ NAMES and it captions itself from the `stepProgress` locale slice ("Hạ bãi (2/4)"); `label`
1635
+ overrides, `captionTone="danger"` is the documented stalled-stage slot.
1610
1636
  - **`breakdown`** — `Breakdown`: a stacked bar + ranked share rows, pressable to drill;
1611
1637
  `maxRows` folds the long tail behind a localized "Show N more" toggle
1612
1638
  (`BreakdownLabels`).
@@ -2019,7 +2045,9 @@ component rather than showing it at zero.
2019
2045
  column quietly acquires TWO left edges and two baselines, and `DetailRow`'s label (which pads
2020
2046
  down to meet a control) agrees with one kind of row and not the other. `InlineStatic` is the
2021
2047
  STRING case and composes it; reach for the slot when the value is a node, which a review
2022
- surface always is. NOT for a table cell a `Table` sets its own row rhythm.
2048
+ surface always is. An ANNOTATED row needs the grid even with no editor in sight, because the
2049
+ annotation stack is inset to a control's words — data_entry.md §The editability affordance
2050
+ has the per-table test. NOT for a table cell — a `Table` sets its own row rhythm.
2023
2051
  - **`use_change_set`** — **`useChangeSet(ids, { initial })`**: the accept/reject/undo
2024
2052
  bookkeeping as a HOOK, zero layout — `status`/`accept`/`reject`/`undo`/`acceptAll`/`reset`,
2025
2053
  `accepted`/`rejected`/`pending`, `keptCount`/`total`/`settled`. Decisions are stored as
@@ -8,6 +8,23 @@ full worked-example screens ship as source in `examples/tpl_*.tsx` (picked by jo
8
8
  [templates](./templates.md)). Every rule here is enforced by the primitives' defaults; fighting
9
9
  them produces off-system UI.
10
10
 
11
+ ## The form comes before the treatment
12
+
13
+ **Name what the subject IS, then reach for parts.** A set of records is a register; a thing moving
14
+ through ordered places is a rail; work assigned to finite resources over time is a board; a
15
+ sequence of events is a timeline; a population whose point is its deviant minority is a queue
16
+ sorted by deviation. Getting this wrong cannot be recovered later — a register of objects that
17
+ should have been a rail is well-typed, well-spaced, correctly aligned and still says nothing about
18
+ order or progress, because a status column reports one word and leaves the reader to know which
19
+ word comes after which.
20
+
21
+ The pull toward a register is structural, not a lapse in taste: `Table`, `DetailRow`,
22
+ `SummaryLine` and `Badge` are the most reachable things here, and they compose into something
23
+ defensible for almost any data. That is exactly why the question has to be asked out loud. **Reuse
24
+ first is about not duplicating primitives** — a one-off composition needs no new component, so the
25
+ kit lacking a device is a reason to compose one from what it has, not a reason to flatten the
26
+ subject into rows.
27
+
11
28
  ## Canvas & content column
12
29
 
13
30
  - **Canvas**: a full-bleed `colors.white` ScrollView with `padding: 28`. Cards separate from the
@@ -183,6 +200,14 @@ without scanning. The heading row is the section's control line — it already c
183
200
  and the view controls — so the add belongs on it, in the same spot whether the section holds
184
201
  nought or forty.
185
202
 
203
+ **When the row runs out of width, the VERB wraps — the title never gives way.** The title names
204
+ the section; the verb is the thing that can move. Getting this backwards is easy to ship because
205
+ RN-Web resolves `flex: 1` to `flex-basis: 0%` with `min-width: 0`, so a title beside a button that
206
+ holds its intrinsic width is the half that collapses — measured at 375 as a heading broken one
207
+ word per line beside an untouched button. `SectionHeading` and `SubsectionHeading` wrap and floor
208
+ their title column for this; do not add a second `flex: 1` spacer beside a title that already has
209
+ one, which halves its share again and is how a badge came to land on top of a section title.
210
+
186
211
  **Where there IS no heading row, the add has nowhere else to be, so it rides the surface's own
187
212
  bar** — an attachment popover, a dialog body, any self-contained widget. That is not an exception
188
213
  to the law so much as the same reasoning: the rule exists so the verb sits in ONE place a reader
@@ -217,6 +242,18 @@ no path actually closes.
217
242
  - **Only the ADD.** A verb that acts on a SELECTION (delete, export, run) belongs to the
218
243
  selection — `FloatingActionBar` — and a verb about ONE row stays on the row.
219
244
 
245
+ ## A partitioning control states its band's size as a PROP
246
+
247
+ `TableGroup` takes `count`; `Tabs` takes it per option. Neither takes a label with the number
248
+ formatted into it, and that is the whole rule: `"Trên tàu (4)"` is a second copy of a figure the
249
+ screen already computes, and it is the copy that goes stale — the band re-counts on every filter
250
+ and the string does not.
251
+
252
+ It also decides where the count may live. A register whose bands already state their sizes must
253
+ not carry a strip of chips above it repeating the same three numbers; that is one fact twice, and
254
+ the reader learns to skip the strip. And a count is a reason to PRESS — a control that states how
255
+ many rows are behind it and does not filter to them is stating a fact it refuses to act on.
256
+
220
257
  ## Period filters for time-constrained data
221
258
 
222
259
  Time-constrained data gets a **`DateRangeFilterField`** in the header band — never a static period
@@ -312,7 +349,11 @@ compete instead of reading as different things. Selection changes what the box I
312
349
  hover changes how intensely it says it.
313
350
 
314
351
  For fields: layer the hover-border AFTER `style` so it wins over the caller's resting edge, put the
315
- open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. The bug this
352
+ open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. That ordering
353
+ is load-bearing rather than tidy: a caller's resting `borderColor` is exactly how a frameless
354
+ variant gets expressed, and layered the other way it erases the hover edge that is such a field's
355
+ ONLY affordance — a control that then draws nothing in either state and is simply never pressed.
356
+ Give the field a variant PROP for its resting frame instead of taking one through `style`. The bug this
316
357
  grammar prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border — it
317
358
  greys *and* animates its edge while its siblings only border-hover. Reach for `PressableHighlight`
318
359
  on a bordered/input control and you've made it.
@@ -416,6 +457,12 @@ a dashboard. Card stat rails use `KPICard`.
416
457
  form round-trip never expands them to a fabricated `"2026-05-01"`. Comparisons and sorting treat
417
458
  a partial as its period start (`"2026" < "2026-05" < "2026-05-01"`).
418
459
 
460
+ **A per-unit price is not a total, and the currency's minor unit is the wrong floor for it.**
461
+ `Intl`'s currency style rounds to two decimals for USD, so a rate of 0,019 per kg prints as
462
+ `0,02` — a 5% error rendered as fact, with nothing on the screen saying it was rounded. Pass
463
+ `maxFractionDigits` on a UNIT price; leave it off for a total, where two decimals is the contract.
464
+ Never hand-roll the grouping or the symbol to get the digits back.
465
+
419
466
  ## Every number is a door
420
467
 
421
468
  Except the KPI strip: a component that summarizes records leads to the records behind it when
@@ -712,6 +759,21 @@ action goes to the referenced record's own page; a local `Drawer` that re-shows
712
759
  just showed makes "Open" mean *read that again, wider*, and leaves the real record unreachable
713
760
  from the only control that promised it.
714
761
 
762
+ **A record surface answers *which record is this* first — and the block that does it is SHORT.**
763
+ Identity is the handful of facts someone would say aloud to name this record (its number and its
764
+ counterparty, usually two); everything else is an ATTRIBUTE and belongs below the answer the
765
+ reader opened the record for. Get the size wrong and the position becomes unwinnable: eight
766
+ labelled header fields push the ledger under the fold, and moving the block to the bottom to fix
767
+ that leaves a reader who cannot tell whose record they are in. Both orders measure perfectly, so
768
+ neither is caught by any check that reads the rendered screen. **When a leading block buries the
769
+ answer, shrink it — do not move it**; frequency of EDIT is not the axis, because a rarely-edited
770
+ fact can still be the one that names the thing.
771
+
772
+ "The chrome already carries identity, so the body need not lead with it" is a real argument and a
773
+ falsifiable one — verify it FACT BY FACT before relying on it. A summary strip showing the record
774
+ number and its totals carries half an identity; the counterparty is missing, and half is what
775
+ makes the claim feel true while the reader still cannot name what they are looking at.
776
+
715
777
  **All of the above is the LIST SCREEN.** A CHILD COLLECTION inside a record page — fees, lines,
716
778
  stops, containers — does the opposite: the row **EXPANDS** (`TableRow` `detail` + `expanded`),
717
779
  never docks a drawer. A drawer's whole job is to supply a context the reader is missing, and on a
@@ -928,10 +990,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
928
990
  alignment law: the row top-aligns and label, control, trailing each center within the
929
991
  first control line — a tall value block never drags the label, and a LABEL too long for its
930
992
  column WRAPS (never clipped, never abbreviated to fit) with its first line still level with
931
- the value. A FLAT value row
932
- (`InlineStatic`, plain `Text`) sets **`DetailRow flat`**: the annotation tucks up by the
933
- control band's slack so the perceived gap under the text equals the gap under a chip
934
- the band's invisible bottom half must never read as a hole above the description.
993
+ the value. A value that sits OFF the
994
+ inline-control grid — a raw node, not an `InlineStatic` / `InlineSlot` sets **`DetailRow
995
+ flat`**, which aligns its annotations on both axes: they drop the control's text inset so the
996
+ description starts on the value's own left edge, and tuck up by the band's slack so the gap
997
+ under the text matches the gap under a chip. Miss it and the row has three left edges and a
998
+ hole; pass it on a value that IS on the grid and you create both. Which one a static value
999
+ should be is decided per table — see data_entry.md §The editability affordance.
935
1000
  - **A verb about a VALUE goes ON the value — an `InlineButton` in the field's `actions`.** Copy
936
1001
  this reference, call this number, open this record: the act names the value, so it travels
937
1002
  with what it acts on, and a reader never has to pair a control sitting at the page's right
@@ -1243,6 +1308,15 @@ which is the loudest possible version of the thing the rule exists to stop. Remo
1243
1308
  without replacing the separation is what left the header looking adrift — the ink step above is
1244
1309
  the replacement, and the two changes only work together.
1245
1310
 
1311
+ **Annotate every droppable column, or none.** `priority` decides what a narrow container sheds
1312
+ (lowest number is last to go, and column 0 — the identity — never drops). A PARTIAL annotation is
1313
+ the trap: an unannotated column defaults above the whole hand-written range, so one annotated
1314
+ column silently makes every other column drop before it, and one unannotated column among
1315
+ annotated ones is the first to go however important it is. Both read as correct in review. The
1316
+ question to answer per register is "what is the last column standing", and the answer is almost
1317
+ always the figure the register exists to compare — so give that one the lowest number and work
1318
+ outwards.
1319
+
1246
1320
  **A row's SUBJECT is `medium`, never semibold.** Semibold is the heading ladder's weight — `#`,
1247
1321
  `##`, `###` are all semibold — so a register whose row subjects are semibold renders its content
1248
1322
  at heading weight once per row, in the same font CUT as the page title (`Inter_600SemiBold` is a
@@ -1495,6 +1569,12 @@ disclosure exception). `TextLink` takes the blue only when it has an `href`; as
1495
1569
  a value, or as a muted in-place disclosure, it stays neutral — because a press that opens a PEEK,
1496
1570
  or unfolds a transcript, is not a trip.
1497
1571
 
1572
+ **Selection is a GROUND, and hover is a ring — at every altitude.** A row, a card, a chip, a
1573
+ tile: the thing you have CHOSEN carries the accent wash, and the thing under the pointer carries
1574
+ the ring. Collapsing them into one "active" style makes the selected item pixel-identical to the
1575
+ hovered one, so "which did I pick" is answerable only by moving the mouse away. The tell in code
1576
+ is a single boolean OR-ing selection together with hover, press and focus.
1577
+
1498
1578
  ## `Badge` is for STATUS only — everything else is text
1499
1579
 
1500
1580
  A `Badge` means STATE that must read at a GLANCE — a lifecycle status, a risk level, a quality
@@ -1610,6 +1690,12 @@ Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px b
1610
1690
  canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
1611
1691
  hierarchy, not cramming.
1612
1692
 
1693
+ **A control's height comes from `CONTROL_HEIGHT`, never from padding tuned against a leading.**
1694
+ Padding that happens to sum to the band height is a second owner of that number, and it drifts the
1695
+ moment the type ramp moves — which the ramp is allowed to do. The signature is one control
1696
+ standing a few pixels proud of every sibling in a filter bar, correct on the day it was written
1697
+ and wrong after a release that touched no control at all.
1698
+
1613
1699
  **The trim test — does this element RESTATE something already on the screen?** Run it over every
1614
1700
  screen before shipping: a per-row proportion bar when the percentage is already on the row; the
1615
1701
  percentage itself when the column holds four values and a total; a state line that only says
@@ -66,6 +66,13 @@ Controlled inputs make derived state track what is on screen, and read shorter t
66
66
  they replace — four `DetailRow` + `Inline*` pairs whose `onSave` exists only to `setState` collapse
67
67
  to four fields. Worked example: `tpl_record`'s New-customer dialog.
68
68
 
69
+ **The discriminator, since the kit's own `tpl_item_list` fills preview cards with `Inline*`
70
+ editors:** what decides is whether anything on the surface READS the draft before the commit. A
71
+ dialog whose Save is gated on the values being typed, or whose visible totals derive from them,
72
+ takes controlled `FormField`s — that is this rule. A REVIEW surface whose gate is decided by
73
+ something else (which cards are kept, which candidate is picked) may keep inline editors, because
74
+ nothing is arguing with the box under the cursor. Neither shape is a create form's stand-in.
75
+
69
76
  **But do not then derive the ERROR per keystroke** — the mistake controlled inputs invite, and the
70
77
  opposite failure to the one above. Validity and the error MESSAGE are different questions. Validity
71
78
  gates the commit and is checked continuously; the message claims the reader did something wrong, so
@@ -313,19 +320,66 @@ where it must stay strictly inside its box — that is `framed`. A DISABLED `fra
313
320
  box: it drops the border and fill so it cannot promise a press, but it stays in line with the enabled
314
321
  fields beside it.
315
322
 
323
+ **`bare` puts its whole affordance in the hover edge, so nothing may outrank that edge.** Pass the
324
+ variant as a PROP; never express it as a resting `borderColor: "transparent"` in `style`, which is
325
+ applied after the hover rule and erases the edge one line after it is computed. The failure is
326
+ silent in every direction that usually catches things — the field keeps its role, its label, its
327
+ focus ring, its metrics and its save — and what breaks is only that nobody presses it, because a
328
+ control drawing nothing at rest and nothing on hover is text. The kit's own `TextInputField` had
329
+ it, so a `bare` inline editor in a register column went un-pressed rather than mis-styled.
330
+
331
+ **The cursor is the other half of that promise, and it belongs to the CONTROL, not to the page.**
332
+ An inline editor takes the pointer like every other control. It once forced the arrow back, on the
333
+ reasoning that an editor is an input rather than a button — right that it is not a button, wrong
334
+ about the replacement: the arrow is what inert prose gets, so the one value on the row that was
335
+ the reader's to change was the one that looked least touchable. The I-beam is not the alternative
336
+ either; a resting editor sets `userSelect: "none"` so a drag edits rather than selects, and an
337
+ I-beam would promise a caret that is not there. A field is disabled is the only case that reverts
338
+ to the arrow, for the same reason it rests bare: an inert value must promise nothing.
339
+
316
340
  A READ-ONLY value in the same column — a computed total, a system ID, a synced/locked field — is
317
341
  **`InlineStatic`**: it copies the editor box metrics exactly (height, padding, 1px transparent
318
342
  border) but stays FLAT and non-interactive, so editable (chip) vs read-only (flat) is legible at a
319
343
  glance and the static value never reads as a disabled input.
320
344
 
321
- **Only in a grid that MIXES the two.** "Copies the editor box metrics" means it hard-sets
322
- `INLINE_CONTROL_HEIGHT` — worth paying to hold a column straight beside real editors, pure waste
323
- where there are none. In an ALL-static grid (a peek's facts, a summary popover) it reserves a 40px
324
- control band for a 20px value, and the row is the MAX of that box and the table's own band, so
325
- neither fix works alone: use plain `Text` for the value AND pass `DetailTable` a `minHeight` (28,
326
- `DetailRow`'s own default). Same test as `labelWidth` a `DetailTable`'s defaults are the
345
+ **In a grid that MIXES the two, or one that ANNOTATES.** "Copies the editor box metrics" means it
346
+ hard-sets `INLINE_CONTROL_HEIGHT` — worth paying to hold a column straight, waste where there is
347
+ no column to hold. Two different things can make the column, and missing the second is what put
348
+ three left edges on a review dialog:
349
+
350
+ - **An editor beside it.** The obvious case: a static value rendered raw beside a real editor
351
+ starts at the cell edge, 20px tall, at the top, while the editor's text starts 9px in and
352
+ centres in a 40px band.
353
+ - **A value column's floor is not a control's floor.** `minValueWidth` decides when the table
354
+ gives up two columns and stacks, and it defaults to `MIN_VALUE_WIDTH` (200) rather than a
355
+ control's `MIN_CONTROL_WIDTH` (160), because the column holds company names and addresses, not
356
+ only editors. The narrow-width failure is silent in both halves: the table technically fits, so
357
+ it does not stack, and every value is then truncated past half while the label beside it measures
358
+ perfectly. Raise it further where a cell holds MORE than one editor; lower it only where the
359
+ values genuinely are short.
360
+
361
+ **A `description` / `warning` / `error` under it.** `FieldAnnotations` insets its stack by
362
+ `CONTROL_TEXT_INSET` so it lines up with a CONTROL's words — so an annotated row whose value is
363
+ raw puts the value at the cell edge and its own description 9px inside it. Nothing else on the
364
+ screen is wrong, the drift is small enough to read as sloppiness rather than structure, and it
365
+ appears only on the rows that happen to carry a description, so a column slides in and out of
366
+ alignment down its own length.
367
+
368
+ Decide it per TABLE, never per row: one annotated static row puts every static value in that table
369
+ on the grid, or the table keeps two row heights for no reason a reader can see.
370
+
371
+ In an all-static, un-annotated grid (a peek's facts, a summary popover) the grid buys nothing and
372
+ costs 40px of band for a 20px value — and the row is the MAX of that box and the table's own band,
373
+ so neither fix works alone: use plain `Text` for the value AND pass `DetailTable` a `minHeight`
374
+ (28, `DetailRow`'s own default). Same test as `labelWidth` — a `DetailTable`'s defaults are the
327
375
  inline-control grid's; a grid that isn't one overrides both.
328
376
 
377
+ `DetailRow flat` is the ESCAPE from the grid, not a synonym for read-only: it says "this value is
378
+ a raw node sitting off the control band", and it corrects the annotations on both axes for it —
379
+ dropping the text inset and tucking the block up by the band's slack. Never pass it with
380
+ `InlineStatic` or `InlineSlot`, which are ON the grid: there it re-introduces the 9px drift it
381
+ exists to remove and pulls the description 4px too tight.
382
+
329
383
  ### Choosing a CHOICE control — by option count, not by taste
330
384
 
331
385
  A field whose value is one-of-N has three answers, and the wrong one is what makes a record
package/docs/reviewing.md CHANGED
@@ -45,10 +45,35 @@ the same direction. The kit measures the CONTAINER, not the screen — see AGENT
45
45
 
46
46
  ---
47
47
 
48
- ## Two gates, before any probe
48
+ ## Three gates, before any probe
49
49
 
50
- A perfectly-treated element that should not exist still measures clean, and a wrong control cannot
51
- be treated into a right one. Both of these are cheap and both outrank everything below.
50
+ A screen in the wrong FORM measures clean in every direction, a perfectly-treated element that
51
+ should not exist still measures clean, and a wrong control cannot be treated into a right one.
52
+ All three are cheap and all three outrank everything below.
53
+
54
+ ### 0. RIGHT FORM
55
+
56
+ **Name what the subject IS before composing anything.** A set of records is a register. A thing
57
+ moving through ordered places is a rail. Work assigned to finite resources over time is a board.
58
+ A sequence of events is a timeline. A population whose point is its deviant minority is a queue
59
+ sorted by deviation. A quantity across two dimensions is a matrix.
60
+
61
+ This gate exists because **every probe below reads a screen that is already a register of fields**
62
+ and measures its TREATMENT. So a screen that should have been a board or a rail passes the type
63
+ inventory, the ground tally, the gaps, the rules and the alignment — all of them — and still reads
64
+ as templated. No amount of treatment fixes it, and nothing else here can see it.
65
+
66
+ *The tell that it was skipped:* every screen in the product is a register with a summary line over
67
+ it. That is what reuse-first produces when nothing asks the question — the catalog's most reachable
68
+ parts are `Table`, `DetailRow`, `SummaryLine` and `Badge`, so a subject whose natural shape is not
69
+ a table gets flattened into one and no probe fires. **Reuse-first is about not duplicating
70
+ PRIMITIVES, not about the shape of a screen**: a one-off composition needs no new component, so
71
+ "the catalog has no journey rail" is an argument for composing one out of `Stepper`, not for
72
+ rendering a status column and moving on.
73
+
74
+ *And check the form against what the data can FILL.* A rail with no stamped stops, a calendar with
75
+ no durations, a map with no coordinates — each renders an empty device that measures perfectly.
76
+ → probe 8.
52
77
 
53
78
  ### 1. SUBTRACT
54
79
 
@@ -244,6 +269,14 @@ A resting register is the cheapest thing to screenshot and the least likely to h
244
269
 
245
270
  - **Open what the row opens** and measure where the actionable field LANDS. An UNBOUNDED section
246
271
  (a feed, a thread) above a bounded one buries it, and the burial deepens with use.
272
+ - **Name the record before you read it.** Collect the y of each fact that IDENTIFIES what is open
273
+ (its number, its counterparty) and compare against the viewport; any of them first answered
274
+ below the fold is the finding. Then measure the other direction — the top of the block answering
275
+ what the reader OPENED this for, and what sits above it. A leading block that pushes that answer
276
+ under the fold is too LONG, not mis-positioned: moving it down trades a buried ledger for a
277
+ reader who cannot tell whose record they are in, and both orders measure clean on their own, so
278
+ nothing that reads a resting screen catches either. Re-measure at 375, where a block that cleared
279
+ the fold at 1280 buries the answer. → [composition.md](./composition.md) §"Master-detail".
247
280
  - **Press every control** and look at the state you land in. Commonest miss: a value rendering as
248
281
  a coloured chip in a register and as bare grey text in its own EDITOR.
249
282
  - **TAB through it, then diff what a keyboard reaches against what LOOKS pressable.** A pointer