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