@lotics/ui 45.10.0 → 45.10.1

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.
@@ -12,62 +12,53 @@ them produces off-system UI.
12
12
 
13
13
  **If the domain already has a standard shape, that is the answer — build it.** A parcel's journey
14
14
  has looked the same in every carrier's app for twenty years: an ordered set of stages, where it is
15
- now, when each was stamped, and where it is stuck. A ledger has looked like a ledger since before
16
- software. A calendar, a seating chart, a pipeline, a boarding pass — each is a form the reader has
17
- already learned somewhere else, and matching it means they arrive knowing how to read the screen.
15
+ now, when each was stamped, and where it is stuck. A ledger, a calendar, a seating chart, a
16
+ pipeline, a boarding pass — each is a form the reader has already learned somewhere else, and
17
+ matching it means they arrive knowing how to read the screen.
18
18
  Go and look at how the industry does it before designing something new; a shape that is unfamiliar
19
19
  in a domain that has a familiar one is a cost paid by every user, forever, to no end.
20
20
 
21
21
  That is a licence, not a leash. **The rules in this document constrain treatment, not invention.**
22
22
  Where the job needs a component the kit does not have, build it — the catalog is where solved
23
- problems live, not a ceiling on what a screen may be, and a screen assembled only from the parts
24
- nearest to hand is how everything ends up a register with a header. The bar for a new composition
25
- is that it is the RIGHT shape for the subject and that it obeys the language below: the kit's type
26
- ramp, colours, spacing and states. Reach for the shape the work deserves, then dress it in the
27
- kit's clothes.
23
+ problems live, not a ceiling on what a screen may be. The bar for a new composition is that it is
24
+ the RIGHT shape for the subject and that it obeys the language below: the kit's type ramp, colours,
25
+ spacing and states.
28
26
 
29
27
  **Which rules bind, and which advise.** Read every rule here for what it ENCODES, because the two
30
28
  kinds are answered differently:
31
29
 
32
30
  - **A rule that prevents a known HARM binds.** A page that scrolls sideways on a phone, an act that
33
31
  cannot be undone, a figure that is typed where it should be computed, a machine's guess rendered
34
- like a person's decision, a control a keyboard cannot reach — these are settled, they were paid
35
- for once, and re-opening them just re-buys the defect.
32
+ like a person's decision, a control a keyboard cannot reach — these are settled.
36
33
  - **A rule that picks among valid FORMS advises.** Which shape fits this subject, how a set is cut,
37
34
  what the reader sees first — the space of jobs is unbounded and the best answer is regularly one
38
35
  no document here has named. Treat these as the accumulated experience they are, then decide.
39
36
 
40
- The distinction is not hard-versus-soft, it is harm-versus-choice and the second kind is where
41
- the good work happens. **Most of what reads as inventive is this document's FIRST rule being
42
- followed well, not escaped**: a set whose tabs are the stages its records pass through is not a
43
- departure from "name what the subject IS", it is that rule landing. Naming the subject honestly is
44
- what produces a shape nobody has seen; reaching for the nearest template is what produces the
45
- fiftieth register with a header.
37
+ **Most of what reads as inventive is this document's FIRST rule being followed well, not escaped**:
38
+ a set whose tabs are the stages its records pass through is not a departure from "name what the
39
+ subject IS", it is that rule landing. Naming the subject honestly is what produces a shape nobody
40
+ has seen; reaching for the nearest template is what produces the fiftieth register with a header.
46
41
 
47
42
  **Name what the subject IS, then reach for parts.** A set of records is a register; a thing moving
48
43
  through ordered places is a rail; work assigned to finite resources over time is a board; a
49
44
  sequence of events is a timeline; a population whose point is its deviant minority is a queue
50
45
  sorted by deviation. Getting this wrong cannot be recovered later — a register of objects that
51
- should have been a rail is well-typed, well-spaced, correctly aligned and still says nothing about
52
- order or progress, because a status column reports one word and leaves the reader to know which
53
- word comes after which.
46
+ should have been a rail says nothing about order or progress, because a status column reports one
47
+ word and leaves the reader to know which word comes after which.
54
48
 
55
49
  **A hierarchy in the DATA does not oblige a hierarchy in the UI.** A parent id, a materialized
56
50
  path, a folder column — each is a real structure, and none of them decides the screen's form. Ask
57
51
  what the reader DOES with the set: a population they walk (a chart of accounts, a bill of
58
- materials, a site → line → cell breakdown) is a tree or a drill; one they scan and search is a
59
- register, and the path becomes the row's supporting line rather than a place to travel to. The
52
+ materials) is a tree or a drill; one they scan and search is a register, and the path becomes the
53
+ row's supporting line rather than a place to travel to. The
60
54
  distinction is who maintains the structure. Where something else writes it — an importer, an
61
55
  integration, an agent — a drill-down makes the reader navigate a shape they did not choose and
62
56
  cannot see the whole of, while a flat register with the address on the row costs one line and
63
57
  loses nothing. Build the tree when walking it IS the task, not because the column exists.
64
58
 
65
- The pull toward a register is structural, not a lapse in taste: `Table`, `DetailRow`,
66
- `SummaryLine` and `Badge` are the most reachable things here, and they compose into something
67
- defensible for almost any data. That is exactly why the question has to be asked out loud. **Reuse
68
- first is about not duplicating primitives** — a one-off composition needs no new component, so the
69
- kit lacking a device is a reason to compose one from what it has, not a reason to flatten the
70
- subject into rows.
59
+ **Reuse first is about not duplicating primitives** a one-off composition needs no new component,
60
+ so the kit lacking a device is a reason to compose one from what it has, not a reason to flatten
61
+ the subject into rows.
71
62
 
72
63
  ## Canvas & content column
73
64
 
@@ -78,11 +69,22 @@ subject into rows.
78
69
  the same left edge as the title above it and the actions below it. Never hand-pad inside one,
79
70
  and never hand-roll one: the three surfaces disagree on the number (24 / responsive / 20) and
80
71
  only the surface knows which it is.
72
+ - **The responsive one is READ, never re-derived — `useDialogGutter()`, no argument.** Inside
73
+ a `Dialog` that is what the header, the scroll area, the footer and any pane a caller drops
74
+ in all call. The `Dialog` resolves `small ? 16 : 24` ONCE, off the SCREEN, and publishes it.
75
+ Centralising the VALUE was not enough and that mistake shipped: a `dialogGutter(small)` left
76
+ every band to answer `small` for itself, so the header, scroll area and footer asked
77
+ `useScreenSize()` while the pane inside asked `useContainerSize()` — two honest answers to
78
+ one question, and the run's rows sat out of line with the dialog's own title. A band that
79
+ measures its own box is measuring the wrong box. Outside a dialog the hook falls back to the
80
+ screen, the same signal a dialog uses, so the two paths cannot disagree about what "small"
81
+ means. The `Drawer`'s counterpart is a constant, `DRAWER_GUTTER`, because that surface's
82
+ number does not move.
81
83
  - **Never wrap a scroller in a padded box.** The box insets the scroller's VIEWPORT, so the list
82
84
  ends short of the surface with dead space beneath it and its last row clipped. A pinned
83
- heading above a scrolling list therefore takes the
84
- overlay's BARE slot and puts the gutter on the pinned part and on the scroller's
85
- `contentContainerStyle`. Same for a full-bleed band, which wants no gutter at all.
85
+ heading above a scrolling list therefore takes the overlay's BARE slot and puts the gutter on
86
+ the pinned part and on the scroller's `contentContainerStyle`. Same for a full-bleed band,
87
+ which wants no gutter at all.
86
88
  - **Content column**: one centered column — `{ width: "100%", maxWidth, alignSelf: "center" }` —
87
89
  sized to the job, with `gap: 16` between top-level bands/cards:
88
90
 
@@ -102,17 +104,16 @@ constant rather than happening to match today.
102
104
  The offender is nearly always a row that PADDED for its own hover wash instead of bleeding it
103
105
  (`ROW_WASH_BLEED`), which pushes its content in by the bleed while everything above it stays put.
104
106
  Fix the row, then DELETE the compensators that were added around it — once the row is right they
105
- become double-corrections, and they are the reason the edge drifts back the next time someone
106
- touches it. Two exceptions are legitimate and neither needs a fix: a self-contained overlay (a
107
- menu, a popover) has nothing to align with, and an indent that something VISIBLE occupies — a
107
+ become double-corrections. Two exceptions are legitimate and neither needs a fix: a
108
+ self-contained overlay (a menu, a popover) has nothing to align with, and an indent that something VISIBLE occupies a
108
109
  checkbox, an ordinal, an identity mark — is explained by the thing occupying it.
109
110
 
110
111
  **A row beat belongs to the PAGE, not to the row.** Collect every row primitive's height on one
111
112
  screen before calling any of them right — the kit's own defaults do not agree with each other,
112
- because each was picked for its own surface: `ListItem` is 64, a ruled `Table` row is 52, a
113
- `Button`-height control is 40. Stack three of those in one column and it reads as three rhythms,
114
- each defensible alone. The page picks one beat and the primitives take it (`minHeight` on the
115
- row, `spacing` on the table).
113
+ each picked for its own surface: `ListItem` is 64, a ruled `Table` row is 52, a `Button`-height
114
+ control is 40. Stack three of those in one column and it reads as three rhythms, each defensible
115
+ alone. The page picks one beat and the primitives take it (`minHeight` on the row, `spacing` on
116
+ the table).
116
117
 
117
118
  ## The heading ramp — one construct per altitude, no drift
118
119
 
@@ -132,25 +133,22 @@ restyle a heading level per-page.
132
133
  20px bar — a heading shouting over the pane that holds it. The CONTAINER publishes the altitude
133
134
  and one `SectionHeadingTitle` answers to it: `##` on a page, `####` inside `DrawerScrollArea` /
134
135
  `DialogScrollArea` / `ModalBody` / `PopoverContent`. Write the same heading either way; there is
135
- no per-surface variant to remember, which is the point — a caller who has to know what is holding
136
- their heading will sooner or later be wrong about it.
136
+ no per-surface variant to remember.
137
137
 
138
138
  Three consequences worth knowing before you compose:
139
139
 
140
140
  - **A whole PAGE surface docked in an overlay keeps the page rung.** The master-detail register
141
141
  puts a full record screen in a drawer; that screen brings its own `#` identity band, carries
142
- its own gutters, and therefore sits in the container's BARE slot rather than the scroll area
143
- which is exactly why the altitude is published by the scroll area. Its sections stay `##` and
144
- its subsections stay `###`. Nothing to pass; put an ordinary pane body in the scroll area and
145
- a page surface in the bare slot, as the gutter contract already asks.
142
+ its own gutters, and therefore sits in the container's BARE slot rather than the scroll area.
143
+ Its sections stay `##` and its subsections stay `###`. Nothing to pass; put an ordinary pane
144
+ body in the scroll area and a page surface in the bare slot, as the gutter contract already
145
+ asks.
146
146
  - **A pane you build YOURSELF declares its own altitude.** A docked side panel is not an overlay
147
147
  and uses none of those regions, but its headings must not shout across 400px either — wrap its
148
148
  content in `<HeadingAltitudeContext.Provider value="panel">` (`@lotics/ui/heading_altitude`).
149
- A rule scoped to the containers the kit happens to render is an allowlist short by however many
150
- panes the app builds.
151
149
  - **In a pane, `####` is the FLOOR.** `SubsectionHeadingTitle` has one altitude (`###` lg) and
152
150
  would out-rank the section holding it, so a named group inside a pane's section is
153
- `Text size="sm" weight="semibold"` — a pane is one surface, not a document.
151
+ `Text size="sm" weight="semibold"`.
154
152
 
155
153
  Nesting never compounds: a popover opened inside a drawer is one rung down, not two.
156
154
 
@@ -160,13 +158,11 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
160
158
  hierarchy device colour cannot substitute for. Restoring a `Text size="xxl" weight="semibold"`
161
159
  title takes the same screen to ~2.7x. It also gives the page somewhere to land: without it the
162
160
  first thing on the screen is a search input, which is chrome.
163
- Yes, the title usually repeats the nav. Accept the repetition — a heading that names the screen
164
- costs one line and buys the whole type range, and the alternative (inventing a title the nav
165
- does not say) is worse.
166
- **A second line carries LIVE STATE, never a gloss.** "Track each record's status and print the
167
- right document for its stage" explains the WIDGET, and a band has no use for it. But a
168
- register's counts are not that line either: they are the result of the TOOLBAR, so they belong
169
- under it — see § Summaries.
161
+ Yes, the title usually repeats the nav. Accept the repetition — the alternative (inventing a
162
+ title the nav does not say) is worse.
163
+ **A second line carries LIVE STATE, never a gloss** explaining the WIDGET. A register's counts
164
+ are not that line either: they are the result of the TOOLBAR, so they belong under it — see
165
+ § Summaries.
170
166
  Right side: the screen's ONE primary action and/or a period filter — never a
171
167
  summary `Badge` (those belong to the KPI strip).
172
168
  - **Card header** — a card's own title band (or separate banded cards): `CardHeader` +
@@ -177,10 +173,9 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
177
173
  lowest heading rank — and neither of the two constructs it sits between: `CardHeaderTitle`
178
174
  (`sm`) labels a CONTAINER of data, whereas here the name IS the content; `RecordSummary`'s `#`
179
175
  is a page/drawer rung, and a page rung inside a popover flattens the very altitude distinction
180
- the ramp exists to keep. **The failure it prevents is measurable:** a body-size name leaves the
181
- panel's subject separated from its own supporting line by WEIGHT alone, which is a separate
182
- font file rather than a step, and the whole surface then measures ~1.17x — flatter than a
183
- register with no page band. Its supporting line takes the rung that gives it SIBLINGS (the
176
+ the ramp exists to keep. A body-size name leaves the panel's subject separated from its own
177
+ supporting line by WEIGHT alone, which is a separate font file rather than a step, and the whole
178
+ surface then measures ~1.17x. Its supporting line takes the rung that gives it SIBLINGS (the
184
179
  other muted lines in the same block), which is what "one rung below" is serving; applied
185
180
  literally against that it produces a singleton treatment instead.
186
181
  - **Section title** — ONE construct: `Section` › `SectionHeading` › `SectionHeadingTitle`
@@ -190,14 +185,12 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
190
185
  below. **The row is `flexDirection: "row"` and never wraps**, so a long control label doesn't
191
186
  drop to its own line at phone width — it holds its own width and the title's `flex: 1` gives
192
187
  up whatever is left, taking the `description` down with it. A label that repeats the heading
193
- verbatim is the usual way this gets long enough to bite: duplicating the title onto the button
194
- squeezes both title and description into a narrow ribbon wrapping
195
- one or two words per line for eleven lines total. The button-label law already asks for
196
- 2–4 words; a heading-row control is the case where skipping it has a measurable width cost,
197
- not just a wordier button. A COUNT earns that slot only when what it counts is not fully on screen — collapsed,
188
+ verbatim is the usual way this gets long enough to bite. The button-label law already asks for
189
+ 2–4 words; a heading-row control is the case where skipping it has a measurable width cost.
190
+ A COUNT earns that slot only when what it counts is not fully on screen — collapsed,
198
191
  paged, or long enough that the reader would have to scan to know. Over a list or thread
199
- rendered whole directly beneath its own heading it restates what is already in view, and the
200
- heading reads busier for it. The page column is a **`SectionStack`** — it owns the
192
+ rendered whole directly beneath its own heading it restates what is already in view. The page
193
+ column is a **`SectionStack`** — it owns the
201
194
  between-section law (a fixed 56px beat + a bare hairline separating one section from the NEXT;
202
195
  null/false children are skipped so a conditional section never leaves a stray hairline). The
203
196
  `Divider` NEVER goes directly under a heading — that orphans the title from its own content.
@@ -207,10 +200,8 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
207
200
  sm-semibold lead lines on a flat page, and NEVER use a bare eyebrow as a section title.
208
201
  - **Pane section title** — the FOURTH rung, taken automatically by `SectionHeadingTitle`
209
202
  inside a pane's content region. Same construct, same `icon`/`description`/`info` slots;
210
- only the type size steps down, and nothing at the call site says so. It exists because
211
- `DialogHeaderTitle` and a `Drawer`'s title are **lg** the rung `SubsectionHeadingTitle`
212
- occupies — so a panel holding a multi-section surface otherwise ties with, or is outranked by,
213
- its own content. Compose it with the SUBSECTION layout (`Subsection` › `SubsectionHeading` ›
203
+ only the type size steps down, and nothing at the call site says so.
204
+ Compose it with the SUBSECTION layout (`Subsection` `SubsectionHeading`
214
205
  this, in a `SubsectionStack`): the 12px body gap and 32px beat are already right for a panel.
215
206
  The ladder stays ABSOLUTE — this is one more fixed rung, never a `size`/`compact` knob.
216
207
  - **Subsection title** — the level BELOW a section on a long record surface: `Subsection` ›
@@ -231,11 +222,8 @@ Nesting never compounds: a popover opened inside a drawer is one rung down, not
231
222
  to its own section-level heading just to separate it.
232
223
  - **Eyebrow vs group lead — a label is one or the other, and the difference is what it
233
224
  LABELS.** Each is a COMPONENT — `@lotics/ui/eyebrow` and `@lotics/ui/group_lead` — so the call
234
- site DECLARES which one it is rather than re-deriving a rung from feel, which is how a title
235
- ends up whispering. They were hand-written `Text` and this section told you to keep it that
236
- way; a shipped app then labelled three act-on-it blocks at the eyebrow's `xs` muted, which is
237
- the failure the prose could not prevent. Neither takes a `size`, `weight` or `transform`: the
238
- props a component omits are the rule it enforces.
225
+ site DECLARES which one it is rather than re-deriving a rung from feel. Neither takes a `size`,
226
+ `weight` or `transform`: the props a component omits are the rule it enforces.
239
227
 
240
228
  | | Eyebrow | Group lead |
241
229
  |---|---|---|
@@ -273,11 +261,9 @@ altitude (`SubsectionHeading`, `CardHeader`).
273
261
 
274
262
  The rule is about a position that does not MOVE. Put the add under the register it extends and
275
263
  where the reader looks for it depends on how many rows there already are — past a screenful the
276
- verb is off-screen, and on an empty list there is no last row to sit under, so it has to become
277
- a second button inside the `EmptyState`. That is one verb with two homes, neither findable
278
- without scanning. The heading row is the section's control line it already carries the meta
279
- and the view controls — so the add belongs on it, in the same spot whether the section holds
280
- nought or forty.
264
+ verb is off-screen, and on an empty list there is no last row to sit under. The heading row is the
265
+ section's control line it already carries the meta and the view controls so the add belongs on
266
+ it, in the same spot whether the section holds nought or forty.
281
267
 
282
268
  **When the row runs out of width, the VERB wraps — the title never gives way.** The title names
283
269
  the section; the verb is the thing that can move. Getting this backwards is easy to ship because
@@ -285,12 +271,10 @@ RN-Web resolves `flex: 1` to `flex-basis: 0%` with `min-width: 0`, so a title be
285
271
  holds its intrinsic width is the half that collapses, and at phone width the heading breaks one
286
272
  word per line beside an untouched button. `SectionHeading` and `SubsectionHeading` wrap and floor
287
273
  their title column for this; do not add a second `flex: 1` spacer beside a title that already has
288
- one, which halves its share again and is how a badge came to land on top of a section title.
274
+ one, which halves its share again.
289
275
 
290
276
  **Where there IS no heading row, the add has nowhere else to be, so it rides the surface's own
291
- bar** — an attachment popover, a dialog body, any self-contained widget. That is not an exception
292
- to the law so much as the same reasoning: the rule exists so the verb sits in ONE place a reader
293
- can return to, and on a surface with no heading the bar IS that place. `FilesEditorUpload` is for
277
+ bar** — an attachment popover, a dialog body, any self-contained widget. `FilesEditorUpload` is for
294
278
  exactly this case; inside a section the host puts its own `Button` on the heading instead, because
295
279
  `FilesEditor` provides the context and the heading is outside it — and a file widget rendering a
296
280
  `SectionHeading` would own something the section owns. Worked both ways in `tpl_record` (Files,
@@ -298,19 +282,15 @@ Photos) and `tpl_task_board` (the attachment popover).
298
282
 
299
283
  **A header CHECKBOX is legible only when it heads a COLUMN.** A table's select-all works because
300
284
  it sits at the same x as every row's box beneath it — the column is what turns a 24px tick into
301
- "all of these". A grid has no such column, so the same control in a section heading heads
302
- nothing: it is a transparent 24×24 at radius 6 standing between 40px grounded buttons at radius
303
- 10, off their baseline, differing on every axis at once, and shape is read before meaning. **A
285
+ "all of these". A grid has no such column, so the same control in a section heading heads nothing. **A
304
286
  grid's select-all is a BUTTON** ("Select all" / "Deselect all"), which is what
305
287
  `FilesEditorSelectAll` ships. Nothing is lost by giving up the tri-state: `indeterminate` says
306
- "some", and the `FloatingActionBar` beside it already says WHICH some as an exact count — the
307
- glyph was approximating a number that was on screen.
288
+ "some", and the `FloatingActionBar` beside it already says WHICH some as an exact count.
308
289
 
309
290
  While a mode is ON, the heading row holds only that mode's controls; the section's own ADD stands
310
291
  down until it is off. Otherwise a `primary` add sits in a row that is entirely about the
311
292
  selection, competing with the promoted act in the action bar — two accents on one screen. At rest
312
- the add returns to the right edge where the law puts it, and drag/paste never stopped working, so
313
- no path actually closes.
293
+ the add returns to the right edge where the law puts it, and drag/paste keep working throughout.
314
294
 
315
295
  - **Weight is the section's**, not a hedge: a section's add is the act that section offers, so
316
296
  `primary`. Don't drop to `secondary` because the list is full — an add that changes weight
@@ -329,9 +309,9 @@ screen already computes, and it is the copy that goes stale — the band re-coun
329
309
  and the string does not.
330
310
 
331
311
  It also decides where the count may live. A register whose bands already state their sizes must
332
- not carry a strip of chips above it repeating the same three numbers; that is one fact twice, and
333
- the reader learns to skip the strip. And a count is a reason to PRESS — a control that states how
334
- many rows are behind it and does not filter to them is stating a fact it refuses to act on.
312
+ not carry a strip of chips above it repeating the same three numbers; that is one fact twice. And
313
+ a count is a reason to PRESS — a control that states how many rows are behind it and does not
314
+ filter to them is stating a fact it refuses to act on.
335
315
 
336
316
  ## Period filters for time-constrained data
337
317
 
@@ -385,17 +365,16 @@ by WHAT THE SURFACE IS; never a raw `Pressable`.**
385
365
 
386
366
  | Surface | Examples | Resting signature | Hover | Base |
387
367
  |---|---|---|---|---|
388
- | Fields (type into / pick from) | `TextInputField`, `Picker`, `Select`, `Combobox`'s input, `NumberInput`, the date/time fields, `DateRangeFilterField`, the inline editors (`InlineSelect`/`InlineDatePicker`) | a BORDER | darkens the border to `HOVER_BORDER` (zinc-400); a borderless in-cell field REVEALS one | `FocusRingPressable` |
368
+ | Fields (type into / pick from) | `TextInputField`, `Picker`, `Select`, `Combobox`'s input, `NumberInput`, the date/time fields, `DateRangeFilterField`, the inline editors (`InlineSelect`/`InlineDatePicker`) | a BORDER | darkens the border to `HOVER_BORDER` (zinc-500); a borderless in-cell field REVEALS one | `FocusRingPressable` |
389
369
  | Actions | `Button`, `IconButton` | a FILL | darkens the fill (own color logic) — never a border, never a wash | built-in |
390
370
  | Pills / toggles | `Chip`/`ChipGroup`/`FilterChip` (via `chipSurfaceStyle`), `SegmentedControl`, `Tabs`, `Switcher`, `RadioPicker` | a pill SURFACE | washes it (white → zinc-100; pressed zinc-200); selected adds a ring/fill | `chipSurfaceStyle` |
391
371
  | Surfaces | `PressableRow`, `MenuButton`, list/menu items, `CardSelectItem`, `Accordion` headers | a row/card SURFACE | washes it — the grey wash IS the affordance | `PressableHighlight` |
392
372
 
393
373
  **ONE signal per species, and it has to be strong enough to carry alone.** `HOVER_BORDER` is
394
- zinc-500, not 400, because at 400 the shift was zinc-200 → zinc-400 on a 1px line — measurably a
395
- change and visually almost nothing, so the inline editors had grown a background TINT on top to
396
- compensate. That left two hover languages in one kit, and a reader crossing a record surface met
397
- both. Strengthen the shared signal rather than letting one control add a second; a control wearing
398
- two is the tell that the first one is too weak for everybody.
374
+ zinc-500, not 400: at 400 the shift is zinc-200 → zinc-400 on a 1px line — measurably a change and
375
+ visually almost nothing, so a control grows a background TINT on top to compensate and the kit ends
376
+ up with two hover languages. Strengthen the shared signal rather than letting one control add a
377
+ second.
399
378
 
400
379
  **A themeable control derives EVERY state from its token — rest, hover, press, disabled.** The
401
380
  failure is a control that resolves `colors.primary` correctly at rest and then paints a fixed
@@ -403,39 +382,35 @@ neighbouring zinc the moment it is touched, so a branded button turns grey under
403
382
  for an OPAQUE overlay especially: a `backgroundImage` gradient drawn over a themed
404
383
  `backgroundColor` hides it entirely while every token in the source reads correct. On web a token
405
384
  is a `var()`, so "the same colour, lighter" cannot be computed at the call site — use a translucent
406
- white/black overlay, which lifts any hue and cannot go stale against a palette it does not know.
385
+ white/black overlay.
407
386
 
408
387
  **A control changes PAINT across its states and never GEOMETRY** — and every property it paints
409
388
  has exactly ONE owner. Text that shifts on focus, a border that thickens, a ring drawn twice, a
410
389
  tint applied on one path only: each is two pieces of code owning one property and happening to
411
- agree at rest. It survives review because a resting screen is what gets looked at, and the second
412
- owner only speaks when the control is touched. The fix is to give the property one owner a
413
- shared surface helper, a single frame kept across the view→edit swap — never to nudge the second
414
- owner into agreeing with the first, which leaves both in place for the next person to re-break.
390
+ agree at rest. The fix is to give the property one owner a shared surface helper, a single frame
391
+ kept across the view→edit swap never to nudge the second owner into agreeing with the first.
415
392
 
416
393
  **A control riding a hoverable surface must be DARKER than that surface** — and you have to hover
417
394
  BOTH to see whether it is, because reaching the control means crossing its host. Equal washes
418
395
  vanish at exactly the moment they are pointed at; a control LIGHTER than its host reads as a hole
419
- punched in the row rather than as a target sitting on it. Neither failure is visible at rest, and
420
- neither is visible hovering the control alone, which is why a control that measures perfectly can
421
- still disappear in use. Take `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS` (`control_surface`), which are a step past the
422
- row's own wash for exactly this reason; a hand-picked grey chosen against the resting row is the
423
- finding however plausible the value looks.
396
+ punched in the row rather than as a target sitting on it. Neither failure is visible at rest, nor
397
+ hovering the control alone. Take `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS` (`control_surface`),
398
+ which are a step past the row's own wash for exactly this reason; a hand-picked grey chosen against
399
+ the resting row is the finding.
424
400
 
425
- **A SELECTED control needs a GROUND, not a heavier outline.** Among white pills a 1px border change
426
- is noise it is the same signal hover already spends, one step stronger, so selection and hover
427
- compete instead of reading as different things. Selection changes what the box IS (a fill, a wash);
428
- hover changes how intensely it says it.
401
+ **A SELECTED control needs a DOUBLED dark EDGE, not a one-pixel border change.** Among white pills
402
+ a 1px change is the same signal hover already spends, one step stronger, so selection and hover
403
+ compete; `chipSurfaceStyle` doubles `zinc-900` with a `boxShadow` instead, which survives the hover
404
+ wash rather than being replaced by it. A ROW is the other altitude and takes a GROUND — see
405
+ § Where the accent goes.
429
406
 
430
407
  For fields: layer the hover-border AFTER `style` so it wins over the caller's resting edge, put the
431
408
  open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. That ordering
432
- is load-bearing rather than tidy: a caller's resting `borderColor` is exactly how a frameless
433
- variant gets expressed, and layered the other way it erases the hover edge that is such a field's
434
- ONLY affordance — a control that then draws nothing in either state and is simply never pressed.
409
+ is load-bearing: a caller's resting `borderColor` is exactly how a frameless variant gets expressed,
410
+ and layered the other way it erases the hover edge that is such a field's ONLY affordance.
435
411
  Give the field a variant PROP for its resting frame instead of taking one through `style`. The bug this
436
412
  grammar prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border — it
437
- greys *and* animates its edge while its siblings only border-hover. Reach for `PressableHighlight`
438
- on a bordered/input control and you've made it.
413
+ greys *and* animates its edge while its siblings only border-hover.
439
414
 
440
415
  ## Cards — banded and composable
441
416
 
@@ -478,12 +453,11 @@ objects — hairlines between them blur into one run-on list.
478
453
 
479
454
  **A summary goes BELOW the controls that produce it.** Its figures are the result of the toolbar —
480
455
  they move every time the reader touches a filter — so the layout has to state that order: set the
481
- query, then read what it returned. Above the toolbar the screen answers before it has been asked,
482
- and the numbers change behind the reader's eye while they are still choosing.
456
+ query, then read what it returned.
483
457
 
484
- It is not the page band's subtitle either, for the same reason. A subtitle describes the SCREEN,
485
- which does not change; these describe the QUERY, which the toolbar just rewrote. The band carries
486
- the title, and the summary sits under the controls, immediately above the rows it counts.
458
+ It is not the page band's subtitle either. A subtitle describes the SCREEN, which does not change;
459
+ these describe the QUERY, which the toolbar just rewrote. The band carries the title, and the
460
+ summary sits under the controls, immediately above the rows it counts.
487
461
 
488
462
  `SummaryLine` is `Summary`'s `Facts` row on its own — reach for the compound when the set has a
489
463
  distribution worth drawing, and for the bare line when it is only a few aggregates. A
@@ -557,53 +531,42 @@ SECTION** is this same family with the heading rung composed inside it — `Sect
557
531
  collapsible section composes identically on both surfaces. There is no separate collapsible-section
558
532
  component: one heading family and one disclosure across static + collapsible. The disclosure
559
533
  header IS the section heading — name it for what it hides, never a generic bucket ("Advanced",
560
- "More", "Options") wrapped around one named section: that stacks two titles of the same rank and
561
- leaves the reader no reason to open the chevron.
534
+ "More", "Options") wrapped around one named section.
562
535
 
563
536
  ### A control's resting treatment promises what pressing it does
564
537
 
565
- Both halves of that fail in practice, and they fail in opposite directions.
566
-
567
538
  **A control that looks like text.** A press target whose resting state is a muted caption is
568
- announced to a screen reader, revealed on hover, and invisible to everyone else including the
569
- reader who navigates by eye, which is most of them. Hover is not an affordance; it is a
570
- confirmation of one. If pressing it navigates, it wears navigation ink at rest: default colour,
571
- `medium`, underlined.
572
-
573
- **Text that looks like a control.** The mirror mistake, and the more annoying one, because the
574
- reader presses and nothing happens or something happens that was not what the ink promised. A
575
- figure that opens a PEEK is not navigating and must not wear the ink of something that leaves;
576
- identity marks and captions that go nowhere stay `muted`, regular, unruled.
577
-
578
- So the two states of one element are treated DIFFERENTLY on purpose, and the difference is the
579
- whole point. Rendering them the same is not neutrality — it is the promise being made to nobody, or
580
- to everybody, and both are wrong. When a component takes an optional handler, its resting
581
- appearance changes with the handler, never only its `accessibilityRole`.
582
-
583
- The failure is invisible to a test that asserts the role, which is why it survives: the role is
539
+ announced to a screen reader, revealed on hover, and invisible to everyone else. Hover is not an
540
+ affordance; it is a confirmation of one. If pressing it navigates, it wears navigation ink at rest:
541
+ default colour, `medium`, underlined.
542
+
543
+ **Text that looks like a control.** The mirror mistake: the reader presses and nothing happens — or
544
+ something happens that was not what the ink promised. A figure that opens a PEEK is not navigating
545
+ and must not wear the ink of something that leaves; identity marks and captions that go nowhere
546
+ stay `muted`, regular, unruled.
547
+
548
+ When a component takes an optional handler, its resting appearance changes with the handler, never
549
+ only its `accessibilityRole`. The failure is invisible to a test that asserts the role: the role is
584
550
  what the element IS, and this rule is about what it LOOKS LIKE.
585
551
 
586
552
  ## No dead rows — registers & tables
587
553
 
588
554
  **The identifier leads.** A register's FIRST column is the thing the reader came to find the row
589
555
  by — its key, code, or id — because that is what they are scanning against a document, an email,
590
- or someone on the phone. Pushing it right of a name or a status makes every lookup a hunt across
591
- the row, and no other column can take its place: a name repeats, a status is shared by dozens.
556
+ or someone on the phone. No other column can take its place: a name repeats, a status is shared by
557
+ dozens.
592
558
 
593
559
  Every listed record is actionable. PRIMARY entity rows press-open the workspace `Drawer`
594
560
  (sequenced — see [master-detail](#master-detail--list--workspace-drawer-with-sequencing));
595
561
  read-only drill-downs expand via `Accordion` or glance via `Peek`; every other row gets an
596
562
  `ActionMenu` (⋯ → `MenuButton` items: destructive last + **confirmed** via `Alert`). The whole
597
563
  surface is the door: register rows are `PressableRow` (full-bleed wash including nested controls),
598
- Divider-separated.
564
+ separated by a 4px gap and nothing else.
599
565
 
600
566
  **How MUCH the row reveals decides expand-vs-drawer.** An expanding row is for a few facts the
601
- reader takes in without losing their place — the rows around it stay visible, which is the whole
602
- point. Once the reveal is a screenful, it pushes the rest of the register off the fold and the
603
- reader is now reading a record through a slot cut in a list: they cannot see the set they came for,
604
- and they have to remember to collapse it. That is a `Drawer`. The threshold is not a row count, it
605
- is whether the neighbouring rows survive the expansion; if opening one row means the list is gone,
606
- open it properly.
567
+ reader takes in without losing their place — the rows around it stay visible. Once the reveal is a
568
+ screenful, it pushes the rest of the register off the fold. That is a `Drawer`. The threshold is
569
+ not a row count, it is whether the neighbouring rows survive the expansion.
607
570
 
608
571
  | `PressableRow` variant | Shape | Use |
609
572
  |---|---|---|
@@ -624,36 +587,33 @@ STATE (the count and Clear — what is picked, and how to un-pick it) from the A
624
587
  (`children`) with the section boundary getting the most air (20) and each group sitting on the
625
588
  action gap (8). Spaced uniformly, four controls read as four equal things, and the emphasis
626
589
  ladder between them — muted for a dismiss, `danger` for a destructive act, `primary` for the
627
- promoted one — stops reading as a ladder and starts reading as arbitrary variety. The colours
628
- are not the defect when this happens; the spacing is. Group first, and the ladder is then read
629
- WITHIN the acts, where it means something.
590
+ promoted one — stops reading as a ladder. The colours are not the defect when this happens; the
591
+ spacing is. Group first, and the ladder is then read WITHIN the acts, where it means something.
630
592
 
631
- The grouping pays a second time at PHONE width, which is why it is structure and not styling. A
632
- count plus an escape plus three verbs is ~520px of content — wider than a 375 screen — so the
633
- bar wraps; because `state` and `acts` are each a nowrap row, the ONLY break available is the
634
- boundary between them. The line splits exactly where the meaning does (what is picked / what to
635
- do with it) instead of orphaning one verb from the ladder it belongs to. A row of five peers has
636
- no such seam and breaks wherever it runs out of room.
593
+ The grouping pays a second time at PHONE width. A count plus an escape plus three verbs is ~520px
594
+ of content — wider than a 375 screen — so the bar wraps; because `state` and `acts` are each a
595
+ nowrap row, the ONLY break available is the boundary between them, so the line splits exactly
596
+ where the meaning does instead of orphaning one verb from the ladder it belongs to.
637
597
 
638
598
  **Two exceptions, both real.** A self-contained OVERLAY — a menu, a selector's popover
639
599
  (`MenuListItem`, `OptionList`) — has nothing outside itself to align with, so its rows keep
640
600
  plain padding; bleeding there would push them past the popover's own edge. And a slot that
641
- holds another component's PART takes that component's constant, never a hand-picked number:
642
- `AgentRun`'s dot column was 18 while the `Marker` it holds is `NODE` (24), so a 24px dot
643
- centred in an 18px column put the whole run rail 3px outside the surface's edge — every row
644
- wrong by the same amount, so it read as internally consistent and nothing flagged it.
601
+ holds another component's PART takes that component's constant, never a hand-picked number.
645
602
 
646
603
  **An indent is legible only when something VISIBLE occupies it**
647
604
  — a checkbox, an ordinal, a diff mark. An empty one is a defect, and the
648
605
  compensation it forces spreads to every new surface that sits beside it.
649
606
 
650
- The `Divider` BETWEEN rows is the resting separation; the wash is the hover state. `selected`
651
- shows a zinc-100 highlight; `marked` (a bulk-ticked row) a resting blue-50 tint.
607
+ NOTHING is drawn between rows — a `Table` spaces them by 4px, and the wash is the only mark a
608
+ row makes. `selected` shows a zinc-100 highlight; `marked` (a bulk-ticked row) shows the SAME
609
+ ground, because what tells a ticked row from an open one is the ticked checkbox.
652
610
 
653
611
  **The COLUMNAR register is `Table`/`TableRow`/`TableCell`**: define `columns` once — the header
654
- band + every row's cell widths derive from it and can't drift. ONE hairline under the header; the
655
- rows below it Divider-separated. A sortable column shows its `SortHeader` glyph ALWAYS (a neutral
656
- up/down glyph when unsorted) so it reads as sortable; pressing cycles none asc desc → none.
612
+ band + every row's cell widths derive from it and can't drift. ONE hairline, under the header
613
+ it divides CHROME from CONTENT, which whitespace cannot say; a rule between two ROWS would divide
614
+ like from like, so the rows below carry none. A sortable column shows its `SortHeader` glyph
615
+ ALWAYS (a neutral up/down glyph when unsorted) so it reads as sortable; pressing cycles none →
616
+ asc → desc → none.
657
617
  A `TableRow` with no `onPress` renders a STATIC read-only row (no hover wash) —
658
618
  so `Table` also serves read-only tabular data (a fee breakdown, a spec sheet), not just interactive
659
619
  registers. A pressable `TableRow` REQUIRES `accessibilityLabel` ("Open …") — its keyboard door is
@@ -666,12 +626,11 @@ Uniform ROW heights hide it completely — the rows measure identical, the conte
666
626
  not — and the drift is against the row's own centred chrome (ordinal, checkbox) as much as
667
627
  against the neighbouring columns.
668
628
 
669
- **It takes a height FLOOR to bite**, which is the part worth understanding rather than
670
- memorising. A `TableRow` with `minHeight` above its tallest cell holds slack that centring then
671
- distributes; without the floor the tallest cell sets the row, every cell starts at the top of its
672
- own row, and nothing drifts. So the cell that IS tallest may vary freely it just makes the rows
673
- ragged instead, which is a different and far more visible problem. It is the cells living inside
674
- another cell's slack that have to hold still.
629
+ **It takes a height FLOOR to bite.** A `TableRow` with `minHeight` above its tallest cell holds
630
+ slack that centring then distributes; without the floor the tallest cell sets the row, every cell
631
+ starts at the top of its own row, and nothing drifts. So the cell that IS tallest may vary freely —
632
+ it just makes the rows ragged instead. It is the cells living inside another cell's slack that have
633
+ to hold still.
675
634
 
676
635
  **"Fixed" means the same every row, NOT one line.** A two-line cell is fine if it is two lines on
677
636
  every row. And the cost is real: bounding a cell usually means `numberOfLines={1}` and
@@ -683,27 +642,24 @@ content off the register entirely.
683
642
  ornament sits beside a text block and centres on it — a `Timeline`'s disc, a marker, an avatar,
684
643
  a rail glyph — the ornament's position is a function of the text's height, so an unbounded text
685
644
  block moves it. A feed is where this actually bites, because a feed is the one row primitive
686
- whose text is written by the USER. Bound the text; the row keeps
687
- its beat and the ornament keeps its line. Where the full value still has to be readable, the row
688
- EXPANDS to it — a truncation and its source, not a second copy.
645
+ whose text is written by the USER. Bound the text. Where the full value still has to be readable,
646
+ the row EXPANDS to it a truncation and its source, not a second copy.
689
647
 
690
648
  **Bounding the text is half the fix; the other half is aligning to the LINE, not the block.** A
691
649
  clamp turns an 80px drift into a 10px one — it does not remove it, because a two-line label
692
650
  still centres its ornaments half a line low while the one-line rows beside it do not. So pin
693
651
  every ornament in the band to the first line's centre, from ONE constant the row derives (see
694
652
  `FIRST_LINE_CENTRE` in `timeline.tsx`, the reference): the mark, the affordance, and whatever a
695
- caller passes as trailing content all read it, and a wrapped row lines up with an unwrapped one.
696
- Two consequences worth stating, because both look like unrelated tidying: a height FLOOR above
697
- the content is what created the slack being distributed, so deriving the touch target from
698
- `padding + line` instead removes the drift and the second source of truth in one move; and an
699
- ornament OUTSIDE the row's padding needs its own box, since it does not inherit that offset.
653
+ caller passes as trailing content all read it. Two consequences: a height FLOOR above the content
654
+ is what created the slack being distributed, so deriving the touch target from `padding + line`
655
+ instead removes the drift and the second source of truth in one move; and an ornament OUTSIDE the
656
+ row's padding needs its own box, since it does not inherit that offset.
700
657
 
701
658
  The way it gets in is a SLOT. A component that takes a caller's string or node cannot assume
702
- anything about it, and every fixture the component was built against is tidy, so the defect is
703
- invisible until it ships. Bound the slot in the component — not in a prop the next caller can
704
- re-open.
659
+ anything about it, and every fixture it was built against is tidy, so the defect is invisible until
660
+ it ships. Bound the slot in the component — not in a prop the next caller can re-open.
705
661
 
706
- Two corollaries, both counter-intuitive enough to state:
662
+ Two corollaries:
707
663
 
708
664
  - **Shrinking a NEIGHBOURING cell does not fix it.** The spread is a function of the varying
709
665
  cell's own range, not of the row's height: a 20/42 cell spreads 11px in a 52px row and 11px in
@@ -721,22 +677,16 @@ freely when something FIXED occupies its top — a meter, a thumbnail — becaus
721
677
  then still lands near the row's centre, where the chrome already is. `tpl_item_list`'s identity
722
678
  cell is the worked example: name on top, the supporting values UNCONDITIONALLY beneath.
723
679
 
724
- **A pressable CELL wears `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS`, never a hand-picked grey.**
725
- Reaching a cell means crossing its row, so both wash at once — a cell painting the row's own
726
- `zinc-100` vanishes under the pointer, and anything LIGHTER reads as a hole punched in the row.
727
- The tokens are one step darker for exactly that reason, and they are what `IconButton`,
728
- `CopyButton` and `CommentsButton` already paint, so a register whose cells use them speaks one
729
- hover language. Bleed the wash outward (`marginHorizontal: -8` against its own padding) so the
730
- cell's content stays on the column's edge — see `ROW_WASH_BLEED`.
680
+ **A pressable CELL wears `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS`, never a hand-picked grey** —
681
+ they are what `IconButton`, `CopyButton` and `CommentsButton` already paint, so a register whose
682
+ cells use them speaks one hover language. Bleed the wash outward (`marginHorizontal: -8` against
683
+ its own padding) so the cell's content stays on the column's edge see `ROW_WASH_BLEED`.
731
684
 
732
685
  **A pressable cell's target is the whole CELL, not the text inside it.** Bind the press and the
733
686
  hover to the cell box, so the reader aims at a column rather than at a word. Wrapping only the name
734
- leaves a target whose shape nobody can predict — it changes with the length of the value, it is a
735
- sliver on a short one, and the wash it lights stops short of the cell's edges so the highlight
736
- appears to bleed into the neighbouring thumbnail or supporting line rather than belonging to
737
- anything. If a cell holds an image and a name and pressing it opens the same thing, the image is
738
- part of the target too; a cell where only some of the content responds is telling the reader there
739
- are two things there when there is one.
687
+ leaves a target whose shape changes with the length of the value, and whose wash stops short of the
688
+ cell's edges. If a cell holds an image and a name and pressing it opens the same thing, the image is
689
+ part of the target too.
740
690
 
741
691
  **Size a flexible column by what it CARRIES, measured.** A `flex` share is a claim about content;
742
692
  when the content shrinks — a value moves to the record, a stacked pair becomes a count — the
@@ -747,20 +697,17 @@ instrument once its track is long enough to be a rule across the row.
747
697
 
748
698
  **The FIXED columns' widths are the flexible one's budget — so audit them by what they CARRY,
749
699
  not by what they are called.** With one `flex` column among N fixed, every pixel a fixed column
750
- takes comes out of the flexible one, silently and with no measurement pointing at the culprit:
751
- the symptom appears somewhere else entirely, as truncated content in the column that was
752
- supposed to have room. The usual offender is a column sized by its HEADER over single-character
753
- data a long label above a column of one-digit counts reserves width for a word nobody reads
754
- twice, and the register's identity column pays for it. Measure the header row's cells and the
755
- widest CELL content per column; where a column's width is set by its label rather than its
756
- values, that difference is the identity column's, not its own.
700
+ takes comes out of the flexible one, silently: the symptom appears somewhere else entirely, as
701
+ truncated content in the column that was supposed to have room. The usual offender is a column
702
+ sized by its HEADER over single-character data. Measure the header row's cells and the widest CELL
703
+ content per column; where a column's width is set by its label rather than its values, that
704
+ difference is the identity column's, not its own.
757
705
 
758
706
  **Space by the RATIO of within-group to between-group, never by absolutes.** Collect the gap
759
707
  inside a composed band and the gap between that band and its neighbours, and divide. Near 1 is
760
- the defect however sensible each number is alone at 14 inside against 16 between, a headline,
761
- a chart and a register read as one undifferentiated stack, because nothing on the screen is
762
- grouped. Aim for roughly 2 or more, and change the BETWEEN gap first: tightening the inside
763
- crowds the group to buy separation the page should have provided.
708
+ the defect however sensible each number is alone. Aim for roughly 2 or more, and change the
709
+ BETWEEN gap first: tightening the inside crowds the group to buy separation the page should have
710
+ provided.
764
711
 
765
712
  Make a register SELECTABLE with the `Table` `leading` gutter + `selectAll` slot — a
766
713
  `CheckboxInput` per `TableRow` (its `leading` slot) + a select-all in the header band, the ticked
@@ -772,29 +719,25 @@ only the selectable ids to `setAll`/`allSelected`/`indeterminate` (a non-selecta
772
719
  disabled checkbox, keeping the gutter aligned). Selection persists across pages, so `count` is the
773
720
  running total. `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagination`).
774
721
 
775
- **A paginated register owes the reader a scroll reset, and it is not the pager's to give.** Putting
776
- the pager under the rows is inherited from full page loads, where the browser reset the scroll for
777
- free; an SPA swaps the rows under a viewport that does not move, so paging from the bottom lands on
778
- the LAST rows of the next page with its first ones already scrolled past. Every control that changes
779
- WHICH rows are on screen owes the same reset page, tab, search, sort, each filter — so route them
780
- all through ONE function that moves the window and scrolls (`goToPage` / `resetPaging` in
781
- `tpl_item_list`), never a `setPage(0)` at each call site that also has to remember to scroll.
722
+ **A paginated register owes the reader a scroll reset, and it is not the pager's to give.** An SPA
723
+ swaps the rows under a viewport that does not move, so paging from the bottom lands on the LAST rows
724
+ of the next page with its first ones already scrolled past. Every control that changes WHICH rows
725
+ are on screen owes the same reset — page, tab, search, sort, each filter so route them all through
726
+ ONE function that moves the window and scrolls (`goToPage` / `resetPaging` in `tpl_item_list`),
727
+ never a `setPage(0)` at each call site that also has to remember to scroll.
782
728
 
783
729
  **ONE pager, ABOVE the rows.** Once the reset returns the viewport to the top on every press, the
784
730
  top is where the reader's cursor already is. A second copy under the rows is the same state said
785
- twice, and two identical clusters of arrows read as two controls.
731
+ twice.
786
732
 
787
733
  **Put it on the `SummaryLine` row, not a row of its own** — summary at `flex: 1`, pager sized to its
788
734
  content, the row aligned `flex-start` so the arrows stay on the summary's FIRST line when it wraps.
789
735
  Both describe the window the toolbar just set (what matched, and which slice is on screen), so they
790
- are one line. A pager given its own row is a small cluster against a mostly empty band — measure it
791
- before believing the row is needed; a four-item summary and a full pager leave a quarter of a
792
- 1000px band spare.
736
+ are one line. A pager given its own row is a small cluster against a mostly empty band.
793
737
 
794
738
  Never hand `Pagination` the full width with an empty left slot. It gives its range `flex: 1` so a
795
739
  band whose left slot HOLDS something pushes the arrows to the far edge; with nothing there, that
796
- same rule strands the range hundreds of pixels from the arrows it belongs to — two halves of one
797
- control reading as two unrelated things on a line.
740
+ same rule strands the range hundreds of pixels from the arrows it belongs to.
798
741
 
799
742
  **The pager's range IS the register's count** (`16–30 of 45`), so pair it with `Table` `counted`,
800
743
  never `count` — the latter prints the matched total again a few rows below, and one number in two
@@ -803,8 +746,7 @@ places is two numbers that can disagree. Row totals beside the pager go the same
803
746
 
804
747
  There is deliberately no page index. `Page 2 of 3` beside `16–30 of 45` answers the same question a
805
748
  second way and enables no decision the range does not — how much is left is in the total, whether
806
- this is the end is in the disabled arrow. A control that is reached for rarely (a register built
807
- around search and filters is meant to be NARROWED, not paged) earns one label, not two.
749
+ this is the end is in the disabled arrow.
808
750
 
809
751
  Right-hand columns align only if every trailing element is FIXED-width — give each trailing action
810
752
  a fixed `width`, so amount/status columns don't jitter.
@@ -832,9 +774,7 @@ non-interactive by construction. `TableRow` already composes `PressDoor`.
832
774
  chevron belongs to the row's button; the slot beside it does not, and if the two are spaced like
833
775
  siblings the eye reads one cluster. Put clearly
834
776
  more space BETWEEN the groups than inside one (2:1 is enough) and the pair separates without a
835
- divider, a weight change or any new element. It is the ordinary grouping rule — one uniform gap
836
- makes every child a peer — applied to a row's right edge, where it is easy to miss because both
837
- items are small grey glyphs sitting on the same line.
777
+ divider, a weight change or any new element.
838
778
 
839
779
  **"Non-interactive by construction" excludes any SLOT.** A trailing `right` / `actions` / `meta`
840
780
  prop takes whatever the caller passes, so a row that wraps one in its own press surface is a
@@ -851,13 +791,11 @@ so it's still keyboard-reachable. Do that locally — there is deliberately no s
851
791
  primitive.
852
792
 
853
793
  **A row is a button wrapped around DATA, so its text stays SELECTABLE and a drag is not a press.**
854
- The surface carries no blanket `userSelect: "none"` — that made a customer's own name and number
855
- undraggable, which is the one way anybody gets a value out of a table, and it bought nothing the
856
- furniture wasn't already buying for itself (`Badge`, `Avatar`, `Button` each opt out). Both press
794
+ The surface carries no blanket `userSelect: "none"` — text a reader drags out of a table is the one
795
+ way anybody gets a value out of it (`Badge`, `Avatar`, `Button` each opt out). Both press
857
796
  surfaces consult `pressSelectedText()` instead, so a release that ENDED a selection doesn't also
858
797
  open the record and throw that selection away. Only a real, non-empty selection suppresses: a
859
- swallowed ordinary click would leave the row impossible to open, which is a worse bug than the one
860
- this fixes.
798
+ swallowed ordinary click would leave the row impossible to open.
861
799
 
862
800
  Selection covers every value at once, so reach for **`CopyButton`** only where one value is copied
863
801
  often enough to earn a control (a phone number a rep dials, a reference quoted into a message) —
@@ -884,34 +822,29 @@ Identity is the handful of facts someone would say aloud to name this record (it
884
822
  counterparty, usually two); everything else is an ATTRIBUTE and belongs below the answer the
885
823
  reader opened the record for. Get the size wrong and the position becomes unwinnable: eight
886
824
  labelled header fields push the ledger under the fold, and moving the block to the bottom to fix
887
- that leaves a reader who cannot tell whose record they are in. Both orders measure perfectly, so
888
- neither is caught by any check that reads the rendered screen. **When a leading block buries the
825
+ that leaves a reader who cannot tell whose record they are in. **When a leading block buries the
889
826
  answer, shrink it — do not move it**; frequency of EDIT is not the axis, because a rarely-edited
890
827
  fact can still be the one that names the thing.
891
828
 
892
829
  "The chrome already carries identity, so the body need not lead with it" is a real argument and a
893
830
  falsifiable one — verify it FACT BY FACT before relying on it. A summary strip showing the record
894
- number and its totals carries half an identity; the counterparty is missing, and half is what
895
- makes the claim feel true while the reader still cannot name what they are looking at.
831
+ number and its totals carries half an identity; the counterparty is missing.
896
832
 
897
833
  **All of the above is the LIST SCREEN.** A CHILD COLLECTION inside a record page — fees, lines,
898
834
  stops, containers — does the opposite: the row **EXPANDS** (`TableRow` `detail` + `expanded`),
899
835
  never docks a drawer. A drawer's whole job is to supply a context the reader is missing, and on a
900
- record page they are already in it: docking one charges a navigation to see nine fields, takes the
901
- sibling rows away while they read, and charges again to come back. `expanded` is CONTROLLED, so
902
- pick the rule — for a ledger it is ONE at a time, because two open details push the next row off
903
- screen and a register that cannot be scanned has stopped being one. The boundary is the detail's
836
+ record page they are already in it. `expanded` is CONTROLLED, so pick the rule for a ledger it is
837
+ ONE at a time, because two open details push the next row off screen. The boundary is the detail's
904
838
  weight, not its field count: read or light-edit (independent inline commits) expands, a real form
905
839
  with a submit and cross-field validation still earns its own surface.
906
840
 
907
841
  The expansion carries **EVERY** field, including the ones already in columns. Column cells are
908
842
  read-only, and `table_fit` tier 2 drops droppable columns on the explicit promise that a door
909
- reaches the dropped values — the expansion IS that door, so anything it omits is unreachable
910
- rather than merely inconvenient. `Table` does not expose its fit result, so this cannot be
911
- conditional. This is also the answer to "just add more columns": the fit budget is the MEASURED
912
- container, so past it the register drops columns silently instead of overflowing widening does
913
- not reveal the extra fields, it hides them without saying so. And free text (a note) has no column
914
- width at any measure.
843
+ reaches the dropped values — the expansion IS that door, so anything it omits is unreachable.
844
+ `Table` does not expose its fit result, so this cannot be conditional. This is also the answer to
845
+ "just add more columns": the fit budget is the MEASURED container, so past it the register drops
846
+ columns silently instead of overflowing. And free text (a note) has no column width at any
847
+ measure.
915
848
 
916
849
  ## Two work shapes, no side panels
917
850
 
@@ -953,12 +886,11 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
953
886
  content is TEXT: a line is ~20, so `minHeight` governs and the padding never binds — which is
954
887
  why a surface can pick any inset it likes and nothing on a resting screen ever contradicts it.
955
888
  The moment something with a FIXED height goes in — an avatar, a swatch, a thumbnail — that
956
- inset decides the control's whole height, and three surfaces that had each chosen their own
957
- (4, 6, 8) rendered one member chip at 46, 50 and 54. So: a fixed-height child is sized against
889
+ inset decides the control's whole height. So: a fixed-height child is sized against
958
890
  the band's budget, never against how it looks alone, and a surface that needs more room than
959
891
  the budget is not a control. The budget is 28 because that is what `InlineButton` and a `md`
960
- `IconButton` were already using inside a field — a control's children must not differ in height
961
- by what KIND of thing they are, so an avatar takes the same number rather than a fourth one.
892
+ `IconButton` use inside a field — a control's children must not differ in height
893
+ by what KIND of thing they are.
962
894
 
963
895
  - **`SearchInput`** is THE search box — a white `TextInputField` preset (the shared
964
896
  `CONTROL_RADIUS` like every other input, a thin border, the leading search glyph + a clear ✕).
@@ -996,8 +928,8 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
996
928
  - **A form's COMMIT sits with its fields, never in the footer.** The button submitting a set of
997
929
  inputs goes directly beneath the last one it commits, `alignSelf="flex-start"` — the same left
998
930
  edge the user just filled (data_entry.md's form-action alignment law, which holds inside an
999
- overlay too). A footer commit is separated from what it commits by the width of the surface and
1000
- reads as belonging to the dialog rather than the fields. A **`Reset` travels with the `Save` it
931
+ overlay too). A footer commit reads as belonging to the dialog rather than the fields.
932
+ A **`Reset` travels with the `Save` it
1001
933
  undoes** — same draft, same row, secondary to the commit's left; splitting the pair across two
1002
934
  surfaces breaks one decision in half. A dialog whose only actions are a commit and its reset has
1003
935
  **no footer at all**.
@@ -1007,11 +939,10 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
1007
939
  at the right edge, secondary to its left. A confirm dialog (no fields) is all surface: its
1008
940
  buttons ARE the dialog, and they belong in the footer.
1009
941
  - **A full-page editor is not a dialog.** Where the body SCROLLS and one commit governs the whole
1010
- page, that commit stays pinned at the page's foot: there is no dialog for it to read as
1011
- belonging to, and inlining it makes a reader scroll a long document to the end to save an edit
1012
- made at the top. What the page's title row carries instead is the actions on the OBJECT hand
1013
- it to the assistant, the overflow — so the pinned bar holds the commit alone and the two
1014
- clusters never compete for the same press.
942
+ page, that commit stays pinned at the page's foot: inlining it makes a reader scroll a long
943
+ document to the end to save an edit made at the top. What the page's title row carries instead
944
+ is the actions on the OBJECT hand it to the assistant, the overflow so the pinned bar holds
945
+ the commit alone.
1015
946
  - **Empty result**: `<EmptyState message hint? icon? action?>` — never a bare muted Text.
1016
947
  - **Inline status**: `<Callout tone="info|success|warning|error|neutral">` — compound (like
1017
948
  Card): compose `CalloutTitle`/`CalloutText`/`CalloutActions` inside. `Callout` is INLINE;
@@ -1032,8 +963,7 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
1032
963
  carries a ground (red-100) exactly where `secondary` carries zinc-100, so every act in the row
1033
964
  keeps ONE shape and only the ink moves; the ladder is then legible as a ladder. Drop a
1034
965
  groundless `danger-secondary` in there and the destructive verb is the one control with no box
1035
- — a shape difference, which a reader parses before colour and reads as an accident, and the
1036
- row stops looking designed (this is what "four buttons with different shapes" is, every time).
966
+ — a shape difference, which a reader parses before colour.
1037
967
  Among TEXT — a row's trailing verb, a `Callout`'s action, a dialog's tertiary — use
1038
968
  `danger-secondary`, because there a filled box is the thing that looks pasted on. A
1039
969
  fill-less `Button` (`danger-secondary`, `muted`, un-colored) shows no box, so its optical edge is
@@ -1045,21 +975,16 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
1045
975
  `InlineButton` (28px, filled, on a field's own surface).
1046
976
 
1047
977
  **AN ACT CARRIES A CONTROL; UNDERLINED TEXT REVEALS.** `Link`/`TextLink` are the underlined
1048
- pair; everything that MUTATES is a `Button` or an `InlineButton`. A surface-less act was tried
1049
- as a general third rung and removed: it made underline mean anything at all, so the affordance
1050
- stopped answering the one question a reader has — does this take me away, or does it do
1051
- something here. It also competed with the `Button` colour ladder, since `muted` already IS the
1052
- quiet rung.
978
+ pair; everything that MUTATES is a `Button` or an `InlineButton`. There is no surface-less
979
+ third rung `muted` already IS the quiet rung of the `Button` colour ladder.
1053
980
 
1054
981
  **The ONE exception, and it is narrow: an IN-PROSE DISCLOSURE.** A show/hide for adjacent
1055
982
  content — a transcript under its player, a long value under its summary — has nowhere else to
1056
- go. It cannot move to chrome, because it belongs to the thing above it; and the two controls
1057
- that look right in chrome are both wrong against a text column. `Button color="muted"` measures
1058
- transparent, borderless and undecorated AT REST, with its wash arriving only on hover: that is
1059
- a hover-only affordance, banned above, and it reads as a stray label between two content
1060
- blocks. `Accordion` fixes the affordance and overcorrects it is a LIST-ROW primitive, a
1061
- full-width header with its own chevron, and nesting one inside a row that already has a chevron
1062
- is list furniture borrowed for a one-off verb.
983
+ go. It cannot move to chrome, because it belongs to the thing above it. `Button color="muted"`
984
+ measures transparent, borderless and undecorated AT REST, with its wash arriving only on hover:
985
+ that is a hover-only affordance, banned above. `Accordion` overcorrects it is a LIST-ROW
986
+ primitive, a full-width header with its own chevron, and nesting one inside a row that already
987
+ has a chevron is list furniture borrowed for a one-off verb.
1063
988
 
1064
989
  So underline is permitted here, and the INK keeps it unambiguous:
1065
990
 
@@ -1068,13 +993,11 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
1068
993
  | blue + `role="link"` (`Link`) | leaves this surface |
1069
994
  | muted underline in a caller's `Pressable` (`TextLink`, no `href`) | reveals more of it, in place |
1070
995
 
1071
- Both say "show me more"; only one takes you away, which is the question the affordance existed
1072
- to answer. It stays scoped to REVEAL: a press that saves, sends, deletes or opens an overlay
1073
- carries a control surface, always. Pick by what the press DOES,
1074
- and let the surface say which kind it is. Never a `muted` text action that MUTATES: it mutes
1075
- the one thing carrying the act. No
1076
- "success"/green button. Decision UIs put positive/negative color on the STATUS (dot) and verdict
1077
- (colored `Text`), not the buttons.
996
+ Both say "show me more"; only one takes you away. It stays scoped to REVEAL: a press that
997
+ saves, sends, deletes or opens an overlay carries a control surface, always. Never a `muted`
998
+ text action that MUTATES: it mutes the one thing carrying the act. No "success"/green button.
999
+ Decision UIs put positive/negative color on the STATUS (dot) and verdict (colored `Text`), not
1000
+ the buttons.
1078
1001
  - **Create buttons carry NO `+` icon** — "Add fee", not "+ Add fee". A create is `primary` and
1079
1002
  rides its section's HEADING row at the right edge — see § The add-placement law for why, and
1080
1003
  for the one-verb-one-weight rule that keeps it `primary` wherever it renders. (The
@@ -1125,10 +1048,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1125
1048
  should be is decided per table — see data_entry.md §The editability affordance.
1126
1049
  - **A verb about a VALUE goes ON the value — an `InlineButton` in the field's `actions`.** Copy
1127
1050
  this reference, call this number, open this record: the act names the value, so it travels
1128
- with what it acts on, and a reader never has to pair a control sitting at the page's right
1129
- edge with the field it belongs to. `DetailRow` has NO trailing slot, because a reserved
1130
- column is paid by every row for the benefit of one — it was declared on the TABLE, so a
1131
- single `Copy` made every value cell stop short of an edge nothing occupied. EVERY `Inline*`
1051
+ with what it acts on. `DetailRow` has NO trailing slot, because a reserved
1052
+ column is paid by every row for the benefit of one. EVERY `Inline*`
1132
1053
  editor takes `actions`, popover-backed ones included — a field exposes its outer box
1133
1054
  separately from the control inside it, so a select's list and a date's calendar still open at
1134
1055
  the field's own width. Anything that is not a verb — a status `Badge`, a unit — composes into
@@ -1139,22 +1060,15 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1139
1060
  editor promises never to do.
1140
1061
  - **What is about the WHOLE record is a PANEL, not a section.** A section takes a position in a
1141
1062
  top-to-bottom reading order, which is a claim that it belongs at that point in the read. A
1142
- record's DISCUSSION does not: a note is written about anything on the page and is wanted while
1143
- you work anywhere on it, so parked at one position it is unreachable from every other — exactly
1144
- when it matters. Dock it in the page's right gutter (the one the outline rail's balance already
1145
- reserved), pinned, scrolling inside itself so a long thread never lengthens the record. Give it
1146
- no rail entry it is not a place you navigate to. Narrow, it collapses onto the same bar the
1147
- sections use, on the RIGHT: sections are where you ARE, the discussion is what is being SAID,
1148
- and the two poles keep the sides they hold on a wide screen.
1149
- - **A record is ONE PAGE. A section is a place you SCROLL to, never a route you swap to.** The
1150
- rule above already assumes it a section earns a POSITION in a top-to-bottom read, and the
1151
- discussion is docked precisely because a thing "parked at one position is unreachable from
1152
- every other". Routing the sections makes that true of every one of them. It is tempting on a
1153
- long record and it does not survive contact: each fix it needs rebuilds the whole-record view
1154
- in miniature — a dot to say which hidden section just changed, a page-band action to
1155
- re-surface an affordance some section had swallowed, an "everything" route that IS this page.
1156
- It also HIDES what it does not carry: a whole-record drop target advertised only inside the
1157
- files section does not exist from the eight destinations that are not it. And it charges
1063
+ record's DISCUSSION does not: a note is written about anything on the page, so parked at one
1064
+ position it is unreachable from every other. Dock it in the page's right gutter (the one the
1065
+ outline rail's balance already reserved), pinned, scrolling inside itself so a long thread never
1066
+ lengthens the record. Give it no rail entry it is not a place you navigate to. Narrow, it
1067
+ collapses onto the same bar the sections use, on the RIGHT.
1068
+ - **A record is ONE PAGE. A section is a place you SCROLL to, never a route you swap to.**
1069
+ Routing the sections makes every one of them "parked at one position and unreachable from every
1070
+ other". It also HIDES what it does not carry: a whole-record drop target advertised only inside
1071
+ the files section does not exist from the eight destinations that are not it. And it charges
1158
1072
  permanently — browser find reaches only the open section, two sections can never be read
1159
1073
  together, and every cross-section check costs a click each way. A long record is a DENSITY
1160
1074
  problem, and density is cuttable (a choice that took four rows, a group that restated the
@@ -1169,8 +1083,7 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1169
1083
  `Transfer point` / `Destination` looks like three fields and is actually one route — it cannot
1170
1084
  hold a second transfer, and three identical rows say nothing about which comes first. The same
1171
1085
  goes for an approval chain, a set of legs, a list of signatories. Rendered as a `Sequence` it
1172
- gains the one thing the grid could never give it — visible ORDER — and stops competing with the
1173
- fields around it, because it is no longer pretending to be one.
1086
+ gains the one thing the grid could never give it — visible ORDER.
1174
1087
  Nothing about spacing or disclosure fixes a section holding the wrong SHAPE, and a section that
1175
1088
  looks "unattended" is usually this rather than a density problem.
1176
1089
  - **A VERB GOES WHERE ITS EFFECT LANDS.** One law places every action on a record surface, and
@@ -1185,19 +1098,16 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1185
1098
  | the record's EXISTENCE | the `DangerZone`, last on the page |
1186
1099
 
1187
1100
  The ADD is the one verb this law does NOT place next to its effect, and the add-placement law
1188
- says why: a position that never moves beats a position that is adjacent, because "below the
1189
- last row" is a different place on every list and no place at all on an empty one. The heading
1190
- row therefore carries identity + meta + view controls + the section's ADD, and no other content
1191
- verb. **An add is `primary` wherever it renders** — on that heading row, or inside an
1192
- `EmptyState` in the one case § Empty states allows, where there is no heading to carry it. It
1101
+ says why. The heading row therefore carries identity + meta + view controls + the section's ADD,
1102
+ and no other content verb. **An add is `primary` wherever it renders** on that heading row,
1103
+ or inside an `EmptyState` in the one case § Empty states allows, where there is no heading to carry it. It
1193
1104
  is the act its section offers, so it carries that section's weight; a lone `secondary` button
1194
1105
  reads as though the real action were somewhere else. The rule that matters is that ONE VERB
1195
1106
  KEEPS ONE WEIGHT — the same add must not be primary on an empty list and secondary on a full one.
1196
1107
  A record surface therefore carries several primaries, one per section, and that is correct:
1197
1108
  "one primary per SCREEN" governs the page BAND, not a stack of independent sections each with
1198
- its own single act. The cost is real and worth knowing the more sections offer an act, the
1199
- less any one of them stands out — so the answer to a loud page is FEWER SECTIONS WITH ACTS,
1200
- never a quieter button.
1109
+ its own single act. The more sections offer an act, the less any one of them stands out — so the
1110
+ answer to a loud page is FEWER SECTIONS WITH ACTS, never a quieter button.
1201
1111
  - **A DENSE section is compacted by a SUMMARY above it, by ROW HEIGHT, and by grouping fields
1202
1112
  that are ONE ENTITY — never by a two-column layout.** The evidence is unusually clear here, so
1203
1113
  do not re-derive it: Baymard finds an overall two-column form layout "generally problematic and
@@ -1212,14 +1122,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1212
1122
  name its parts by adding a sub-label line no other row has — which drops its controls off the
1213
1123
  baseline every other row shares — and naming them with placeholders loses the names the moment
1214
1124
  the row is filled. In a label-left grid, ONE FIELD PER ROW is not a failure to compact; it is
1215
- the only shape that keeps the column a reader scans. Both attempts are recorded here so nobody
1216
- re-runs them.
1125
+ the only shape that keeps the column a reader scans.
1217
1126
  **A spread `DetailRow` gives its VALUE the squeeze, never its label.** The two share one flex
1218
1127
  row, so whichever of them cannot shrink forces the other to. A flex child defaults to
1219
1128
  `min-width: auto`, which floors the value at its longest word — and the label, having no floor
1220
- of its own, absorbs everything left: on a narrow register a two-word label collapsed to a 4px
1221
- column 200px tall, one character per line. The `minWidth: 0` on the value is what stops it, and
1222
- it is load-bearing rather than housekeeping. **The probe generalises past this component**:
1129
+ of its own, absorbs everything left, collapsing to a one-character-wide column many times taller
1130
+ than the row. The `minWidth: 0` on the value is what stops it. **The probe generalises past this
1131
+ component**:
1223
1132
  wherever a fixed-ish label shares a row with a `flex: 1` value, measure the LABEL's width and
1224
1133
  height across rows whose values differ in length — a label whose box changes shape from row to
1225
1134
  row is the signature, and it is invisible at any width where every value happens to fit.
@@ -1227,9 +1136,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1227
1136
  required field, while a record page is read-mostly.
1228
1137
  A SUMMARY belongs to the RECORD, not to a section. The highlights-panel precedent (a strip of
1229
1138
  key facts above the detail) is a PAGE-level device, where what it summarises is a scroll or a
1230
- tab away; repeated per section it sits inches above the very rows it repeats, so it reads as
1231
- duplication and adds a third block of chrome section title, description, strip — before any
1232
- data. Put the few facts someone would ask for by phone in the record's own header, once.
1139
+ tab away; repeated per section it sits inches above the very rows it repeats. Put the few facts
1140
+ someone would ask for by phone in the record's own header, once.
1233
1141
  A read-only view behind an Edit button is the last resort: it compacts by DROPPING empty
1234
1142
  fields — and an empty row is information, the difference between "this record has no vehicle"
1235
1143
  and "this section has no vehicle field" — and it costs a MODE, against the law that the surface
@@ -1237,9 +1145,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1237
1145
  - **A REFERENCE to another record is a FIELD, not a section and not a card.** The kit already
1238
1146
  draws every other reference as a token — a person is a `MemberChip`, a file a `FileBadge`, a
1239
1147
  select option an `OptionBadge` — and a record is no different. It renders as a VALUE wearing
1240
- the **inline editor's own resting surface** (the 40px band, the white bordered chip, the radius), so
1241
- a pointer at another record sits in the value column exactly like the editors above and below
1242
- it. Put BOTH the name and its code INSIDE it — one entity reads as one object, and a muted
1148
+ the **inline editor's own resting surface** (the 40px band, the white bordered chip, the radius).
1149
+ Put BOTH the name and its code INSIDE it one entity reads as one object, and a muted
1243
1150
  code beside a link reads as an orphan.
1244
1151
  **ONE act on the surface, the rest one layer in.** Pressing the field PEEKS the facts — free,
1245
1152
  so it takes the whole box as its target — and every verb lives INSIDE the peek, placed by
@@ -1250,21 +1157,18 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1250
1157
  Change is `secondary` and NOT `danger` — a correction, not a destruction — Edit takes the one
1251
1158
  filled-dark rung and hands it to Save, Open stays `secondary` and never prioritized. None of
1252
1159
  them sits on the field. After Change, hand the picker focus; after Clear, never — the two
1253
- verbs land in the same place meaning opposite things. An interior verb was tried and removed: it put two destinations behind one object
1254
- (press the box → a summary; press 8px right → navigate), it demanded the whole
1255
- container-with-controls anatomy (a role-less surface, a `PressDoor` for the tab stop, the verb
1256
- lifted on `zIndex`, hover tracked on the container), and it was neither common nor an act
1257
- ABOUT the value — leaving is the rare move on a record page. Without it the field has its plain
1258
- anatomy back: the press target is the box, the focus ring rings the field.
1160
+ verbs land in the same place meaning opposite things. An interior verb puts two destinations
1161
+ behind one object (press the box → a summary; press 8px right → navigate), and is neither common
1162
+ nor an act ABOUT the value leaving is the rare move on a record page. The field has its plain
1163
+ anatomy: the press target is the box, the focus ring rings the field.
1259
1164
  Detach earns its depth twice — destructive and rare, and it ANSWERS the question the peek asks
1260
1165
  ("is this the right one?"), so the check and the correction are one gesture. A detach link
1261
1166
  repeated down a column of references is noise for an act most readers never perform.
1262
1167
  **Open is OPTIONAL, and Edit is why.** A reference whose facts can be corrected in the peek has
1263
- no errand to send the reader on, and a verb wired to nothing is worse than an absent one. Edit
1264
- opens a DRAFT in the same grid nothing commits until Save, which sends only what CHANGED
1265
- and the open draft PINS the popover so outside-press and Escape cannot bin the typing. That
1266
- pinning is the whole licence: a peek may hold a draft, and may never hold a self-persisting
1267
- inline editor, because there blur and dismiss are one gesture.
1168
+ no errand to send the reader on. Edit opens a DRAFT in the same grid nothing commits until
1169
+ Save, which sends only what CHANGEDand the open draft PINS the popover so outside-press and
1170
+ Escape cannot bin the typing. That pinning is the whole licence: a peek may hold a draft, and may
1171
+ never hold a self-persisting inline editor, because there blur and dismiss are one gesture.
1268
1172
  **Never promote it to a Section.** A section claims a distinct AREA of the record and buys an
1269
1173
  outline-rail entry; one pointer at one other row never earns that. What tempts the promotion is
1270
1174
  the machinery around it — and each piece is just part of the field: the search is its EMPTY
@@ -1299,9 +1203,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1299
1203
  - **A PERSON token peeks the same way a record reference does — but only where nothing else
1300
1204
  on the line presses.** `MemberPeek` makes a `MemberChip` a door onto `MemberProfileCard`
1301
1205
  (a `xl` avatar, the sign-in identity, role, teams), and it is the same drill-down grammar:
1302
- identity on the surface, the facts one layer in. The constraint is where it may live, and
1303
- it is the interior-verb lesson above generalised past the reference field: **a token inside
1304
- something already pressable puts two destinations behind one object.** Two surfaces fail it
1206
+ identity on the surface, the facts one layer in. The constraint is where it may live: **a token
1207
+ inside something already pressable puts two destinations behind one object.** Two surfaces fail it
1305
1208
  and both keep the plain chip — a PICKER OPTION (a card over the open menu, fired at the one
1306
1209
  moment the reader is choosing rather than browsing) and a ROW THAT NAVIGATES (press the row,
1307
1210
  open the record; press four pixels left, open a card). What is left is where a peek is
@@ -1311,10 +1214,9 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1311
1214
  the drill-down.
1312
1215
  **Judge the CONTAINER, not how inert it looks.** A grouped register's band header names a
1313
1216
  person, carries no visible control, and is a button — it collapses the group — so a token
1314
- inside it nests a button in a button and splits one gesture in two. The corollary is worth
1315
- stating because it reads as a failure and is not: a screen built entirely of editors,
1316
- pickers and navigating rows has NOWHERE a peek belongs, and the right move there is no peek
1317
- at all rather than the least-bad host.
1217
+ inside it nests a button in a button and splits one gesture in two. A screen built entirely of
1218
+ editors, pickers and navigating rows has NOWHERE a peek belongs, and the right move there is no
1219
+ peek at all rather than the least-bad host.
1318
1220
  **A profile's rows are conditional, and absent is not empty**: a field nobody supplied has
1319
1221
  no row, while a field supplied as EMPTY keeps its row and says so. Collapsing the two turns
1320
1222
  "we were not told" into a confident claim about a colleague — the same distinction a
@@ -1328,8 +1230,7 @@ checkbox, a switch, a chip. The line is not "is it pressable" (nearly everything
1328
1230
  "does pressing it DO something, or does it set a value / open the thing you are already
1329
1231
  looking at".
1330
1232
 
1331
- Drawn tightly because a signal on half the screen is not a signal. A pointer everywhere says
1332
- only "this is a web page"; reserved, it marks the few things that act. What carries "you can
1233
+ Drawn tightly because a signal on half the screen is not a signal. What carries "you can
1333
1234
  press this" elsewhere is the hover treatment the surface already owns — a row's wash, a
1334
1235
  field's border, a chip's fill — which is what a native list or menu uses too, where the
1335
1236
  cursor stays an arrow throughout.
@@ -1355,52 +1256,45 @@ ask for one.
1355
1256
  **WHAT a line separates is the whole test — not how big the break feels.** A rule between two rows
1356
1257
  divides LIKE FROM LIKE, and the space between them already says that. The band's rule divides
1357
1258
  CHROME from CONTENT — a row of labels from the records they name — and no amount of whitespace
1358
- states that, because whitespace is what already separates the rows from each other. Removing it was
1359
- tried on a real register and the column band floated: with nothing beneath them the labels read as
1360
- a first row rather than as the header of the rest.
1259
+ states that. Without a rule beneath them the labels read as a first row rather than as the header
1260
+ of the rest.
1361
1261
 
1362
1262
  Drawing both makes them compete, and a grid of equal lines is most of what "it looks like a
1363
1263
  spreadsheet" means.
1364
1264
 
1365
1265
  **The hover wash does the work the rule did**, and it already has the shape: `PressableRow` bleeds
1366
1266
  its ground past the content and rounds it, so a register reads as a stack of cards with no card
1367
- component involved. That is why dropping the rules does not make rows ambiguous the separator
1368
- moved from a permanent line to a pointer-following one. The row has to be tall enough for that wash
1369
- to read as a surface rather than a stripe, which is what the 72 buys.
1267
+ component involved. The row has to be tall enough for that wash to read as a surface rather than a
1268
+ stripe, which is what the 72 buys.
1370
1269
 
1371
1270
  **Density is a number, never a mode.** A surface that genuinely needs more rows on screen sets
1372
- `minHeight` on the row. What no caller can ask for is the rules back — and that is deliberate,
1373
- because a table of VALUES rather than of objects is a different component: `Matrix` for a numeric
1374
- grid (a statement, a pivot, a rollup), `DataGrid` for a compact one. The distinction the two
1375
- rhythms used to express already lived at the component boundary; offering it again as a prop was a
1376
- third way to say the same thing, and the way that let a register contradict this rule.
1271
+ `minHeight` on the row. What no caller can ask for is the rules back — a table of VALUES rather
1272
+ than of objects is a different component: `Matrix` for a numeric grid (a statement, a pivot, a
1273
+ rollup), `DataGrid` for a compact one.
1377
1274
 
1378
1275
  ## A register that TRIAGES — `TableGroup`
1379
1276
 
1380
1277
  A flat register sorted by a column hands the reader every row at equal weight and leaves the
1381
- deciding to them: "which of these needs me today" is answered by reading all of them. `TableGroup`
1382
- moves that decision into the screen — a titled band of rows with a count.
1278
+ deciding to them. `TableGroup` moves that decision into the screen a titled band of rows with a
1279
+ count.
1383
1280
 
1384
1281
  **Flat is the default; grouping is something the reader turns ON.** A register opens as one
1385
1282
  continuous list, and the control that splits it into bands lives in the filter band beside the
1386
1283
  other view controls — an explicit "group by" the reader operates, defaulting to none. Authored
1387
1284
  bands fail three ways at once: the value they group on is unsortable, it is unfilterable, and it
1388
1285
  vanishes the moment the reader wants a different cut — so the screen holds an opinion the reader
1389
- cannot inspect, disagree with, or put down. Worse, it decides on their behalf every time they open
1390
- the screen, including the many times the triage it encodes is not the question they came with.
1286
+ cannot inspect, disagree with, or put down.
1391
1287
 
1392
1288
  **The status COLUMN is the floor.** Whatever the register can group on also exists as a column the
1393
1289
  reader can see, sort and filter — the column is the fact, the grouping is one way of looking at it.
1394
1290
  Ship the column first, offer grouping as a control, and let `TableGroup` render whatever the reader
1395
- picked. Everything below still governs how the bands look and behave once they are on; it just no
1396
- longer describes the state the screen starts in.
1291
+ picked. Everything below governs how the bands look and behave once they are on.
1397
1292
 
1398
1293
  **A value's filter renders the value the same way its cell does.** If the status cell is a
1399
1294
  `Badge variant="dot"`, the status filter's options are dot badges too — same vocabulary, same
1400
- colours, same shapes. The reader is matching what they just saw in the rows against what the
1401
- control offers; re-rendering it as plain text makes them translate between two presentations of
1402
- one field, and the two drift the first time either side gains a value. This holds for every
1403
- custom-rendered value, not just status.
1295
+ colours, same shapes. Re-rendering it as plain text makes the reader translate between two
1296
+ presentations of one field, and the two drift the first time either side gains a value. This holds
1297
+ for every custom-rendered value, not just status.
1404
1298
 
1405
1299
  **Group by what implies a different ACTION, never by a category the reader can already see.**
1406
1300
  Bands like *Waiting on you* / *Gone quiet* / *Open* / *Closed* each name a different response, so
@@ -1428,64 +1322,55 @@ column is a sort wearing chrome: it spends a band per value to repeat the cell b
1428
1322
  the archived ones — and the tempting shortcut is to let the band list double as the vocabulary,
1429
1323
  treating "matches no band" as "unrecognised". It breaks in both directions at once. The values
1430
1324
  you deliberately left out now match nothing, so they resurface under whatever heading catches
1431
- the remainder — *Unclassified*, *Other* which is louder than the bands they were removed from
1432
- and states something false about them. Fix that by treating "known" as "shown" instead, and a
1325
+ the remainder — *Unclassified*, *Other*. Fix that by treating "known" as "shown" instead, and a
1433
1326
  value added to the field later silently disappears from the one screen that would have shown it.
1434
1327
  Keep a set of values the screen can classify and a list of bands it renders: a value in the
1435
1328
  first but not the second is omitted on purpose, and a value in neither gets a visible band,
1436
1329
  because a register that quietly files what it does not understand is how a stale row stays
1437
1330
  invisible. Both lists are ALLOWLISTS — `else` is where the second one goes wrong.
1438
1331
 
1439
- Banding also changes what the COLUMNS have to carry, and the check is worth running afterwards: a
1440
- column that exists to signal presence ("is there a next step?") is now stated by the band, so it
1441
- is left carrying only its text — and it will be empty in every band but one. That is not
1442
- necessarily wrong (which pile against what specifically), but it is a new duplication to look at.
1332
+ Banding also changes what the COLUMNS have to carry: a column that exists to signal presence ("is
1333
+ there a next step?") is now stated by the band, so it is left carrying only its text — and it will
1334
+ be empty in every band but one. That is not necessarily wrong, but it is a new duplication to look
1335
+ at.
1443
1336
 
1444
1337
  ## The register's own craft — what "it looks like a spreadsheet" actually is
1445
1338
 
1446
1339
  Three defects produce that report, none of them about colour, and all three survive a design
1447
- audit. (The fourth was drawing a line between every row;
1448
- that one is settled above — a register has one line, and it is the band.)
1340
+ audit.
1449
1341
 
1450
1342
  **A column header is a LABEL at the body size** — `sm` / `muted` / `medium`, matching the values
1451
1343
  it heads in size and separating from them by weight and ink. It is not an eyebrow: an eyebrow is
1452
1344
  `xs`, calibrated to sit quietly beside a value it captions, and a column header names a whole
1453
1345
  column of them. Dropped to `xs` and lightened past `muted` it stops being legible and starts
1454
- reading as a caption that lost its value small type is not the same as quiet type. The active
1455
- SORTED column steps up in ink, never in weight; semibold belongs to the heading ladder.
1346
+ reading as a caption that lost its value. The active SORTED column steps up in ink, never in
1347
+ weight; semibold belongs to the heading ladder.
1456
1348
 
1457
1349
  **A fixed column `width` is sized for the LONGEST locale, not the one you typed it in.** Every
1458
1350
  header is a translated string, and the same label runs half again as long in Vietnamese as in
1459
1351
  English — so a width measured against the source truncates the header in the language the app
1460
- actually ships in, on a column the reader was told to check. It fails silently in review because
1461
- the reviewer reads the source locale. Size against the longest translation, or give the column
1462
- `flex` and let it take what it needs.
1352
+ actually ships in. It fails silently in review because the reviewer reads the source locale. Size
1353
+ against the longest translation, or give the column `flex` and let it take what it needs.
1463
1354
 
1464
1355
  **Sentence case, in the table header too.** The heading ramp already bans all-caps and names it
1465
- the #1 templated tell; `Table` and `SortHeader` were uppercasing every column label in every app,
1466
- which is the loudest possible version of the thing the rule exists to stop. Removing the case
1467
- without replacing the separation is what left the header looking adrift — the ink step above is
1468
- the replacement, and the two changes only work together.
1356
+ the #1 templated tell. Removing the case without replacing the separation leaves the header
1357
+ looking adrift the ink step above is the replacement, and the two changes only work together.
1469
1358
 
1470
1359
  **A LOOKUP KEY IS NOT A COLUMN — and this is where "cramped" usually comes from.** Ask of each
1471
1360
  column whether its value is COMPARED down the page or MATCHED one at a time. A reference,
1472
1361
  identifier or code that a reader arrives already holding — quoted on the phone, printed on a
1473
1362
  document, pasted from an email — is matched, and the question it answers is "which record is this
1474
1363
  one", which the search field answers exactly and without scanning. A column answers it by spending
1475
- its width on every row forever, and takes that width from the columns that ARE compared. Measured
1476
- on a register whose reference columns held 350px and truncated nothing while the two columns
1477
- carrying the facts a reader scans lost 224px and 84px of real values: cutting the references
1478
- halved the truncation and returned the width to where the eye actually goes. Put them in the
1479
- search haystack, on the record, and in the row's expanded detail if they must be visible — never
1364
+ its width on every row forever, and takes that width from the columns that ARE compared. Put them
1365
+ in the search haystack, on the record, and in the row's expanded detail if they must be visible — never
1480
1366
  in a column, unless the value is genuinely compared (a sequence a reader reads for gaps, a code
1481
1367
  whose PREFIX groups the rows).
1482
1368
 
1483
1369
  **`Table` does not scroll horizontally, and that is the feature.** When the columns do not fit, it
1484
1370
  sheds by `priority` and then stacks — it never offers a pan. A horizontal scroller is a WIDTH fix
1485
1371
  for a COUNT problem: it makes room for one more column without ever asking whether the column
1486
- earns a row's width, and the columns it hides off-screen are hidden from the author too, so the
1487
- register quietly grows past what anyone reads. The narrowing IS the question "which of these does
1488
- the reader compare", asked at the only time it gets answered. A dataset that genuinely wants pan —
1372
+ earns a row's width, and the columns it hides off-screen are hidden from the author too. The
1373
+ narrowing IS the question "which of these does the reader compare". A dataset that genuinely wants pan —
1489
1374
  every column compared against its neighbours — is a different shape: a quantity across two
1490
1375
  dimensions is `Matrix`, a managed grid is `DataGrid`.
1491
1376
 
@@ -1502,11 +1387,11 @@ outwards.
1502
1387
  no row carries yet — that is data-driven shape, and it is allowed. What breaks is that the
1503
1388
  `columns` entry and the row's `TableCell` are two edits in two files, so it is natural to make
1504
1389
  only the first: the header band loses a heading, the row still renders the cell, and from there
1505
- every column right of the gap sits under its neighbour's title. Nothing is null, nothing throws,
1506
- and both files read as correct on their own — the defect exists only in the pair. Derive both from
1507
- one value (`const showX = …`, used for the `columns` filter AND passed to the row) so they cannot
1508
- be edited apart. The signature when reviewing a render: the header has one fewer cell than the
1509
- body, and the columns after the hidden one are shifted by exactly one slot.
1390
+ every column right of the gap sits under its neighbour's title. Nothing is null, nothing throws
1391
+ the defect exists only in the pair. Derive both from one value (`const showX = …`, used for the
1392
+ `columns` filter AND passed to the row) so they cannot be edited apart. The signature when
1393
+ reviewing a render: the header has one fewer cell than the body, and the columns after the hidden
1394
+ one are shifted by exactly one slot.
1510
1395
 
1511
1396
  **A row's SUBJECT is `medium`, never semibold.** Semibold is the heading ladder's weight — `#`,
1512
1397
  `##`, `###` are all semibold — so a register whose row subjects are semibold renders its content
@@ -1530,8 +1415,7 @@ recedes.
1530
1415
  **The subject leads by WEIGHT and by its identity mark, not by size.** `medium` against `regular`,
1531
1416
  plus the avatar or tile that answers "which row is this", is enough. Money is the case that most
1532
1417
  often argues for a size step and should not get one: tabular figures and right alignment already
1533
- mark it, and a lone larger value on a row reads as a mistake rather than as emphasis. One larger
1534
- thing among peers is not emphasis; it is the thing that looks broken.
1418
+ mark it, and a lone larger value on a row reads as a mistake rather than as emphasis.
1535
1419
 
1536
1420
  **Whether the SUPPORTING line drops a rung is decided by what else separates the pair — not by
1537
1421
  where it sits and not by what it carries.** `color="muted"` is already one channel of demotion, so
@@ -1552,9 +1436,8 @@ pair's, not each half's.
1552
1436
  **A STACKED PAIR takes `leading="tight"` — it is not prose.** The ramp's body rungs carry prose
1553
1437
  leading (14/24, ratio 1.71), which is right for a paragraph and wrong for two lines that are one
1554
1438
  object: it leaves ~10px of empty box between them, so the pair reads as two separate lines. Beside
1555
- a 40px mark the same fault is measurable rather than a matter of taste the stack comes to 50 and
1556
- overhangs the mark it belongs to. Tight is `size + 6`, which is what `xs` has always been, so this
1557
- is the smallest rung's own rule extended up the ladder rather than a second opinion about leading.
1439
+ a 40px mark the stack then comes to 50 and overhangs the mark it belongs to. Tight is `size + 6`,
1440
+ which is what `xs` already is the smallest rung's own rule extended up the ladder.
1558
1441
 
1559
1442
  Set it on BOTH lines where both are body rungs; where the supporting line is `xs` it is already
1560
1443
  tight, so only the subject carries the prop. Then the stack's own `gap` goes to **0** — each tight
@@ -1567,12 +1450,8 @@ Running text keeps prose leading. The default is for paragraphs, and `.ui-markdo
1567
1450
  sits beside — a 24px `CopyButton` on an 18px `xs` line — so a flex row containing both is sized by
1568
1451
  the CONTROL, and `alignItems: "center"` then spends the difference above the text. The line's text
1569
1452
  starts lower, and the pair reads with a wider gap than the identical pair one column over that
1570
- happens to carry no control — one shared `gap` reading as two different ones.
1571
-
1572
- It is invisible in review because every number is defensible — the gap is right, the rungs are
1573
- right, the control is the documented size — and the defect is the interaction between them. It is
1574
- also the harder half of *same shape, same treatment*: the reason for the difference is real, and a
1575
- reader cannot see it.
1453
+ happens to carry no control — one shared `gap` reading as two different ones. It is invisible in
1454
+ review because every number is defensible and the defect is the interaction between them.
1576
1455
 
1577
1456
  Pin the row to the TEXT's line box (`height` = the rung's leading) and let the control overflow it.
1578
1457
  The hit area is unchanged — it paints outside the box rather than growing it — and the text sits
@@ -1587,17 +1466,14 @@ where it would if the control were not there. The same applies to any verb ridin
1587
1466
  **The shape varies only where the surface MIXES entity types** and the reader acts on which is
1588
1467
  which: a search result holding both people and companies, a "related records" list, a picker over
1589
1468
  two directories. On a surface whose rows are all one kind of thing — a register of records, a
1590
- roster, a list of accounts — pick ONE shape and hold it. The distinction is real, but a reader
1591
- does not act on it there, and a column of mixed circles and squares reads as an inconsistency
1592
- rather than as information. That is the general form: a structural device asserts a difference,
1469
+ roster, a list of accounts — pick ONE shape and hold it. A column of mixed circles and squares
1470
+ reads as an inconsistency rather than as information: a structural device asserts a difference,
1593
1471
  and asserting one the reader cannot use costs more than it gives.
1594
1472
 
1595
- Both exist for the same reason: the mark's entire job is making a row identifiable at a glance,
1596
- so it is the one element in the row that must not look the same on every row. A register of
1597
- twenty people in one accent colour is twenty identical circles, with the largest and most
1598
- colourful thing in the row carrying no information; and a register of companies drawn with a
1599
- person's disc asserts they are the same kind of entity, which the column header alone has to
1600
- correct.
1473
+ The mark's entire job is making a row identifiable at a glance, so it is the one element in the
1474
+ row that must not look the same on every row. A register of twenty people in one accent colour is
1475
+ twenty identical circles, with the largest and most colourful thing in the row carrying no
1476
+ information.
1601
1477
 
1602
1478
  - **The hue is derived, never passed.** A per-app or per-screen map is a second copy of something
1603
1479
  the name already determines, and it drifts. `avatar_tone` is pure, so one person is one colour
@@ -1612,16 +1488,16 @@ correct.
1612
1488
  - **Every register row should carry a LEADING MARK, best effort from the data.** An avatar, a
1613
1489
  logo, a thumbnail, a file badge, a coloured initial — something the eye can aim at without
1614
1490
  reading. A register whose rows open with text starts flat, and the symptom is reported as
1615
- anything but the cause ("that avatar looks too small", "it looks plain"), because a missing
1616
- element is invisible by definition. A person gets initials; an account gets the squared mark; a
1617
- record with no likeness of its own borrows the identity a reader actually scans for — a deal
1618
- takes its account's mark, since "whose deal is this" is the question being asked of the list.
1619
- Only rows with genuinely no identity are exempt: a list of amounts, a log of events.
1491
+ anything but the cause ("that avatar looks too small", "it looks plain"). A person gets
1492
+ initials; an account gets the squared mark; a record with no likeness of its own borrows the
1493
+ identity a reader actually scans for — a deal takes its account's mark, since "whose deal is
1494
+ this" is the question being asked of the list. Only rows with genuinely no identity are exempt:
1495
+ a list of amounts, a log of events.
1620
1496
  - **The rung follows what the row is ABOUT**: `lg` where the entity is the row's subject, `md`
1621
1497
  where it is one field among columns (see `avatar_size`).
1622
1498
  - **A column of faces must not draw an UNBOUNDED audience as faces.** Where a cell answers "who
1623
1499
  can reach this", the commonest state is often *everyone* — and three sampled avatars assert
1624
- something false about the one fact the column exists to be scanned for exceptions in. Draw the
1500
+ something false about it. Draw the
1625
1501
  unbounded grant as a mark that SAYS so, and people only where the set is genuinely enumerable;
1626
1502
  cap the faces at a few and let the remainder be a `+N` — as the last mark IN the stack, since a
1627
1503
  number trailing after the discs reads as a separate fact about the row. Drop anyone the row
@@ -1630,23 +1506,19 @@ correct.
1630
1506
  reader may not be entitled to. And the faces stay INERT inside a pressable row, per
1631
1507
  member_peek.tsx.
1632
1508
  - **An overlapping stack separates with translucent INK, never with the surface colour.** The
1633
- conventional facepile rings each mark in the page background so the discs read as distinct —
1634
- which binds the ring to a colour the row does not keep. A register row washes on hover, again
1635
- when it is the open record, and again while pressed, so a ring painted white is a halo in three
1636
- of the four states, and they are the states a reader is in when they are actually looking. A
1509
+ conventional facepile rings each mark in the page background which binds the ring to a colour
1510
+ the row does not keep. A register row washes on hover, again when it is the open record, and
1511
+ again while pressed, so a ring painted white is a halo in three of the four states. A
1637
1512
  translucent dark edge darkens whatever is behind it instead of matching it, so it holds on every
1638
1513
  ground; `FileBadge` separates itself from its own surface the same way. Use a `boxShadow`, not a
1639
1514
  border — a border grows the box, and the stacked mark then measures wider than the identical
1640
1515
  mark in the next column.
1641
1516
 
1642
- The brand did not lose a home when the disc stopped carrying it — it moved to chrome, below.
1643
-
1644
1517
  ## Character comes from the DATA — the answer when a screen reads "bland"
1645
1518
 
1646
1519
  Every rule above removes a defect. None of them ADDS anything, so a screen can pass all of them
1647
1520
  and still be reported as plain, boring, or unfinished — which is a different complaint with a
1648
- different fix. Removing defects converges on *inoffensive*; these three questions converge on
1649
- *distinctive*.
1521
+ different fix.
1650
1522
 
1651
1523
  - **What is inherently visual in THIS data?** Work software has no photography, so the data itself
1652
1524
  is the imagery: identity marks (above), status colour, file-type badges, sparklines, proportion
@@ -1661,36 +1533,31 @@ different fix. Removing defects converges on *inoffensive*; these three question
1661
1533
  keep not landing.
1662
1534
  - **What makes this belong to THIS product?** Default typeface, default greys, default accent,
1663
1535
  default control shapes is a *well-built generic screen*, and no further polish changes that. The
1664
- fix at that point is a theme (`applyLoticsTheme`), not another treatment pass — and knowing
1665
- which of the two you are looking at saves a great deal of pointless nudging.
1536
+ fix at that point is a theme (`applyLoticsTheme`), not another treatment pass.
1666
1537
 
1667
1538
  Type SCALE is the fourth, and it belongs with the others: range = largest ÷ smallest across the
1668
- screen, and under about 2× reads flat *at any amount of colour*. Scale is the one hierarchy device
1669
- colour cannot substitute for. A register with no page band tops out near 1.33×.
1539
+ screen, and under about 2× reads flat *at any amount of colour*.
1670
1540
 
1671
1541
  **The test every visual has to pass: does it make the screen CLEARER *and* better-looking?** Both,
1672
1542
  not either. A visual that only decorates is clutter and goes; a visual that only informs but fights
1673
1543
  the composition is a chart nobody reads. The ones that pay are the ones where the two are the same
1674
1544
  act — a status colour that is also the scan path, a product thumbnail that is also the identifier, a
1675
- trend line that is also the answer. That framing is what keeps "make it less bland" from becoming
1676
- "add more things", which is the failure this section is most often misread into.
1677
-
1678
- **And the same test cuts the other way, which the rest of this document does not say often enough.**
1679
- Nearly every rule here removes something SUBTRACT is a gate, the trim test hunts restatement, the
1680
- absence pass looks for what should not be there. Follow only that gradient and you converge on a
1681
- screen that is clean, defensible, and *does not tell the reader enough to act*. **Minimal is not the
1682
- same as clear.** A number with no comparison, a status with no age, a total with no composition, a
1683
- row that forces a click to answer the question it exists to answer each is a screen that passed
1684
- every subtraction and failed the reader. Withholding is a defect exactly as much as clutter is; it
1685
- is simply the one that survives review, because nothing on the screen looks wrong. Ask what the
1686
- reader must DECIDE, then check that the screen holds every fact that decision needs.
1545
+ trend line that is also the answer.
1546
+
1547
+ **And the same test cuts the other way.** Nearly every rule here removes something — follow only
1548
+ that gradient and you converge on a screen that is clean, defensible, and *does not tell the reader
1549
+ enough to act*. **Minimal is not the same as clear.** A number with no comparison, a status with no
1550
+ age, a total with no composition, a row that forces a click to answer the question it exists to
1551
+ answer — each is a screen that passed every subtraction and failed the reader. Withholding is a
1552
+ defect exactly as much as clutter is; it is simply the one that survives review, because nothing on
1553
+ the screen looks wrong. Ask what the reader must DECIDE, then check that the screen holds every
1554
+ fact that decision needs.
1687
1555
 
1688
1556
  **Where the data has a history, show the trajectory, not just the latest value.** A figure states
1689
1557
  where things stand; a line, a bar series or a `Sparkline` states where they are GOING, and the
1690
1558
  direction is usually the decision — a balance that is falling, a return rate that is climbing, a
1691
1559
  week that broke the pattern. This is the most common honest source of visual interest in work
1692
- software, because the shape *is* information. It is also the one most often left out, since the
1693
- current value is what the query returns first.
1560
+ software, because the shape *is* information.
1694
1561
 
1695
1562
  **A register whose entity has ordered stages should render the stage, not just name it.** A word in
1696
1563
  a cell makes the reader know the vocabulary and its order; a small coloured progress indicator makes
@@ -1711,8 +1578,8 @@ case. `primary` is already the ink the kit spends on "this is on": a checkbox's
1711
1578
  track, a picked option's tick, the one primary button. An active tab is the same claim at page
1712
1579
  scale, so it belongs to the same ink, and the rail then sits at the same weight as the CTA it
1713
1580
  shares a screen with. Painted in `accent`, an unthemed screen gets exactly one blue thing on it —
1714
- a single-member hue from a family used nowhere else, which is the definition of a library default
1715
- nobody chose (see § Color discipline), three pixels from a near-black button.
1581
+ a single-member hue from a family used nowhere else, which is a library default nobody chose (see
1582
+ § Color discipline).
1716
1583
 
1717
1584
  The line between the two: `accent` marks WHICH OF THESE (a row among rows, a filter among
1718
1585
  filters), `primary` marks THIS IS ON (a control's state, the section you are in).
@@ -1723,51 +1590,41 @@ keeping the ground every other chip and card has. The two altitudes deliberately
1723
1590
  is the row's own language, so a control that borrows it says the same thing twice, and a control
1724
1591
  has a border to say it with while a row does not.
1725
1592
 
1726
- The reason to hold that line is that it was crossed twice, in both directions, and each crossing
1727
- looked reasonable. Painting selections through `accent_wash` shipped a blue nobody had chosen onto
1728
- the busiest surface in the product; making the token neutral to fix that turned a themed app's drop
1729
- target grey and left a token named for the brand that resolved to zinc. Selections carry their own
1730
- LITERALS now, so "is this selected" never depends on whether a tenant set an accent, and
1731
- `accent_wash` goes back to meaning what it says: the brand's tint, spent on ATTENTION — a region
1593
+ Selections carry their own LITERALS, so "is this selected" never depends on whether a tenant set
1594
+ an accent, and `accent_wash` means what it says: the brand's tint, spent on ATTENTION — a region
1732
1595
  lighting up under a dragged file — never on which record is open.
1733
1596
 
1734
- **Selection and hover must not share a ground.** They did, and the register's one piece of
1735
- persistent state was erased by a transient one: the row whose record is open looked exactly like
1736
- the row under the pointer. One neutral step separates them — **hover zinc-50, selected zinc-100,
1737
- pressed zinc-200**, three rungs of the ramp every other pressable already uses to say the same
1738
- three things. A control marked by an EDGE sidesteps the collision entirely, which is the other
1739
- reason chips are marked that way: the edge survives the hover wash instead of being replaced by
1740
- it.
1597
+ **Selection and hover must not share a ground** the register's one piece of persistent state is
1598
+ then erased by a transient one: the row whose record is open looks exactly like the row under the
1599
+ pointer. One neutral step separates them — **hover zinc-50, selected zinc-100, pressed zinc-200**,
1600
+ three rungs of the ramp every other pressable already uses to say the same three things. A control
1601
+ marked by an EDGE sidesteps the collision entirely, which is the other reason chips are marked that
1602
+ way: the edge survives the hover wash instead of being replaced by it.
1741
1603
 
1742
1604
  ### Theming is an app-level ESCAPE HATCH, not a product surface
1743
1605
 
1744
1606
  An app sets its own identity in its own code — `applyLoticsTheme({ … })` once at boot, before
1745
1607
  `mount`. **Nothing in the platform writes a theme**,
1746
1608
  there is no settings screen for it, and `apps.theme.color` is the LAUNCHER ICON's colour and
1747
- nothing else. Read that as the boundary rather than as a gap waiting to be filled: the mechanism
1748
- exists because a customer occasionally needs their own typeface, not because theming is a feature
1749
- we sell.
1609
+ nothing else. That is the boundary, not a gap waiting to be filled.
1750
1610
 
1751
1611
  **What makes it possible is small and load-bearing.** `colors.web.ts` reads five roles through a
1752
1612
  CSS variable with the kit's own value as the inline fallback, and `font_family.web.ts` does the
1753
1613
  same for the three weight rungs. That indirection is the ENTIRE mechanism — and it cannot be
1754
- replaced by wrapping a component and passing styles down, which is the intuition to check before
1755
- reaching for it. React Native Web writes `fontFamily` onto every `Text` element itself, so a
1756
- wrapper's font has nothing to inherit into; and `background-color` and `border-color` do not
1757
- inherit in CSS at all, so no ancestor can reach a `Button`'s fill or a `Table`'s hairlines. The
1758
- only alternative to the variables is passing `style` at every call site, which is a fork rather
1759
- than a theme.
1614
+ replaced by wrapping a component and passing styles down. React Native Web writes `fontFamily`
1615
+ onto every `Text` element itself, so a wrapper's font has nothing to inherit into; and
1616
+ `background-color` and `border-color` do not inherit in CSS at all, so no ancestor can reach a
1617
+ `Button`'s fill or a `Table`'s hairlines. The only alternative to the variables is passing `style`
1618
+ at every call site, which is a fork rather than a theme.
1760
1619
 
1761
1620
  **Prefer `applyLoticsTheme` over declaring the variables by hand**, because two of the values are
1762
1621
  DERIVED and getting either wrong fails quietly: `accent_wash` is the accent at 7%, and one
1763
1622
  `bodyFont` has to reach all three weight rungs or the medium and semibold runs stay in the kit's
1764
1623
  own face and the screen reads as a font that half-loaded.
1765
1624
 
1766
- **It is a function, not a provider**, and the shape follows from what theming IS: an app declares
1767
- one identity at boot and never changes it. A React component bought a memo, a wrapper element, a
1768
- layout effect, a restore-on-unmount and a context nobody read all of it lifecycle for a value
1769
- with no lifecycle. Calling it before `mount` is also strictly better than a provider could be: the
1770
- variables are in place for the FIRST paint, where a provider's effect ran after one.
1625
+ **It is a function, not a provider**: an app declares one identity at boot and never changes it,
1626
+ so there is no lifecycle to buy. Calling it before `mount` puts the variables in place for the
1627
+ FIRST paint, where a provider's effect would run after one.
1771
1628
 
1772
1629
  ## Color discipline — solid / tint / ramp + ONE accent
1773
1630
 
@@ -1804,9 +1661,8 @@ the sizes are.
1804
1661
  `color="muted"` is the token that means "this supports something else"; a hand-written
1805
1662
  `color="zinc-500"` is a *different, lighter* neutral that typechecks, renders plausibly, and is
1806
1663
  already spoken for — components mute a DEPARTED or disabled subject to it. Reach for the raw shade
1807
- and a supporting line ends up wearing the ink that means "this person has left", so on a departed
1808
- row the mute and the metadata come out identical and the state stops signalling anything. Grep a
1809
- component for a raw neutral on `Text` before trusting that it reads muted.
1664
+ and on a departed row the mute and the metadata come out identical, so the state stops signalling
1665
+ anything. Grep a component for a raw neutral on `Text` before trusting that it reads muted.
1810
1666
 
1811
1667
  **TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
1812
1668
  `Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
@@ -1833,8 +1689,7 @@ cue. That is the ONLY thing it is for; keep the vocabulary scarce so a colored c
1833
1689
  status. **A type / category / attribute / count is NOT a status — render it as inline `Text` (a
1834
1690
  muted metadata line, free to wrap the row taller), NEVER a Badge.** A party's type, a document
1835
1691
  kind, a city, "3 files" → text. If you're reaching for a Badge in a row or an option list "to
1836
- show more info," STOP — that's text. (The bug that set this rule: tonal type-pills sprinkled
1837
- through combobox option rows.)
1692
+ show more info," STOP — that's text.
1838
1693
 
1839
1694
  When it IS a status, match weight to prominence — lightest → heaviest:
1840
1695
 
@@ -1854,9 +1709,9 @@ Prefer `dot` when the column is secondary or the row already carries a coloured
1854
1709
  `tonal` when the status column is what the register is SCANNED by — the tints are the palette's
1855
1710
  50-step against its 900-step ink, comfortably clear of the AA floor, and
1856
1711
  a column of them is the difference between a register that reads as organised and one that reads
1857
- as a grey spreadsheet. That difference is the commonest "it looks bland" report, and it has a
1858
- real answer that costs no meaning: make the colour the screen ALREADY earns more present, rather
1859
- than adding colour somewhere that has to invent a meaning to justify it.
1712
+ as a grey spreadsheet. That is the commonest "it looks bland" report: make the colour the screen
1713
+ ALREADY earns more present, rather than adding colour somewhere that has to invent a meaning to
1714
+ justify it.
1860
1715
 
1861
1716
  **A `Badge` in a table cell needs `alignSelf`.** A cell stretches its child on the cross axis, and
1862
1717
  a `dot` has no ground so nothing shows — swap the variant to `tonal` and the pill becomes a filled
@@ -1874,9 +1729,8 @@ not a form they fill down. Stacked one per row at the page's own rhythm, a handf
1874
1729
  values can spend a quarter of the first screen while the viewport's right half sits empty. Pair
1875
1730
  them into columns above the width where a label and its value still fit.
1876
1731
 
1877
- The tell is a screen reported as **plain** whose treatment is fine. Then the treatment was never
1878
- the problem, and another pass at it will not help: the page is spending its height on facts and
1879
- its width on nothing.
1732
+ The tell is a screen reported as **plain** whose treatment is fine: the page is spending its height
1733
+ on facts and its width on nothing.
1880
1734
 
1881
1735
  **A record page also owes the reader the SIZE of its subject.** Where the data is quantities — how
1882
1736
  many lines, how heavy, worth what — a page that holds them only inside sections makes a reader open
@@ -1905,11 +1759,9 @@ cluttered rather than as small. Recede with INK, at the same rung. `xs` is for g
1905
1759
  timestamp, a count), not for a label that a person actually has to read.
1906
1760
 
1907
1761
  **A LABEL sharing a row with a flexible VALUE must be told which one gives way.** Two items in one
1908
- flex row, and whichever cannot shrink forces the other to. A value floored at its longest
1909
- unbreakable word leaves the label absorbing every remaining pixel collapsing to a
1910
- one-character-wide column many times taller than the row it names. It is invisible wherever the
1911
- values happen to fit, which is usually the width it was built at, and it appears the first time a
1912
- real record carries a long one. Decide deliberately: `flexShrink: 0` on the one that must stay
1762
+ flex row, and whichever cannot shrink forces the other to. It is invisible wherever the values
1763
+ happen to fit, which is usually the width it was built at, and it appears the first time a real
1764
+ record carries a long one. Decide deliberately: `flexShrink: 0` on the one that must stay
1913
1765
  whole (a fixed-length key, a label), `flexShrink: 1` + `numberOfLines` on the one that may clip,
1914
1766
  and pick by which is still USEFUL truncated — `RC-2026-0…` is unusable, `(555) 384-7…` is still
1915
1767
  recognisably a phone number.
@@ -1940,13 +1792,9 @@ steepens as the type grows — nothing in the kit tracks positive except `transf
1940
1792
  which compensates for the caps itself.
1941
1793
 
1942
1794
  A hand-set `letterSpacing` is a finding wherever it appears, and it fails in a specific way: it
1943
- looks like a local decision and is actually a second copy of the curve. Three of them shipped
1944
- before this was written a private size→tracking table on `Metric` that put the product's
1945
- summary figures at ZERO while the sentence beside them tracked -0.48px, a flat value on `Text`
1946
- itself that was the whole of native's tracking, and `+0.3` left on a column header after its size
1947
- and weight had been corrected. Each was defensible in its own file and wrong on the screen. If
1948
- something cannot be wrapped in `Text` (a raw `TextInput`, a chart axis), take the rung from
1949
- `type_ramp` rather than picking a number.
1795
+ looks like a local decision and is actually a second copy of the curve defensible in its own
1796
+ file and wrong on the screen. If something cannot be wrapped in `Text` (a raw `TextInput`, a chart
1797
+ axis), take the rung from `type_ramp` rather than picking a number.
1950
1798
 
1951
1799
  Weights: `regular` / `medium` /
1952
1800
  `semibold`. Colors: `default` (zinc-900), `muted` (zinc-600), `inverted`, the valence set
@@ -1966,9 +1814,8 @@ equal — and the reader has to reconstruct the grouping from the content, becau
1966
1814
  telling them the parts are peers of the wholes. The tell is a stack that reads as an undifferentiated
1967
1815
  list when you unfocus your eyes, or a supporting line drifting so far from the value it supports
1968
1816
  that it looks like the next unit's first line. This is the same law the gap-ratio probe in
1969
- [reviewing.md](./reviewing.md) measures (~2:1 between over inside); it is stated here because it is
1970
- a composition decision, not a treatment one no amount of type or colour work fixes a stack whose
1971
- spacing groups the wrong things.
1817
+ [reviewing.md](./reviewing.md) measures (~2:1 between over inside) no amount of type or colour
1818
+ work fixes a stack whose spacing groups the wrong things.
1972
1819
 
1973
1820
  **A control's height comes from `CONTROL_HEIGHT`, never from padding tuned against a leading.**
1974
1821
  Padding that happens to sum to the band height is a second owner of that number, and it drifts the
@@ -1998,10 +1845,9 @@ React Native Web maps a subset of RN's accessibility props and **silently drops
1998
1845
  <Pressable accessibilityRole="tab" aria-selected={isActive} />
1999
1846
  ```
2000
1847
 
2001
- The trap is the failure's shape rather than its cause. There is no type error
2002
- the prop is declared no runtime warning, and the control looks and behaves
2003
- correctly; only a screen reader and the DOM inspector disagree with the source.
2004
- Nothing in a normal loop surfaces it, so it survives review, tests and QA.
1848
+ There is no type error the prop is declared no runtime warning, and the control looks and
1849
+ behaves correctly; only a screen reader and the DOM inspector disagree with the source. Nothing in
1850
+ a normal loop surfaces it, so it survives review, tests and QA.
2005
1851
 
2006
1852
  Write the raw `aria-*` attribute for any STATE (`aria-selected`, `aria-checked`,
2007
1853
  `aria-expanded`, `aria-disabled`, `aria-current`). `accessibilityRole` and
@@ -2017,8 +1863,7 @@ no matter how long you `waitFor`. That covers everything built on it: the `Combo
2017
1863
  this reason — there is no render test for any of them.
2018
1864
 
2019
1865
  The trap is that the CONTROL works fine, so the failure looks like a wiring bug: the input takes
2020
- its value, `aria-expanded` flips to `true`, and `role="option"` is nowhere. Time goes into
2021
- debugging the component instead of the harness.
1866
+ its value, `aria-expanded` flips to `true`, and `role="option"` is nowhere.
2022
1867
 
2023
1868
  So split the assertion:
2024
1869
 
@@ -2032,6 +1877,42 @@ A component test can still assert everything BEFORE the overlay opens: the empty
2032
1877
  placeholder, the disabled state, and — often the point — what the screen refuses to claim while
2033
1878
  nothing is selected.
2034
1879
 
1880
+ ## Localization — a rendered string is WIRED, never a literal
1881
+
1882
+ Microcopy below says what the words should SAY. This says how they reach the screen, and it is
1883
+ the same family of defect as `accessibilityState` above: it typechecks, it renders, and it is
1884
+ wrong in a language nobody on the change speaks.
1885
+
1886
+ **No user-facing string is a literal in `src/` when a locale slice already names it.** Every
1887
+ string a component renders resolves **prop → `useLoticsLocale()` slice → the English pack**: a
1888
+ per-instance `labels` override wins, the pack from `LoticsLocaleProvider` answers next, and `en`
1889
+ is the context's default value rather than a per-key fallback — each slice reuses the
1890
+ component's own `*Labels` type, so both packs are compile-forced complete and a missing
1891
+ translation is a build error, never a silent English leak at a call site. The module itself —
1892
+ the provider, the shipped `en`/`vi` packs, the `bcp47` tag, and the few surfaces that still take
1893
+ `labels` directly — is [catalog.md §"Theming, locale & tokens"](./catalog.md).
1894
+
1895
+ **Write it as an ALLOWLIST, not a hunt.** The question is never "does this string look
1896
+ English?" — it is "which slice names this string?", asked of every run of language the component
1897
+ emits, visible text and accessible names alike. A hunt is a denylist against a language you can
1898
+ recognise, and it goes blind twice over:
1899
+
1900
+ - **A literal typechecks, renders, and passes a render test in whatever language it happens to
1901
+ be written in.** The kit shipped a hardcoded Vietnamese ⓘ label — the exact value of the very
1902
+ slot it should have read — to every tenant, and a whole suite of `vi`-pack assertions was
1903
+ blind to it, because on that bug the literal and the translation are the same string.
1904
+ - **A string BUILT by concatenation hides inside caller data.** `Open ${name}`, `About ${label}`,
1905
+ `${label} min` each carry one English word welded onto a value, so a Vietnamese app announced
1906
+ "Open hop_dong.pdf" to the one reader who has only the announcement. The fix is a pack-owned
1907
+ FUNCTION, not a connector string: word order forks by language, and a pack that only supplies
1908
+ the word cannot move it.
1909
+
1910
+ **So assert a wired string under the OTHER language.** English is what the bug produces, so an
1911
+ `en` assertion passes on the defect; render under `vi` and match the accessible name EXACTLY —
1912
+ a substring match contains the caller's data either way and is blind to precisely this. The one
1913
+ inversion: a string hardcoded to the Vietnamese value can only be caught under `en`. Whichever
1914
+ language the literal is in, the proof is the other one.
1915
+
2035
1916
  ## Microcopy — content, not mechanics
2036
1917
 
2037
1918
  UI copy states WHAT something is — domain content or one real rule — never how the widget
@@ -2061,8 +1942,8 @@ that praises it is marketing.
2061
1942
  - **NEVER join text with a middot — ` · ` is banned.** It is punctuation doing a word's job: it
2062
1943
  asserts that two things are related while refusing to say how, leaving the reader to guess
2063
1944
  between "and", "of", "for", "then", and "here is another fact". It is also the single loudest
2064
- templated-metadata tell (`5 min read · Jan 3 · Design`), and screen readers drop it, so the
2065
- only thing carrying the relationship vanishes. Use the mark that MEANS what you intend:
1945
+ templated-metadata tell (`5 min read · Jan 3 · Design`), and screen readers drop it. Use the
1946
+ mark that MEANS what you intend:
2066
1947
  **`:`** for a run-in label naming its value (`Hàng hoá: <goods>`), **`( )`** for a
2067
1948
  supplementary count or qualifier (`Đã đọc biểu thuế (2 bước)`), **`;`** between two related
2068
1949
  clauses, **`,`** in a genuine list of like items, a real preposition when the relation is
@@ -2109,8 +1990,7 @@ one exists).
2109
1990
 
2110
1991
  An empty region has three jobs (NN/g): confirm this is EMPTY — not loading, not broken — and
2111
1992
  say what belongs here. The `EmptyState` props are that anatomy and nothing more (`message` =
2112
- what's empty, `hint` = what to do about it). "Not broken" is why the failed read has its own
2113
- component: an alert glyph inside an empty state says both at once and means neither.
1993
+ what's empty, `hint` = what to do about it).
2114
1994
 
2115
1995
  **`EmptyState` carries NO verb.** The add is on the heading row (§ The add-placement law) and
2116
1996
  duplicating it here would put two buttons for one act in view and make the add jump the moment
@@ -2130,10 +2010,8 @@ default holds a screenful because nothing else is in view: collapsing to two gre
2130
2010
  read as a rendering bug rather than a state, which is the first of the three jobs. Inside a
2131
2011
  record that premise is false. The band's heading above it, the sections below it and the add
2132
2012
  verb beside it have already proved the surface rendered; the only open question is what this one
2133
- band holds, and a screenful is a long way to say "nothing yet". Measured on a four-section
2134
- record where three bands can be empty at once, the default spent **178px per band** and pushed
2135
- the section the reader opened the record FOR under the fold on a 768px screen — every probe
2136
- passes on that page, because nothing is wrong with any element on it.
2013
+ band holds, and a screenful is a long way to say "nothing yet" on a four-section record where
2014
+ three bands can be empty at once, the default spends **178px per band**.
2137
2015
 
2138
2016
  So: `compact` on a band, the default on a region. It is a DENSITY axis, not a second component —
2139
2017
  same three jobs, same two strings, same no-verb rule. Compact also sets its text to the LEFT,
@@ -2146,11 +2024,9 @@ at a fixed stroke on a 24 viewBox, so the artwork fills 20/24 of whatever box yo
2146
2024
  `sm` text the CAP HEIGHT is 11px, so a mark has to be ~13px to sit ON the line instead of
2147
2025
  straddling it — and at 13–16px a glyph with interior detail (`package`, `receipt`, `file-text`)
2148
2026
  closes up into a smudge. At 20px it reads, and its 16.7px of artwork then stands half again as
2149
- tall as the capitals beside it, rising above them and dropping past the baseline. Three sizes,
2150
- three different complaints, no size that answers both. Reach for a mark beside text only where
2151
- the glyph is SIMPLE (`check`, a chevron, a dot) or the text is `md` or larger; a detailed glyph
2152
- wants the block form, where it gets 28px and a line of its own. Here the anchoring is already
2153
- done by the band's heading, which is the premise the whole variant rests on.
2027
+ tall as the capitals beside it, rising above them and dropping past the baseline. Reach for a mark
2028
+ beside text only where the glyph is SIMPLE (`check`, a chevron, a dot) or the text is `md` or
2029
+ larger; a detailed glyph wants the block form, where it gets 28px and a line of its own.
2154
2030
 
2155
2031
  ### Buttons & action labels — the outcome, verb-first
2156
2032