@lotics/ui 44.13.0 → 45.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -15,9 +15,9 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
- | [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); a choice that GATES a form is usually one not to ask (two doors to one room is not a chooser; show the dominant path, offer the other beside its submit, and give a recovery link a quieter treatment than an alternative way in); Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count (and why "no selection" is a PLACEHOLDER, never an option valued `""` — the picker reads that as nothing chosen and renders down to its chevron), browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
18
+ | [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); a choice that GATES a form is usually one not to ask (two doors to one room is not a chooser; show the dominant path, offer the other beside its submit, and give a recovery link a quieter treatment than an alternative way in); Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count (and why "no selection" is a PLACEHOLDER, never an option valued `""` — the picker reads that as nothing chosen and renders down to its chevron), browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing); the swap rule for a NEW editor (same string ⇒ one element, different string ⇒ swap) worked through its two live cases — `InlineMarkdown` renders prose at rest and refuses to collapse when opened, while a URL takes `InlineTextInput`'s `link` TREATMENT because marking it changes its ink and not its characters, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface, its kind derived from before/after rather than a boolean "did this change" — an empty `before` is `added`, never `changed`), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
- | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape; **a hierarchy in the DATA does not oblige one in the UI** — who MAINTAINS the structure decides, and a set the reader scans is a register whatever shape the data has), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other, and a section heading's own row never wraps so a control label that repeats the title can squeeze the title+description into a narrow ribbon at phone width), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column, and what the screen can CLASSIFY kept as a separate list from what it RENDERS, so a value omitted on purpose does not resurface under *Unclassified* and one added later does not vanish), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair; plus the two ways a `columns` array and a rendered row disagree — a partial `priority` annotation, and a conditional column whose cell was not hidden with it), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber; a column of faces must not draw an UNBOUNDED audience as faces, and an overlapping stack separates with translucent INK rather than the surface colour, which a row's wash keeps changing out from under), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, **where a commit sits** (with its fields, never an overlay's footer — but a full-page editor is not a dialog, so a scrolling document keeps its commit pinned), and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
20
+ | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape; **a hierarchy in the DATA does not oblige one in the UI** — who MAINTAINS the structure decides, and a set the reader scans is a register whatever shape the data has), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other, and a section heading's own row never wraps so a control label that repeats the title can squeeze the title+description into a narrow ribbon at phone width), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column, and what the screen can CLASSIFY kept as a separate list from what it RENDERS, so a value omitted on purpose does not resurface under *Unclassified* and one added later does not vanish), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair; plus the two ways a `columns` array and a rendered row disagree — a partial `priority` annotation, and a conditional column whose cell was not hidden with it), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber; a column of faces must not draw an UNBOUNDED audience as faces, and an overlapping stack separates with translucent INK rather than the surface colour, which a row's wash keeps changing out from under), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **a fact band is read at a GLANCE and a glance is WIDE** (header facts paired into columns rather than stacked one per row — the tell is a screen reported as plain whose treatment is fine, spending its height on facts and its width on nothing; plus a record owes the reader the SIZE of its subject, summed from what the page already holds), **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, **where a commit sits** (with its fields, never an overlay's footer — but a full-page editor is not a dialog, so a scrolling document keeps its commit pinned), and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). Selection is never the brand — a neutral ground on a row, a dark doubled edge on a chip or card, with `accent_wash` kept for attention; a lookup key is not a column (the search matches it); `Table` sheds and stacks rather than scrolling horizontally, because narrowing is what asks whether a column earns its width. |
21
21
  | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 11 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), FIRST PAINT (an entry screen showing zero inputs is gated by something, usually a mode chooser), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
22
22
  | [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
23
23
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
@@ -61,6 +61,13 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
61
61
  `primary` `Button` beside `SectionHeadingTitle`, rendered empty or full. Under the rows it
62
62
  extends, the verb MOVES with the row count and vanishes off-screen on a long list; a heading is
63
63
  the one place it doesn't. → [composition.md §The add-placement law](./docs/composition.md).
64
+ - **A section heading's RUNG is the surface's answer, never the call site's.** There is ONE
65
+ `SectionHeadingTitle`; it renders `##` xl on a page and `####` md inside a pane, because the
66
+ container publishes the altitude — `DrawerScrollArea` / `DialogScrollArea` / `ModalBody` /
67
+ `PopoverContent` do it for you, and a pane you build yourself declares it with
68
+ `HeadingAltitudeContext`. Do not look for a per-surface heading variant and do not hand-pick a
69
+ size: a `##` inside a drawer is 24px over the drawer's own 20px title.
70
+ → [composition.md §The heading ramp](./docs/composition.md).
64
71
  - **`EmptyState` carries NO verb, and a FAILED read is not an empty one.** Four region states,
65
72
  picked by what the region can ASSERT: `Skeleton`/`Loading` in flight → **`ErrorState`**
66
73
  (`message`/`detail`/`onRetry`) on failure → `EmptyState` (succeeded, found nothing) →
package/MIGRATION.md CHANGED
@@ -14,6 +14,63 @@ usage produced an interactive control nested in a `button`: invalid ARIA, and on
14
14
  owners, since ticking the box also opened the file. A slot the docs tell you to put a control in
15
15
  cannot be inside the control.
16
16
 
17
+ ## 45.0.0 — a section heading stops shouting over the panel that holds it
18
+
19
+ *A MAJOR, because an export was removed.* Every consumer pinning `^44.x` is excluded by design:
20
+ the removal below breaks a build, and the altitude change silently re-ranks headings in any pane
21
+ that already composes its body in a content region. Neither should arrive with a routine install —
22
+ an app takes this release when someone is looking at it.
23
+
24
+ **`DialogSectionHeadingTitle` is REMOVED. Use `SectionHeadingTitle` everywhere — it now takes the
25
+ `####` rung by itself inside an overlay.** Replace the import and the tag; every slot
26
+ (`icon`, `description`, `info`, `level`) is unchanged, and the rendered output inside a dialog is
27
+ identical — measured, 16px/600/aria-level 4 before and after.
28
+
29
+ A `Drawer`'s title is `lg`, as is a `Dialog`'s and a `Modal`'s. A `SectionHeadingTitle` inside one
30
+ rendered `xl` — **24px under a 20px bar**, measured, and the same in every consuming app. The kit's
31
+ answer was a second component, which only moved the question to the call site: the caller still had
32
+ to know what was holding the heading, and callers reached for the plain one. Nothing covered the
33
+ drawer at all.
34
+
35
+ So the CONTAINER answers instead. `DrawerScrollArea`, `DialogScrollArea`, `ModalBody` and
36
+ `PopoverContent` publish the surface's heading altitude, and one `SectionHeadingTitle` renders `##`
37
+ xl on a page and `####` md inside them. **Nothing to pass, and nothing to change in an app that was
38
+ already composing pane content in those regions** — the fix arrives with the upgrade.
39
+
40
+ Three things worth knowing:
41
+
42
+ - **A whole PAGE surface docked in an overlay is unaffected, deliberately.** The master-detail
43
+ register puts a record screen in a drawer; that screen brings its own `#` identity band, carries
44
+ its own gutters, and so sits in the container's BARE slot rather than the scroll area — which is
45
+ why the altitude is published by the scroll area rather than by `Drawer`. Its sections stay `##`
46
+ and its `###` subsections stay under them. A pane body that hand-rolls the gutter instead of
47
+ using the scroll area also keeps `##`; wrap it, as the gutter contract already asks, and it steps.
48
+ - **A pane you build yourself declares its own altitude** — new export
49
+ `@lotics/ui/heading_altitude`. A docked side panel is not an overlay and uses none of those
50
+ regions, but its headings must not shout across 400px either: wrap its content in
51
+ `<HeadingAltitudeContext.Provider value="panel">`.
52
+ - **In a pane, `####` is the FLOOR.** `SubsectionHeadingTitle` is `###` at every altitude and would
53
+ out-rank the section holding it. A named group inside a pane's section is
54
+ `Text size="sm" weight="semibold"`.
55
+
56
+ **`Finding`'s source is VISIBLY a link when `onOpenSource` is set.** It was a `PressableHighlight`
57
+ with `accessibilityRole="link"` wrapping muted caption text — reachable by keyboard, announced to a
58
+ screen reader, and indistinguishable from an inert caption to everyone else until they hovered it.
59
+ With a handler it now renders default-colour `medium` underlined; without one it is unchanged. No
60
+ API change. If a host was passing `onOpenSource` for sources it did not actually want to advertise,
61
+ pass it only for the ones it can open.
62
+
63
+ **`Combobox` stops suppressing `getOptionDescription` too.** 44.9.3 removed
64
+ `description={content ? undefined : desc}` from `OptionList` and left the identical line in
65
+ `Combobox` — the instance fixed, not the class. `optionPicker` sets `renderOptionContent`
66
+ whenever a badge is requested, so a Combobox caller passing both still silently lost the
67
+ subtitle. Found by an audit reading the source rather than the pixels; no app hits it today.
68
+
69
+ **`Matrix`'s corner label matches its column headers.** 44.10.0 moved the column headers to
70
+ `sm` / `muted` / `medium` and left the corner at `xs`/regular — so a label that names an axis
71
+ sat a rung AND a weight below its own peers, measured live as 12px/400 beside 14px/500. Same
72
+ structural role, same treatment.
73
+
17
74
  ## 44.9.3 — an option's description stops being erased, and a pivot's headers rejoin the ramp
18
75
 
19
76
  **`OptionList` no longer suppresses `getOptionDescription` when `renderOptionContent` is set.**
@@ -1739,16 +1796,15 @@ const ref = useRef<SectionHandlers>(null);
1739
1796
  // AFTER
1740
1797
  <Accordion expanded={open} onToggle={setOpen}>
1741
1798
  <AccordionHeader>
1742
- <DialogSectionHeadingTitle icon="eye-off">Hidden fields</DialogSectionHeadingTitle>
1799
+ <SectionHeadingTitle icon="eye-off">Hidden fields</SectionHeadingTitle>
1743
1800
  </AccordionHeader>
1744
1801
  <AccordionContent>{body}</AccordionContent>
1745
1802
  </Accordion>
1746
1803
  ```
1747
1804
 
1748
- Pick the heading rung by where the surface sits, exactly as for a static section:
1749
- `SectionHeadingTitle` (`##`), `SubsectionHeadingTitle` (`###`), `DialogSectionHeadingTitle`
1750
- (`####`). The last one gained `icon` and `description` in this release, so every rung of the ramp
1751
- now carries the same slots.
1805
+ Compose the heading rung exactly as for a static section — `SectionHeadingTitle` (`##` on a page,
1806
+ `####` inside an overlay, decided by the surface) or `SubsectionHeadingTitle` (`###`). Every rung
1807
+ carries the same slots, so a disclosure header loses no affordance.
1752
1808
 
1753
1809
  ## v19 from 18.x
1754
1810
 
@@ -553,9 +553,8 @@ the person's conclusion on the page's own ground; that contrast IS the signal.
553
553
 
554
554
  **Model prose arrives with its own type ladder — take it away at the door.** A model asked for
555
555
  a summary emits `##` headings and heavy `**bold**` knowing nothing of what surrounds it, and
556
- document-scale markdown hands those an `h1` equal to your section heading: measured on a real
557
- feed, machine content rendered 18px/600 headings over 44 medium-weight runs, so the
558
- least-trustworthy text on the screen was also the loudest. Render every embedded value with
556
+ document-scale markdown hands those an `h1` equal to your section heading so the
557
+ least-trustworthy text on the screen ends up the loudest. Render every embedded value with
559
558
  `<Markdown variant="embedded">`.
560
559
 
561
560
  And one that is not about treatment at all: **check that the read path projects every field the
package/docs/catalog.md CHANGED
@@ -154,9 +154,17 @@ then inserts a newline and the value commits on blur. Both halves matter: a fiel
154
154
  goes multiline once focused hides the text from the reader who never clicks, and one that
155
155
  reserves less than it opens to shoves the rest of the form down the page on every click.
156
156
  **`autoGrow`** makes that budget a MINIMUM and fits the value instead — see
157
- [data_entry.md §Open prose grows](./data_entry.md)),
157
+ [data_entry.md §Open prose grows](./data_entry.md). **`link`** marks a URL value with
158
+ the navigation ink as a TREATMENT on the same single element — never a swapped-in
159
+ `TextLink`, which would reintroduce the sub-pixel jump this field stays one
160
+ `<input>` to avoid; pressing still edits, so pair it with an `Open` `InlineButton`
161
+ in `actions`),
158
162
  `InlineNumberInput`, `InlineSelect` (single or
159
- `multi`), `InlineMemberSelect`, `InlineDatePicker`, `InlineTimePicker`; a
163
+ `multi`), `InlineMemberSelect`, `InlineDatePicker`, `InlineTimePicker`,
164
+ **`InlineMarkdown`** (markdown RENDERED at rest, raw source while editing — the
165
+ member that swaps because its two states show different strings; its editor never
166
+ opens shorter than the view it replaced, so pressing a long note cannot collapse
167
+ the box, and **`minLines`** sets the floor for a short one); a
160
168
  READ-ONLY field in that same column uses `InlineStatic` (matches the editor box exactly, no
161
169
  input chrome, so it aligns pixel-for-pixel). A stack of labelled field rows lives in
162
170
  `DetailTable` + `DetailRow`; the record's identity band is `RecordSummary`; its money
@@ -281,8 +289,6 @@ to drill).
281
289
  gap-spaced region + `SectionHeading`/`SectionHeadingTitle`/`SectionHeadingMeta`; no body
282
290
  component — children are the body), `Subsection` (+ `SubsectionHeading`/
283
291
  `SubsectionHeadingTitle` — the named group INSIDE a section, `###` lg-semibold title),
284
- `DialogSectionHeadingTitle` (`####` md-semibold — the rung for a surface inside a DIALOG, whose
285
- `DialogHeaderTitle` already occupies lg; composes with the Subsection layout),
286
292
  `SectionStack` (the flat page's content column — owns the fixed 56px beat + hairline between
287
293
  top-level blocks), `SubsectionStack` (a section's `Subsection` groups — fixed 32px beat,
288
294
  space-only while the groups are SHORT, `divided` once they run long), `SectionCard`, `PageHeader` /
@@ -661,12 +667,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
661
667
  `warning`/`error` render as an ARIA `alert`) — wrapping form fields in an alert is wrong.
662
668
  The law: **content + fields → `Inset`; a message → `Callout`.**
663
669
  - **`section_heading`** — `Section`, `SectionHeading`, `SectionHeadingTitle`,
664
- `SectionHeadingMeta`, `Subsection`, `SubsectionHeading`, `SubsectionHeadingTitle`,
665
- `DialogSectionHeadingTitle` — the
670
+ `SectionHeadingMeta`, `Subsection`, `SubsectionHeading`, `SubsectionHeadingTitle` — the
666
671
  card-less twin of the Card family, compound, owns no margin; spacing via the Section gap
667
- (16, fixed), no body component. `SectionHeadingTitle` is ALWAYS `##` (xl semibold;
668
- `weight="medium"` opt-down only) + `info` for an ⓘ provenance popover after the title,
669
- same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3
672
+ (16, fixed), no body component. `SectionHeadingTitle` is the SECTION rung + `info` for an ⓘ
673
+ provenance popover after the title, same as `CardHeaderTitle.info` (`weight="medium"` is the
674
+ one opt-down). **Which rung it renders is the SURFACE's answer, not a prop:** `##` xl-semibold
675
+ on a page, `####` md-semibold inside an overlay's content region (`DrawerScrollArea` /
676
+ `DialogScrollArea` / `ModalBody` / `PopoverContent`), where the container's own title already
677
+ occupies lg. The call site is identical on both, which is the whole point — there is no
678
+ per-surface variant to pick, so it cannot be picked wrong. A whole PAGE surface docked in an
679
+ overlay (a record screen in a master-detail drawer) carries its own gutters and so sits in the
680
+ container's BARE slot, keeping the page rungs; nesting overlays never compounds.
681
+ `SubsectionHeadingTitle` is the `###` lg-semibold level-3
670
682
  title of a named group inside a section (same `info` ⓘ AND `description` slots as the section
671
683
  title) — heading-row siblings ride its right edge. Its `description` is what a files
672
684
  SUBSECTION uses to name its drag/paste paths: a `FileDropTarget`-wrapped region shows no
@@ -674,11 +686,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
674
686
  target) carries that line at THIS rung, not the section above it. **A section's ADD is one of those siblings** — a
675
687
  `primary` `Button` beside the title, rendered whether the collection is empty or full, never
676
688
  under the rows it extends and never repeated in the `EmptyState`
677
- (composition.md § The add-placement law). `DialogSectionHeadingTitle` is the `####`
678
- md-semibold rung with the SAME `icon`/`description`/`info` slots as the section title, so a
679
- dialog surface loses only the type size, never an affordance. **`description` is on every
680
- rung** the ramp's slots do not vary by level; only the type size does. The heading ramp is FIXED:
681
- `#` xxl / `##` xl / `###` lg / `####` md, no size props.
689
+ (composition.md § The add-placement law). An overlay surface loses only the type size, never an
690
+ affordance `icon`, `description` and `info` are the same slots at both altitudes. **`description`
691
+ is on every rung** — the ramp's slots do not vary by level; only the type size does. Inside an
692
+ overlay `####` is the ramp's FLOOR: `SubsectionHeadingTitle` has one altitude and would out-rank
693
+ the section holding it, so a named group there is `Text size="sm" weight="semibold"`. The heading
694
+ ramp is FIXED: `#` xxl / `##` xl / `###` lg / `####` md, no size props.
695
+ - **`heading_altitude`** — `HeadingAltitudeContext`, `useHeadingAltitude`, `HeadingAltitude`
696
+ (`"page" | "panel"`): WHICH SURFACE a heading is standing on, which is what selects the section
697
+ rung. The kit's content regions publish it, so an ordinary screen never imports this. Reach for
698
+ it when you build your OWN pane — a docked side panel, a split view, any self-contained region
699
+ whose own chrome caps at `lg` and which therefore uses none of those regions: wrap its content
700
+ in `<HeadingAltitudeContext.Provider value="panel">` and every heading inside is right. Scoping
701
+ the rule to the panes the KIT renders would make it an allowlist short by however many you
702
+ build. Flat, never a depth counter — a popover inside a drawer is one rung down, not two.
682
703
  - **`section_stack`** — `SectionStack`, `SubsectionStack` — stacks that own the
683
704
  between-block law, skipping null children: `SectionStack` = a fixed 56px beat + a hairline
684
705
  `Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only while
@@ -45,8 +45,8 @@ subject into rows.
45
45
  and never hand-roll one: the three surfaces disagree on the number (24 / responsive / 20) and
46
46
  only the surface knows which it is.
47
47
  - **Never wrap a scroller in a padded box.** The box insets the scroller's VIEWPORT, so the list
48
- ends short of the surface with dead space beneath it and its last row clipped measured, a
49
- 400px panel ends its scroll at 380. A pinned heading above a scrolling list therefore takes the
48
+ 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
50
  overlay's BARE slot and puts the gutter on the pinned part and on the scroller's
51
51
  `contentContainerStyle`. Same for a full-bleed band, which wants no gutter at all.
52
52
  - **Content column**: one centered column — `{ width: "100%", maxWidth, alignSelf: "center" }` —
@@ -88,10 +88,38 @@ restyle a heading level per-page.
88
88
  | Rank | `Text` size | Construct |
89
89
  |---|---|---|
90
90
  | `#` | `xxl` semibold | Page title — `PageHeader` / `RecordSummary` title |
91
- | `##` | `xl` semibold | Section title — `SectionHeadingTitle`, always |
91
+ | `##` | `xl` semibold | Section title on a PAGE — `SectionHeadingTitle` |
92
92
  | `###` | `lg` semibold | Subsection title — `SubsectionHeadingTitle`, always |
93
+ | `####` | `md` semibold | Section title in a PANE — the SAME `SectionHeadingTitle` |
93
94
  | body | `sm` | Everything else |
94
95
 
96
+ **Which rung a section title takes is the SURFACE's answer, never the call site's.** A `Drawer`,
97
+ `Dialog` or `Modal` caps its own chrome title at `lg`, so a `##` inside one renders 24px over a
98
+ 20px bar — a heading shouting over the pane that holds it. The CONTAINER publishes the altitude
99
+ and one `SectionHeadingTitle` answers to it: `##` on a page, `####` inside `DrawerScrollArea` /
100
+ `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.
103
+
104
+ Three consequences worth knowing before you compose:
105
+
106
+ - **A whole PAGE surface docked in an overlay keeps the page rung.** The master-detail register
107
+ 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.
112
+ - **A pane you build YOURSELF declares its own altitude.** A docked side panel is not an overlay
113
+ and uses none of those regions, but its headings must not shout across 400px either — wrap its
114
+ 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
+ - **In a pane, `####` is the FLOOR.** `SubsectionHeadingTitle` has one altitude (`###` lg) and
118
+ 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.
120
+
121
+ Nesting never compounds: a popover opened inside a drawer is one rung down, not two.
122
+
95
123
  - **Page band** — the screen's TYPE ANCHOR, and only secondarily a label. A register's own type
96
124
  runs 12–16px, so a page without a band tops out around a **1.33x** range between its largest and
97
125
  smallest text, and that reads flat no matter how much colour is on it — scale is the one
@@ -128,9 +156,8 @@ restyle a heading level per-page.
128
156
  below. **The row is `flexDirection: "row"` and never wraps**, so a long control label doesn't
129
157
  drop to its own line at phone width — it holds its own width and the title's `flex: 1` gives
130
158
  up whatever is left, taking the `description` down with it. A label that repeats the heading
131
- verbatim is the usual way this gets long enough to bite: measured on a real section, an
132
- 11-character title-plus-button pair left the title a comfortable column, and the SAME text
133
- duplicated onto the button squeezed both title and description into a 107px ribbon wrapping
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
134
161
  one or two words per line for eleven lines total. The button-label law already asks for
135
162
  2–4 words; a heading-row control is the case where skipping it has a measurable width cost,
136
163
  not just a wordier button. A COUNT earns that slot only when what it counts is not fully on screen — collapsed,
@@ -144,14 +171,14 @@ restyle a heading level per-page.
144
171
  slots carry each section's count and CTA); a `Text size="sm" weight="semibold"` stand-in is only
145
172
  for small sub-groups. Do NOT wrap each section in its own `CardHeader`, do NOT hand-roll
146
173
  sm-semibold lead lines on a flat page, and NEVER use a bare eyebrow as a section title.
147
- - **Dialog section title** — the FOURTH rung, for a surface hosted in a dialog:
148
- `DialogSectionHeadingTitle` (`####` md semibold, with the same `icon`/`description`/`info`
149
- slots as `SectionHeadingTitle`, so only the type size steps down). It exists because
150
- `DialogHeaderTitle` is **lg** — the rung `SubsectionHeadingTitle` occupies — so a dialog holding
151
- a multi-section surface otherwise ties with, or is outranked by, its own content. Compose it
152
- with the SUBSECTION layout (`Subsection` › `SubsectionHeading` › this, in a `SubsectionStack`):
153
- only the type size steps down, the 12px body gap and 32px beat are already right. The ladder
154
- stays ABSOLUTE — this is one more fixed size, never a `size`/`compact` knob on a rung above.
174
+ - **Pane section title** — the FOURTH rung, taken automatically by `SectionHeadingTitle`
175
+ 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` ›
180
+ this, in a `SubsectionStack`): the 12px body gap and 32px beat are already right for a panel.
181
+ The ladder stays ABSOLUTE — this is one more fixed rung, never a `size`/`compact` knob.
155
182
  - **Subsection title** — the level BELOW a section on a long record surface: `Subsection` ›
156
183
  `SubsectionHeading` › `SubsectionHeadingTitle` (`###` — lg semibold, with the same `info` ⓘ
157
184
  and `description` slots as `SectionHeadingTitle`; siblings — a `Badge`, a
@@ -221,7 +248,7 @@ nought or forty.
221
248
  **When the row runs out of width, the VERB wraps — the title never gives way.** The title names
222
249
  the section; the verb is the thing that can move. Getting this backwards is easy to ship because
223
250
  RN-Web resolves `flex: 1` to `flex-basis: 0%` with `min-width: 0`, so a title beside a button that
224
- holds its intrinsic width is the half that collapses measured at 375 as a heading broken one
251
+ holds its intrinsic width is the half that collapses, and at phone width the heading breaks one
225
252
  word per line beside an untouched button. `SectionHeading` and `SubsectionHeading` wrap and floor
226
253
  their title column for this; do not add a second `flex: 1` spacer beside a title that already has
227
254
  one, which halves its share again and is how a badge came to land on top of a section title.
@@ -490,14 +517,38 @@ below the pressed element — the composable `Accordion` family (`AccordionHeade
490
517
  rhythm, so lists mix expandable + static rows. The body is **flush** with the header's left edge
491
518
  (aligns with the title — like a `Section` body, no indent, no tinted well). `AccordionHeader` is a
492
519
  layout slot: `AccordionTitle`/`AccordionMeta` is the compact list-row heading, but a **collapsible
493
- SECTION** is this same family with the heading rung composed inside it — `SectionHeadingTitle` /
494
- `SubsectionHeadingTitle` / `DialogSectionHeadingTitle` (+ `SectionHeadingMeta`) in the
495
- `AccordionHeader`, the body in `AccordionContent`. There is no separate collapsible-section
520
+ SECTION** is this same family with the heading rung composed inside it — `SectionHeadingTitle` or
521
+ `SubsectionHeadingTitle` (+ `SectionHeadingMeta`) in the `AccordionHeader`, the body in
522
+ `AccordionContent`; inside a pane the section title steps to `####` on its own, so a
523
+ collapsible section composes identically on both surfaces. There is no separate collapsible-section
496
524
  component: one heading family and one disclosure across static + collapsible. The disclosure
497
525
  header IS the section heading — name it for what it hides, never a generic bucket ("Advanced",
498
526
  "More", "Options") wrapped around one named section: that stacks two titles of the same rank and
499
527
  leaves the reader no reason to open the chevron.
500
528
 
529
+ ### A control's resting treatment promises what pressing it does
530
+
531
+ Both halves of that fail in practice, and they fail in opposite directions.
532
+
533
+ **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
550
+ what the element IS, and this rule is about what it LOOKS LIKE.
551
+
501
552
  ## No dead rows — registers & tables
502
553
 
503
554
  **The identifier leads.** A register's FIRST column is the thing the reader came to find the row
@@ -1320,8 +1371,8 @@ necessarily wrong (which pile against what specifically), but it is a new duplic
1320
1371
 
1321
1372
  ## The register's own craft — what "it looks like a spreadsheet" actually is
1322
1373
 
1323
- Three defects produce that report, none of them about colour, and all three measured on a shipped
1324
- screen that had already passed a design audit. (The fourth was drawing a line between every row;
1374
+ 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;
1325
1376
  that one is settled above — a register has one line, and it is the band.)
1326
1377
 
1327
1378
  **A column header is a LABEL at the body size** — `sm` / `muted` / `medium`, matching the values
@@ -1344,6 +1395,28 @@ which is the loudest possible version of the thing the rule exists to stop. Remo
1344
1395
  without replacing the separation is what left the header looking adrift — the ink step above is
1345
1396
  the replacement, and the two changes only work together.
1346
1397
 
1398
+ **A LOOKUP KEY IS NOT A COLUMN — and this is where "cramped" usually comes from.** Ask of each
1399
+ column whether its value is COMPARED down the page or MATCHED one at a time. A reference,
1400
+ identifier or code that a reader arrives already holding — quoted on the phone, printed on a
1401
+ document, pasted from an email — is matched, and the question it answers is "which record is this
1402
+ 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
1408
+ in a column, unless the value is genuinely compared (a sequence a reader reads for gaps, a code
1409
+ whose PREFIX groups the rows).
1410
+
1411
+ **`Table` does not scroll horizontally, and that is the feature.** When the columns do not fit, it
1412
+ sheds by `priority` and then stacks — it never offers a pan. A horizontal scroller is a WIDTH fix
1413
+ 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 —
1417
+ every column compared against its neighbours — is a different shape: a quantity across two
1418
+ dimensions is `Matrix`, a managed grid is `DataGrid`.
1419
+
1347
1420
  **Annotate every droppable column, or none.** `priority` decides what a narrow container sheds
1348
1421
  (lowest number is last to go, and column 0 — the identity — never drops). A PARTIAL annotation is
1349
1422
  the trap: an unannotated column defaults above the whole hand-written range, so one annotated
@@ -1378,9 +1451,9 @@ pair on the screen and divide. A spread in the RATIOS is the finding, not a spre
1378
1451
  **Every SUBJECT on one row starts at the same rung — a register's is `sm`, and a value in any
1379
1452
  other column is `sm` too.** "One rung below" is only half a rule: it constrains the STEP and says
1380
1453
  nothing about where the pair starts, so a subject placed a rung high stays compliant while dragging
1381
- its whole column above the rest. Measured on a register that read as disproportionate: the identity
1382
- column ran 16/14 while the column beside it ran 14/12 both correctly "one rung apart", one whole
1383
- rung out of step with each other, and the first column shouted while everything else receded.
1454
+ its whole column above the rest. Two columns can each be correctly "one rung apart" and still be a
1455
+ whole rung out of step with each other and then the higher one shouts while everything else
1456
+ recedes.
1384
1457
 
1385
1458
  **The subject leads by WEIGHT and by its identity mark, not by size.** `medium` against `regular`,
1386
1459
  plus the avatar or tile that answers "which row is this", is enough. Money is the case that most
@@ -1397,9 +1470,8 @@ the size step is a second, and a second is only worth its cost where nothing els
1397
1470
  body size. This matters most where that line is the DISAMBIGUATOR (which person, at which
1398
1471
  company): `xs` demotes content the reader needs, and 12px is where stacked diacritics crowd.
1399
1472
  - Both lines are regular weight and colour is the only thing between them — the supporting line
1400
- drops to **`xs`**. Measured: a date over its touch count, promoted to one rung, stopped reading
1401
- as a value and its annotation and started reading as two peers. The step is what makes them a
1402
- pair.
1473
+ drops to **`xs`**. Without the step the two stop reading as a value and its annotation and start
1474
+ reading as two peers.
1403
1475
 
1404
1476
  Deciding from the CONTENT instead — prose gets `sm`, reference data gets `xs` — fails on a line
1405
1477
  that holds both. An account name beside a contract number is one line, and it takes ONE rung: the
@@ -1415,8 +1487,7 @@ is the smallest rung's own rule extended up the ladder rather than a second opin
1415
1487
  Set it on BOTH lines where both are body rungs; where the supporting line is `xs` it is already
1416
1488
  tight, so only the subject carries the prop. Then the stack's own `gap` goes to **0** — each tight
1417
1489
  line box already contributes 3px above and below, giving 6px between the two, and a `gap` on top
1418
- is a third source of space between lines that are meant to read as one. Measured after: the
1419
- identity stack sits at exactly the mark's 40, and every pair on the screen carries the same 6px.
1490
+ is a third source of space between lines that are meant to read as one.
1420
1491
 
1421
1492
  Running text keeps prose leading. The default is for paragraphs, and `.ui-markdown` renders at it.
1422
1493
 
@@ -1424,8 +1495,7 @@ Running text keeps prose leading. The default is for paragraphs, and `.ui-markdo
1424
1495
  sits beside — a 24px `CopyButton` on an 18px `xs` line — so a flex row containing both is sized by
1425
1496
  the CONTROL, and `alignItems: "center"` then spends the difference above the text. The line's text
1426
1497
  starts lower, and the pair reads with a wider gap than the identical pair one column over that
1427
- happens to carry no control. Measured on a register: 5px under the name against 2px under the
1428
- figure beside it, from one `gap: 2` shared by both.
1498
+ happens to carry no control one shared `gap` reading as two different ones.
1429
1499
 
1430
1500
  It is invisible in review because every number is defensible — the gap is right, the rungs are
1431
1501
  right, the control is the documented size — and the defect is the interaction between them. It is
@@ -1528,10 +1598,10 @@ colour cannot substitute for. A register with no page band tops out near 1.33×.
1528
1598
 
1529
1599
  ## Where the accent goes — "where you are", and nothing else
1530
1600
 
1531
- `colors.accent` paints the state that answers **where the reader is** on a SURFACE: the selected
1532
- row's ground (`accent_wash`), an active filter. It never paints DATA — a status, a series and a
1533
- valence all come from a palette family, so an accent can never overwrite a meaning — and it never
1534
- paints the focus ring.
1601
+ `colors.accent` paints an active FILTER — the one place a brand hue answers "where the reader is"
1602
+ without also answering "what is this control doing". It never paints DATA — a status, a series and
1603
+ a valence all come from a palette family, so an accent can never overwrite a meaning — it never
1604
+ paints the focus ring, and it never paints a SELECTION (below).
1535
1605
 
1536
1606
  **A page-scale position marker takes `primary`, not `accent`** — an active tab's underline is the
1537
1607
  case. `primary` is already the ink the kit spends on "this is on": a checkbox's fill, a switch's
@@ -1544,14 +1614,27 @@ nobody chose (see § Color discipline), three pixels from a near-black button.
1544
1614
  The line between the two: `accent` marks WHICH OF THESE (a row among rows, a filter among
1545
1615
  filters), `primary` marks THIS IS ON (a control's state, the section you are in).
1546
1616
 
1547
- `accent_wash` is derived from `accent`, not chosen beside it. The two used to default to blue-600
1548
- and zinc-100 one token pair disagreeing with itself, so every app that themed nothing got a
1549
- colourless "you are here".
1550
-
1551
- **Selection and hover must not share a wash.** They did, and the register's one piece of
1617
+ **SELECTION IS NEVER THE BRAND — it is a neutral GROUND on a row, and a dark EDGE on a control.**
1618
+ A register row wears `zinc-100`; a chip and a card wear a `zinc-900` border doubled by a boxShadow,
1619
+ keeping the ground every other chip and card has. The two altitudes deliberately differ: a ground
1620
+ is the row's own language, so a control that borrows it says the same thing twice, and a control
1621
+ has a border to say it with while a row does not.
1622
+
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
1629
+ lighting up under a dragged file — never on which record is open.
1630
+
1631
+ **Selection and hover must not share a ground.** They did, and the register's one piece of
1552
1632
  persistent state was erased by a transient one: the row whose record is open looked exactly like
1553
- the row under the pointer. Hover is neutral and lighter (it answers "you can press this"),
1554
- selection carries the accent.
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.
1555
1638
 
1556
1639
  ### Theming is an app-level ESCAPE HATCH, not a product surface
1557
1640
 
@@ -1666,7 +1749,7 @@ happens to render 133 times is one badge.
1666
1749
 
1667
1750
  Prefer `dot` when the column is secondary or the row already carries a coloured mark. Reach for
1668
1751
  `tonal` when the status column is what the register is SCANNED by — the tints are the palette's
1669
- 50-step against its 900-step ink (measured 8.7:1 and 9.5:1, so roughly double the AA floor), and
1752
+ 50-step against its 900-step ink, comfortably clear of the AA floor, and
1670
1753
  a column of them is the difference between a register that reads as organised and one that reads
1671
1754
  as a grey spreadsheet. That difference is the commonest "it looks bland" report, and it has a
1672
1755
  real answer that costs no meaning: make the colour the screen ALREADY earns more present, rather
@@ -1681,6 +1764,24 @@ text. Pass it at the call site.
1681
1764
 
1682
1765
  A `Badge` is never a metric value.
1683
1766
 
1767
+ ### A fact band is read at a GLANCE, and a glance is WIDE
1768
+
1769
+ A record's header facts — owner, direction, mode, terms — are a band a reader takes in at once,
1770
+ not a form they fill down. Stacked one per row at the page's own rhythm, a handful of one-word
1771
+ values can spend a quarter of the first screen while the viewport's right half sits empty. Pair
1772
+ them into columns above the width where a label and its value still fit.
1773
+
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.
1777
+
1778
+ **A record page also owes the reader the SIZE of its subject.** Where the data is quantities — how
1779
+ many lines, how heavy, worth what — a page that holds them only inside sections makes a reader open
1780
+ two of them to learn how big the thing in front of them is. Sum what the page has already loaded
1781
+ into one band under the identity, and print no total that is not the sum of something visible
1782
+ further down. Drop an empty total rather than rendering a zero: "0 kg" asserts a measurement nobody
1783
+ took.
1784
+
1684
1785
  ## Typography
1685
1786
 
1686
1787
  **A `size` rung scales the WHOLE component, not just its figure.** A composite with a graphic and
@@ -131,6 +131,17 @@ FORMATTED and the editor shows something else (`1,250 kg` → `1250`, `31/07/202
131
131
  field), the string changes anyway, so those editors do swap and should. **The rule for a new
132
132
  editor: same string in both states ⇒ one element; different string ⇒ swap.**
133
133
 
134
+ Two worked cases sit on either side of it, and they look alike until you apply the rule.
135
+ **`InlineMarkdown` swaps**: `**bold**` and **bold** are different strings, so the quarter pixel is
136
+ invisible and rendering prose at rest is worth having. A swap editor is supposed to leave the box
137
+ where it was, which markdown cannot do exactly — so it does the half that matters and refuses to
138
+ COLLAPSE: the resting height becomes the editor's floor, because growing is tolerable where
139
+ shrinking pulls the text out from under the reader and jumps everything below it.
140
+ **A LINK does not swap**: marking a URL changes its ink, not its characters, so `InlineTextInput`
141
+ takes a **`link`** treatment on its single `<input>` rather than swapping in a `TextLink`. Pressing
142
+ the field still edits — an `Open` `InlineButton` in `actions` is how you reach the destination,
143
+ which is where a verb about the value belongs.
144
+
134
145
  **Commit-on-blur would race the press that caused it — the kit closes that race for you.**
135
146
  Pressing an action while an inline field is focused starts TWO things in ONE gesture: the
136
147
  field's write (the blur, on mousedown) and the button's handler (on mouseup). A handler that
@@ -220,9 +231,10 @@ One per type:
220
231
  field.
221
232
 
222
233
  They split across two shared shells. The same-height **typed** editors (`InlineTextInput`,
223
- `InlineNumberInput`) render **`InlineEditFrame`** driven by the
224
- **`useInlineEdit`** hook (the draft buffer, async save, and — for the two that swap — the view⇄edit
225
- toggle) — pass a custom input to that pair to join the family. On the frame, **`editing` means an
234
+ `InlineNumberInput`, `InlineMarkdown`) render **`InlineEditFrame`** driven by the
235
+ **`useInlineEdit`** hook (the draft buffer, async save, and — for the ones that swap — the view⇄edit
236
+ toggle) — pass a custom input to that pair to join the family. The frame's `display` takes a NODE as
237
+ well as a string, which is what lets a swapping editor rest as something other than text. On the frame, **`editing` means an
226
238
  input is MOUNTED** (which element renders, and whether the surface drops its resting padding to
227
239
  host one); an always-mounted editor passes it constant and passes the real state as **`editOpen`**,
228
240
  which the ✓/✕ verbs read — they assert "there is an edit to accept or reject", so on a resting
@@ -263,9 +275,8 @@ verified on a wide screen is not verified.
263
275
  `autoGrow` makes the budget a minimum and fits the value. It costs nothing the fixed reserve was
264
276
  protecting, and the reason is worth stating because it is easy to assume otherwise: an
265
277
  `InlineTextInput` is ONE input in both states, never a resting view swapped for an editor, so a
266
- grown box is the same height focused as at rest measured identical to the pixel, box and
267
- origin. The reserve was guarding against a jump the one-element design had already made
268
- impossible. Growing on a control that DOES swap (number, date, time — their resting display is
278
+ grown box is the same height focused as at rest. The reserve was guarding against a jump the
279
+ one-element design had already made impossible. Growing on a control that DOES swap (number, date, time — their resting display is
269
280
  formatted) would reintroduce it, which is why the prop lives here and not on those.
270
281
 
271
282
  Two places to keep the reserve anyway: a value that can be **arbitrarily long** (growth has no