@lotics/ui 45.10.0 → 46.0.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/docs/catalog.md CHANGED
@@ -3,13 +3,23 @@
3
3
  The complete inventory of the kit, in two passes: **Reach by role** maps each data role /
4
4
  job (actions, members, selects, dates, files, money, status, …) to the ONE canonical
5
5
  component, and the **Full import inventory** lists every `@lotics/ui/<module>` entry point
6
- with its purpose. Read this before building any screen — reuse first; a component absent
7
- from this catalog does not exist, and one present here must never be hand-rolled. The
6
+ with its purpose. Read this before building any screen — reuse first, and never hand-roll
7
+ something listed here. Absence is the weaker signal: this file is a hand-written index of
8
+ `src/`, not `src/` itself, so an entry can be missing from a kit that ships the component —
9
+ the first bullet below is what to do about that. The
8
10
  composition patterns (data entry, AI review, templates) live in the sibling area docs
9
11
  indexed in [AGENTS.md](../AGENTS.md).
10
12
 
11
13
  ## How to read a component's API
12
14
 
15
+ - **Reuse first — and this catalog can LAG `src/`.** The kit's inventory is `src/`; this file
16
+ is a hand-written index of it, so an export lands in `src/` and in `package.json`'s `exports`
17
+ in one edit and gets its entry here in another. Before hand-rolling ANY capability, list and
18
+ grep the shipped sources — `ls node_modules/@lotics/ui/src/` — as well as reading this file.
19
+ A component in `src/` and missing here is a DOC BUG: from inside the kit it is fixed in the
20
+ same change, and from an app it is a reason to import the module anyway, never a licence to
21
+ hand-roll a second copy of what the kit already ships. Only the converse is absolute —
22
+ everything listed here exists, and what is listed here is never hand-rolled.
13
23
  - **Compose with React Native primitives**: `View` / `ScrollView` from `react-native` (never
14
24
  `div`/`span`), style with RN style objects (not CSS), and render every string through the `Text`
15
25
  primitive — the kit renders on web **and** native.
@@ -47,9 +57,7 @@ in place), `Chip` (dismissible facet chip), `CopyButton`
47
57
  A button is
48
58
  never a raw `Pressable`. For a link OUT (a URL / record / document) use `Link` — fixed
49
59
  underline+blue + `role="link"`, the destination signal (`onPress` only — the consumer wires
50
- the opener, e.g. the app SDK's `openExternal`); `TextLink` is the neutral,
51
- colour-configurable underlined marker. Underlined text GOES somewhere or REVEALS something and
52
- the ink says which — anything that MUTATES carries a control surface instead.
60
+ the opener, e.g. the app SDK's `openExternal`).
53
61
 
54
62
  ### Pick from a list
55
63
 
@@ -80,28 +88,23 @@ renders these per option.)
80
88
 
81
89
  **To answer "who IS this?" — `MemberPeek`**, the same chip made a door onto a
82
90
  `MemberProfileCard`: a 72px avatar, what they sign in as, their role, their teams and when they
83
- joined. The chip wears `marker` there a pressable that paints nothing at rest is text, and its
84
- hover wash does not exist on touch. Feed it
91
+ joined. The trigger takes `Peek`'s `variant="control"` there, so the chip seats in the 40px
92
+ band instead of under-filling it there is no `marker` prop. Feed it
85
93
  a member (an app's `ResolvedMember` already carries `groups`); `role` arrives ALREADY
86
- TRANSLATED, never a raw enum — the kit holds no domain vocabulary. Use `MemberProfileCard`
94
+ TRANSLATED, never a raw enum. Use `MemberProfileCard`
87
95
  alone wherever the card is not behind a press (a profile header, a drawer).
88
96
 
89
97
  **Where a peek must NOT go**, both cases being one rule — *two destinations behind one
90
- object*: a PICKER OPTION (`MemberSelect` / `InlineMemberSelect` render a chip per option, and
91
- a card over the open menu fires exactly when the reader is choosing), and a ROW THAT ALREADY
92
- PRESSES (the row opens the record; a chip inside it would open something else four pixels
93
- away). Those keep the plain `MemberChip`. A peek belongs where nothing else on the line
98
+ object*: a PICKER OPTION, and a ROW THAT ALREADY PRESSES. Those keep the plain `MemberChip`. A
99
+ peek belongs where nothing else on the line
94
100
  presses — a person named in prose, a static header, a detail row, a log whose rows do not
95
101
  navigate. **Judge the container, not its look:** a `DataGrid` group band names a person and
96
102
  reads inert, but it is a button that collapses the group, so a peek there nests a button in a
97
- button. A fully interactive screen often has NO valid spot — `examples/tpl_task_board.tsx`
98
- names a person three times (collapsing band, inline editor, picker option) and correctly
99
- peeks on none of them.
103
+ button. A fully interactive screen often has NO valid spot.
100
104
 
101
105
  **`groups` distinguishes absent from empty, and the card renders the difference**: omitted
102
106
  drops the row (this caller was never told — a public-app response, an older server), `[]`
103
- keeps it and says "None". Passing `[]` for "we don't know" states as fact that a colleague is
104
- on no team.
107
+ keeps it and says "None".
105
108
 
106
109
  **Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 28 / 40 / 72
107
110
  (`@lotics/ui/avatar_size`, shared by `Avatar`, `MemberChip` and `GroupAvatar`). Default `md`.
@@ -111,17 +114,14 @@ Pick by density:
111
114
 
112
115
  **Both middle rungs quote `control_surface`, and which one you want depends on whether the
113
116
  avatar is IN a control or IS one.** `md` is `CONTROL_CONTENT_HEIGHT` (28) — the largest rung a
114
- 40px control band seats without growing, and the height `InlineButton` and a `md` `IconButton`
115
- already used to sit inside a field. An avatar is the tallest thing in whatever row holds it, so
116
- a rung that overshoots doesn't look slightly big, it RESIZES the control. Put nothing taller
117
+ 40px control band seats without growing. Put nothing taller
117
118
  than `md` in a control. `lg` is `CONTROL_HEIGHT` (40) — the avatar that stands in a band on its
118
- own, an account menu beside a row of icon buttons, so it measures what they do. Anything bigger
119
- belongs in a row that sets its own height.
119
+ own. Anything bigger belongs in a row that sets its own height.
120
120
 
121
121
  **`sm` shows ONE initial; every larger rung shows two** — two letters are not legible at 24px.
122
- That is the practical reason not to reach for `sm` by reflex: on a real roster a single letter
123
- is rarely unique (nine people can share "N"), so a register that must identify someone wants
124
- `md`. Initials are the first and **last** word of the name — "Vũ Thị Lan" → VL, "Mary Jane
122
+ That is the practical reason not to reach for `sm` by reflex: a register that must identify
123
+ someone wants `md`. Initials are the first and **last** word of the name "Vũ Thị Lan" → VL,
124
+ "Mary Jane
125
125
  Watson" → MW — because a Vietnamese name's first two words are the family and middle names
126
126
  that a whole office shares.
127
127
 
@@ -150,9 +150,7 @@ The `Inline*` family: `InlineTextInput` (**`numberOfLines`** — the line budget
150
150
  reader must read WHOLE rather than recognise at a glance: a payment term, an address, a clause.
151
151
  Above 1 the resting box RESERVES that many lines — the full editor height, not the height this
152
152
  particular value needs — so clicking in moves neither the field nor anything below it; Enter
153
- then inserts a newline and the value commits on blur. Both halves matter: a field that only
154
- goes multiline once focused hides the text from the reader who never clicks, and one that
155
- reserves less than it opens to shoves the rest of the form down the page on every click.
153
+ then inserts a newline and the value commits on blur.
156
154
  **`autoGrow`** makes that budget a MINIMUM and fits the value instead — see
157
155
  [data_entry.md §Open prose grows](./data_entry.md). **`link`** marks a URL value with
158
156
  the navigation ink as a TREATMENT on the same single element — never a swapped-in
@@ -167,20 +165,16 @@ is mounted at rest and merely gains a draft on focus, so focusing moves nothing
167
165
  the prose stays SELECTABLE rather than sitting under a button. Its toolbar is off for
168
166
  that reason — a band that appears with the edit pushes everything under it down — and it
169
167
  passes its **`variant`** THROUGH to the editor, which is what actually draws the box:
170
- the frame paints no surface here, so a variant that stopped at it stopped at nothing and
171
- every markdown field wore the document sheet, 4px right of the plain-text field above it.
168
+ the frame paints no surface here.
172
169
  **`numberOfLines`** is the reserve, as everywhere else in the family); a
173
170
  READ-ONLY field in that same column uses `InlineStatic` (matches the editor box exactly, no
174
171
  input chrome, so it aligns pixel-for-pixel). **`MarkdownEditor`** is that editor standalone — a WYSIWYG whose value stays a plain
175
- markdown string, with a formatting toolbar as a SIBLING of the field (what the border
176
- encloses is the document; the controls that act on it sit outside it). Web is
172
+ markdown string, with a formatting toolbar as a SIBLING of the field. Web is
177
173
  ProseMirror via `@lotics/markdown-editor`; native falls back to raw-text editing, and
178
174
  the platform-neutral `MarkdownEditorProps` is what stops the two drifting. **`variant`**
179
175
  is `"document"` (the default soft sheet) or the two FIELD states, `"framed"` / `"bare"`,
180
176
  DERIVED from `InlineEditVariant` so they cannot drift from the rest of the family. A field
181
- takes the control's corner and the control's text inset instead of the sheet's. The default
182
- is NAMED rather than left as absence: an optional prop whose omission is a distinct third
183
- mode reads exactly backwards to anyone who knows `TextInputField.variant`. Wrap it in a
177
+ takes the control's corner and the control's text inset instead of the sheet's. Wrap it in a
184
178
  label with **`FormMarkdownEditor`**. Pass **`accessibilityLabel`** whenever the visible
185
179
  label is a sibling rather than a wrapping `FormField` — a `DetailRow`, an inline
186
180
  editor's frame — or the editable surface is an unnamed text box. A stack of labelled
@@ -198,13 +192,9 @@ axes carry every shape it takes, and BOTH are decisions about meaning, not looks
198
192
  have.
199
193
  - **`mark`** — does ticking mean DONE or CHOSEN? `check` is a ring, `select` is the square box.
200
194
  Reach for the square whenever the row is PICKED rather than finished — which forms to produce,
201
- which records to export. Spending the ring on a choice is the failure this axis exists to
202
- prevent: a reader who has learned that a filled ring means "done" reads a ticked pick-list as
203
- work already carried out, and nothing on screen corrects them. (The shapes are 24px either way
195
+ which records to export. (The shapes are 24px either way
204
196
  and share one gutter, so a surface swaps between them without moving anything.)
205
197
 
206
- A record's Progress and a desk's to-do list are the same component two booleans apart.
207
-
208
198
  **A row can carry a DECIDED outcome instead of a tick — `ChecklistItem.verdict` (`"pass"` /
209
199
  `"fail"`).** This is a per-ROW axis, not a list-level one: a QC gate, a reconciliation match,
210
200
  any row whose mark means an inspection SETTLED rather than work getting done. `fail` renders in
@@ -216,21 +206,12 @@ no-op (withheld internally), because neither of the toggle-capable marks (`Check
216
206
  pass/fail should drive that from its own control, then set `verdict` from the result, never
217
207
  reach for `done`+`onToggle` on the same row a verdict already settled.
218
208
 
219
- **A row carries its TITLE and its own stamp, and nothing else.** A checklist REPORTS: it does not
220
- collect, and it does not PREVIEW. A field some section owns — an assignee, an address, a portal
221
- login — is NAMED as a gap by a `ChecklistNote` whose `action` jumps to that section, never edited
222
- in the row (two edit surfaces for one fact) and never rendered as a value (an avatar or badge is
223
- a copy that has to be kept in step). The exceptions are narrow and stated: `ChecklistItem.trailing`
224
- for the row's OWN stamp (a tick writes today, so a run filled in after the fact needs the
225
- correction in reach), and `ChecklistField` for a value with NO other home.
226
-
227
209
  **A list you scan DOWN columns — the same four values compared across twenty rows — is a
228
210
  `Table`.** Grouping, filters and a per-row register belong there.
229
211
 
230
212
  **A record's work state is a connected checklist** ([`tpl_record`](../examples/tpl_record.tsx)
231
- § Progress), where the desks are `ChecklistGroup`s and the milestones are rows. A per-desk
232
- checklist without the connecting line described the work but never said where the record SAT, and
233
- put the CTA a section away from the state that gated it. A register's workspace drawer renders the
213
+ § Progress), where the desks are `ChecklistGroup`s and the milestones are rows. A register's
214
+ workspace drawer renders the
234
215
  same record, so it follows automatically; for a surface whose SUBJECT is tasks see
235
216
  [`tpl_task_board`](../examples/tpl_task_board.tsx).
236
217
 
@@ -259,16 +240,15 @@ Two columnar shapes, and the choice is about data size:
259
240
  heading, so digits line up under their label; **`DataGridColumn.footer` gives that column its
260
241
  cell in the group's footer row** (a subtotal, a resolved measure) — the grid lays it out in
261
242
  the column's own box, so a total can never drift from what it sums and no consumer rebuilds
243
+ the row. `renderGroupFooter` remains for content that does NOT sit in a column (a per-group
244
+ add row). The grid takes exactly its container's width — no outdent to fight, so it needs no
245
+ scroller unless YOU add one.
262
246
  **`DataGrid` SHEDS NOTHING at narrow widths, and that is deliberate** — every column is a
263
247
  measure you came to compare, unlike `Table`'s register of objects where the row is an index
264
248
  and the record holds the rest. So it has no `priority` and no stacked mode: fixed columns keep
265
249
  their widths, the flexible column holds a `FLEX_MIN_WIDTH` floor, and past that the grid
266
- OVERFLOWS. A narrow container must scroll it horizontally; the floor exists because without
267
- one the flexible identity column absorbed the entire squeeze down to zero and the grid
268
- rendered rows of unlabelled numbers, silently. If you want columns to drop, you want `Table`.
269
- the row. `renderGroupFooter` remains for content that does NOT sit in a column (a per-group
270
- add row). The grid takes exactly its container's width — no outdent to fight, so it needs no
271
- scroller unless YOU add one. It renders ALL rows (no
250
+ OVERFLOWS. A narrow container must scroll it horizontally. If you want columns to drop, you
251
+ want `Table`. It renders ALL rows (no
272
252
  virtualization), so it's only for sets small enough to hold in view — at 10k+ it lags, and
273
253
  grouping + pagination/infinite don't compose; use the register instead. Worked example:
274
254
  [`tpl_task_board`](../examples/tpl_task_board.tsx) (a `CheckCircle` leading).
@@ -279,10 +259,9 @@ Two columnar shapes, and the choice is about data size:
279
259
  — below the toolbar, from the filtered rows), `KPICard` / `Metric` (headline figures),
280
260
  `TrendChip` (delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG
281
261
  set — no recharts), `ProgressRing`, `ProgressBar` (its `compact` prop = ONE row, track + a
282
- plain sm tabular count beside it — the cell/heading/peek-trigger meter; a caption floating
283
- above a tiny bar reads misaligned. **The track clamps at 100%, the caption does not** over
284
- its max it reads `2,100 / 2,000 (105%)`, because a meter that says "100%" when you are over
285
- tells the reader they are exactly at the limit. Numbers format in the reader's locale, so
262
+ plain sm tabular count beside it — the cell/heading/peek-trigger meter. **The track clamps at
263
+ 100%, the caption does not** over its max it reads `2,100 / 2,000 (105%)`. Numbers format in the
264
+ reader's locale, so
286
265
  never hand-format the value you pass in — when display precision differs from the true value
287
266
  (whole credits off a fractional balance), `formatValue` reshapes the caption text and leaves
288
267
  the fill and the percentage exact) / `StackedProgressBar` (ONE whole split across segments,
@@ -294,12 +273,12 @@ stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long ta
294
273
  "Show N more" toggle — `labels` to localize — so several facet cards align to one height in
295
274
  a row), `Funnel` (a CONVERSION funnel — ordered stages as bars that NARROW; the step
296
275
  conversion rate is the HEADLINE (a bold aligned row across the top, the first stage = the
297
- 100% baseline), the count is the supporting figure below — the Amplitude/Mixpanel
298
- convention, never pin the rate to the fill height. `orientation` vertical columns |
276
+ 100% baseline), the count is the supporting figure below — never pin the rate to the fill height.
277
+ `orientation` vertical columns |
299
278
  horizontal bars; pass `onSelect`+`selectedKey` to make the bars press-to-drill (the selected
300
279
  stays solid, others dim — the caller renders the records). The subset/drop-off sibling of
301
280
  `StackedProgressBar` — nested cohorts that shrink "calls → connected → won", NOT a whole
302
- split across stages — that's `StackedProgressBar`), `StatusGrid` + `StatusLegend`, `Heatmap`
281
+ split across stages), `StatusGrid` + `StatusLegend`, `Heatmap`
303
282
  (density: colour-only, "where does it cluster"), `Matrix` (the PIVOT cross-tab: the NUMBER
304
283
  in each cell — optionally a heat wash behind it — plus row/column/grand totals; press a cell
305
284
  to drill).
@@ -329,11 +308,8 @@ action/badge/unit), `ActionMenu` (⋯), `FloatingActionBar` (bulk-select bar).
329
308
 
330
309
  A DESIGNED row (your own pile, card, or panel) that presses open AND carries its own
331
310
  controls is `PressableRow` + **`PressDoor`** — never `PressableHighlight`, which IS a
332
- button and wraps its children (a button must not contain interactive descendants). The
333
- door is an empty absolutely-positioned SIBLING of the content carrying the tab stop,
334
- accessible name, and focus ring; lift the content above it with `zIndex: 1`. `Table`/
335
- `TableRow` does exactly this internally, so a columnar register needs no assembly —
336
- reach for `PressDoor` only outside it.
311
+ button and wraps its children (a button must not contain interactive descendants). `Table`/
312
+ `TableRow` does exactly this internally, so a columnar register needs no assembly.
337
313
 
338
314
  ### Filters & view controls
339
315
 
@@ -366,11 +342,9 @@ into a list *and* a grid is the mistake — it gives the reader two places to lo
366
342
  thing. The minority is not stranded by this: a non-image tile in a grid renders a `DocumentCard`
367
343
  printing its filename, and a photo in a list keeps its full name plus a meta line and is ONE tap
368
344
  from the picture, because a `FileRows` row press opens the built-in gallery. (The badge is
369
- MIME-driven, so that row shows a generic image mark, not the shot itself which is the cost the
370
- majority already decided to pay.) A screen showing BOTH surfaces (`tpl_record`) does
371
- so because it holds two distinct SETS the reader asks different questions of — the paperwork
372
- that arrived on the record, and the hand-over photos taken against it — not because one set was
373
- split by type.
345
+ MIME-driven, so that row shows a generic image mark, not the shot itself.) A screen showing
346
+ BOTH surfaces (`tpl_record`) does so because it holds two distinct SETS the reader asks
347
+ different questions of, not because one set was split by type.
374
348
 
375
349
  `FilesEditor` (THE attachment surface: an upload-aware grid whose bar you COMPOSE — the root
376
350
  owns selection + gallery + confirmed remove, the bar pieces and any HOST verb go below it via
@@ -392,8 +366,7 @@ action / remove; `onPress` makes the whole row a pressable door, `trailing` stay
392
366
  independently-pressable sibling; for checklists & readable lists. **`size`** — `sm` (default)
393
367
  is the compact attachment line for many files scanned as a list; **`md` is the document-desk
394
368
  row** (taller badge, `ListItem` height) for the FEW rows that ARE the section's subject — an
395
- expected-document checklist, a slot a button fills. A two-row section at `sm` reads as an
396
- appendix to the page rather than its point), `FileBadge` (the two-tone
369
+ expected-document checklist, a slot a button fills), `FileBadge` (the two-tone
397
370
  type mark), `FilePreview` / `FileGalleryModal`, `ImageGallery`; picking is `pickFiles`
398
371
  (`@lotics/ui/file_picker` — opens the browser picker and resolves the chosen `File[]`, the
399
372
  imperative half behind every Add-file CTA); for gated CRUD compose locally with
@@ -426,20 +399,14 @@ that same line, over the `readings` stacked ONE PER LINE — each `source value`
426
399
  joining them (a middot claims a relation it will not name, and is the one mark a screen reader
427
400
  drops), both kept on
428
401
  screen with NEITHER marked as the wrong one, since a finding reports that two sources disagree
429
- and which is right is the reader's call. `detail` is the optional consequence. A reader scanning
430
- a stack gets severity down the left edge and magnitude down the right. The readings ARE the
402
+ and which is right is the reader's call. `detail` is the optional consequence. The readings ARE the
431
403
  provenance, so there are no `Sources` chips under it repeating the same names. The children slot
432
404
  composes ANY visual result — a compact `Table` for per-line detail (danger color on the offending cells),
433
405
  `ProgressBar` for consumption-toward-a-cliff (free time, credit), dot `Badge`s for a
434
406
  present/missing checklist, `Confidence` for judgment calls. Display-only — it informs the
435
407
  verdict the host records; `finding` locale slice). `ApprovalPrompt` (the surface that ANSWERS
436
- an agent's approval-gated action the interactive counterpart to `AgentRun`'s read-only amber
437
- `awaiting` row: a warning icon + message on a bordered card, Deny/Approve right-aligned, an
438
- optional `children` input summary; slot it where the composer sits while the run is parked;
439
- `approvalPrompt` locale slice), `MessageActions` (THE single shared per-message chrome bar for a
440
- chat transcript — copy with owned copied-feedback, regenerate, edit, and the branch/version
441
- pager as an internal part; each action renders only when its handler is given, so the assistant
442
- bar and the user bar are one component with different handlers; `messageActions` locale slice).
408
+ an agent's approval-gated action), `MessageActions` (THE single shared per-message chrome bar
409
+ for a chat transcript).
443
410
 
444
411
  ---
445
412
 
@@ -452,15 +419,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
452
419
 
453
420
  - **`vite`** — `loticsOptimizeDeps`: the canonical `optimizeDeps.include` list a custom-code
454
421
  app's `vite.config.ts` must pre-bundle for `lotics app dev` to render (RN-ecosystem + markdown
455
- CJS-interop; dev-only — the prod rollup build resolves the interop without it). Ships WITH the
456
- kit so it can never drift from what @lotics/ui's transitive deps require across a major bump
457
- the scaffold's `vite.config.ts` imports it instead of hardcoding the list:
422
+ CJS-interop; dev-only — the prod rollup build resolves the interop without it). The
423
+ scaffold's `vite.config.ts` imports it instead of hardcoding the list:
458
424
  `import { loticsOptimizeDeps } from "@lotics/ui/vite"` → `optimizeDeps: { include: loticsOptimizeDeps }`.
459
425
  To add app-specific entries, spread: `include: [...loticsOptimizeDeps, "my-dep"]`. A Node-loadable
460
426
  `.mjs` leaf (a Vite config can't import a `.ts` from `node_modules`), zero-import by contract.
461
427
  - **`vite`** — `loticsResolve()`: the whole `resolve` block, for the same reason and on the same
462
- terms — `resolve: loticsResolve()`. Every entry is dictated by the kit's internals, so the kit
463
- owns them: the `react-native` → `react-native-web` alias (**load-bearing** — without it the RN
428
+ terms — `resolve: loticsResolve()`. The entries: the `react-native` `react-native-web` alias
429
+ (**load-bearing** — without it the RN
464
430
  primitives have no web implementation and the app does not build), `.web.tsx`-first `extensions`
465
431
  (per-target variants win; `.mjs`/`.mts` kept for parity with Vite's default resolver), `dedupe`
466
432
  for React + RN-Web (the kit is consumed across many subpaths, and a second React copy in one
@@ -468,8 +434,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
468
434
  read at call time so it lasts exactly one command. To add your own alias, spread:
469
435
  `const base = loticsResolve(); resolve: { ...base, alias: [...base.alias, entry] }`.
470
436
  `lotics app codegen` writes the matching `paths` into the app's `.lotics/tsconfig.link.json`,
471
- so `tsc`, vitest, eslint and the editor resolve the same kit Vite does — the dev-link is not a
472
- Vite-only trick that leaves every other tool checking the published package.
437
+ so `tsc`, vitest, eslint and the editor resolve the same kit Vite does.
473
438
 
474
439
  ### Text & formatting
475
440
 
@@ -495,18 +460,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
495
460
  `Icon` rather than rendering the wrong company's logo. Not for the app's
496
461
  own glyphs; that is `icon`. `x` is the one mark whose brand colour is BLACK, so it is the one
497
462
  that needs `muted` on a dark ground; the rest carry their own hue either way. **Not a product
498
- logo**: a monochrome stand-in for one reads as a different thing (a red "M" is not Gmail), the kit
499
- ships no vendor artwork, and official artwork is not uniformly inlinable anyway — pass your own
500
- image to `SwitchButton`'s `leading` slot instead.
463
+ logo**: a monochrome stand-in for one reads as a different thing, the kit ships no vendor
464
+ artwork, and official artwork is not uniformly inlinable anyway — pass your own image to
465
+ `SwitchButton`'s `leading` slot instead.
501
466
  - **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
502
467
  `AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
503
468
  native; takes a markdown `children` string. **`variant`** decides whose type
504
469
  ladder the headings belong to: `"document"` (default) where the markdown IS the
505
470
  surface, `"embedded"` where it is a VALUE on a page someone else designed — a
506
471
  field on a record, a summary in a feed row. Reach for `"embedded"` whenever the
507
- text was not written by whoever built the screen, which is now the common case
508
- because the writer is routinely a model; at document scale an `h1` it emits is
509
- the same rung as your `SectionHeadingTitle`. Embedded demotes the heading LEVEL as well as
472
+ text was not written by whoever built the screen; at document scale an `h1` it
473
+ emits is the same rung as your `SectionHeadingTitle`. Embedded demotes the heading LEVEL as well
474
+ as
510
475
  the scale (h1→h4, h2→h5, h3+→h6): sizing alone leaves a model's `##` announcing as a PEER of
511
476
  your own sections in heading navigation, which no visual check can see.
512
477
  - **`markdown_types`** — `MarkdownProps`; types only.
@@ -518,10 +483,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
518
483
  `getInputLineHeight` — the 16px-mobile/14px-desktop input contract that stops Safari iOS
519
484
  auto-zoom; only for hand-rolled raw inputs. It carries the **ink** as well as the size,
520
485
  because omitting `color` on a native `<input>` does not inherit — it falls to the UA
521
- default, pure black, against the `zinc-900` every resting value beside it uses. A field
522
- whose text darkens the instant it is focused reads as the text MOVING (at 14px a darkness
523
- change re-weights every stroke's antialiasing), which is a bug no box measurement can
524
- see. Hand-rolling a raw input means taking this style, not just its font size.
486
+ default, pure black, against the `zinc-900` every resting value beside it uses.
487
+ Hand-rolling a raw input means taking this style, not just its font size.
525
488
  - **`font_family`** — the three Inter stacks (`fontFamilyRegular` / `fontFamilyMedium` /
526
489
  `fontFamilySemiBold`). Weight in this kit is a FAMILY, not a `font-weight` axis: each rung is
527
490
  a separate file, which is why `Text`'s `weight` prop swaps the family rather than a number.
@@ -531,14 +494,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
531
494
  the ONLY themeable typeface. There is deliberately no body-face role: body weight is three
532
495
  coupled families, `text.css` hand-tunes tracking for Inter's glyphs and for Vietnamese
533
496
  diacritics at 12px, and `font-feature-settings: "cv11","ss01","ss03"` are Inter's own
534
- alternates. A variable for the body face would not fail loudly it would render every screen
535
- subtly miscalibrated. Changing the body face is a kit change, done once for everybody.
497
+ alternates. Changing the body face is a kit change, done once for everybody.
536
498
  - **`type_ramp`** — the type ramp as DATA: `TYPE_RAMP_MOBILE` / `TYPE_RAMP_DESKTOP` (size +
537
499
  leading per rung, the 768px breakpoint between them), `TYPE_TRACKING` (em per rung, negative
538
500
  throughout and steepening as the type grows), `TypeRung` / `TypeRungName` (`TextSize` IS this
539
501
  set), and `trackingPx` for platforms with no em unit. `Text` renders through two mechanisms —
540
502
  a `data-text-size` attribute matched by `text.css` on web, a `StyleSheet` on native — and this
541
- is what both derive from, so the two cannot disagree. Read it to learn what a rung IS; you
503
+ is what both derive from, so the two cannot disagree. You
542
504
  almost never need to import it, because `Text size=` is the way to ask for a rung. Reach for
543
505
  it only when styling something `Text` cannot wrap (a raw `TextInput`, a canvas, a chart axis)
544
506
  and the result has to sit at a rung exactly.
@@ -575,15 +537,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
575
537
  - **`spacing`** — the `SPACE` scale + `SpaceToken`, and **`pagePad(small)`** — the page's
576
538
  side gutter (`SPACE.md` on a phone, `SPACE.xl` wider). `PageContent` applies it; a screen
577
539
  that cannot use that shell — one with a rail, a side panel or its own scroller — imports it
578
- rather than picking a number, because a list and the record it opens are one reading column
579
- seen twice and must not shift sideways between them. It is also COUPLED to `ROW_WASH_BLEED`:
540
+ rather than picking a number. It is also COUPLED to `ROW_WASH_BLEED`:
580
541
  a register row bleeds its wash outward to `pagePad − ROW_WASH_BLEED`, so the two are read
581
542
  together and neither belongs to the app.
582
543
  - **`control_surface`** — `CONTROL_HEIGHT` (40), `CONTROL_CONTENT_HEIGHT` (28 — the tallest
583
544
  FIXED-height node a 40px band seats, 6 per side of air; what `InlineButton` and a `md`
584
545
  `IconButton` already used, now named so an avatar or any other child lands on it too. Text
585
- never needs it because `minHeight` governs a single line, which is exactly why three surfaces
586
- could each pick a different inset and only disagree once an avatar went in),
546
+ never needs it because `minHeight` governs a single line),
587
547
  `CONTROL_PADDING_V` (5 — derived from the pair, BORDER INCLUDED in the 6 per side; every 40px
588
548
  surface pads by it instead of a number of its own), `CONTROL_RADIUS` (10), `CONTROL_TEXT_INSET`
589
549
  (9 — how far a control insets its OWN text: 1px border + 8px padding; anything that must line
@@ -631,8 +591,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
631
591
  so a prop declared on one side typechecks everywhere and silently does nothing in the browser.
632
592
  - **`label_tone`** — `labelTone(label)`: one stable `ColorName` per free-text label (a tag, a
633
593
  topic, a category), for `Badge`. `avatarTone`'s sibling and the same discipline: pure and
634
- NEVER STORED, so a set of labels can stay a `text[]` instead of earning a table the moment a
635
- colour has to be chosen; `red` and `amber` excluded, because a palette that can paint an
594
+ NEVER STORED; `red` and `amber` excluded, because a palette that can paint an
636
595
  arbitrary noun red makes every red on the screen ambiguous; hue-interleaved so two labels in
637
596
  adjacent buckets still look different. Case- and space-folded, matching whatever normalizes the
638
597
  set — two spellings of one label must not be two colours. Returns a NAME (not a hex like
@@ -650,14 +609,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
650
609
  one cell. Takes `{ id, name, kind: "person" | "group", image? }`, shows `max` of them (default
651
610
  3) and makes the rest a `+N` that is the LAST MARK IN THE STACK, not loose text beside it.
652
611
  `size` is the shared avatar scale with the same `md` default as `Avatar`/`MemberChip`, and the
653
- marks, the overlap and the count's type all derive from it — no fixed pixels, so a stack at any
654
- rung is the same object. Three rules it exists to carry: the caller passes only principals it
612
+ marks, the overlap and the count's type all derive from it — no fixed pixels. Three rules it
613
+ exists to carry: the caller passes only principals it
655
614
  can NAME, so the overflow never counts a mark the reader sees is missing; a group is ONE mark
656
615
  bearing the group's name, never its members' faces (expanding it states a membership the reader
657
616
  may not be entitled to); and the separating hairline is **translucent ink, never the surface
658
617
  colour** — a register row washes to `zinc[50]` on hover, `accent_wash` when open and
659
618
  `zinc[200]` while pressed, so a ring painted the background is a halo in three of the four
660
- states, while a dark edge darkens any ground (the trick `FileBadge` already uses). Marks
619
+ states, while a dark edge darkens any ground. Marks
661
620
  `announce` because they stand alone here. INERT — its home is a register row, and a row that
662
621
  already presses must not hold a second destination (see `member_peek`). A single person →
663
622
  `MemberChip`; a single group → `GroupAvatar`.
@@ -672,8 +631,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
672
631
  - **`member_profile_card`** — `MemberProfileCard`: WHO IS THIS — `xl` avatar, name, sign-in
673
632
  identity, role, join date and groups, plus one optional `action`. Every row is conditional, so a
674
633
  card told nothing is a header and nothing else. `role` comes in pre-translated (vocabulary only
675
- the product can name); `joined` comes in as an ISO DATE and the card formats it to month + year
676
- (a date has a canonical form the kit owns, so two hosts formatting it themselves would drift);
634
+ the product can name); `joined` comes in as an ISO DATE and the card formats it to month + year;
677
635
  `groups` distinguishes absent (no row) from `[]` ("None"). `inactive` draws the departed state as
678
636
  a `Badge` — a lifecycle state, not another muted line.
679
637
  - **`member_peek`** — `MemberPeek`: `MemberChip` + `Peek` + the card above, with the trigger's
@@ -700,8 +658,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
700
658
  one opt-down). **Which rung it renders is the SURFACE's answer, not a prop:** `##` xl-semibold
701
659
  on a page, `####` md-semibold inside an overlay's content region (`DrawerScrollArea` /
702
660
  `DialogScrollArea` / `ModalBody` / `PopoverContent`), where the container's own title already
703
- occupies lg. The call site is identical on both, which is the whole point there is no
704
- per-surface variant to pick, so it cannot be picked wrong. A whole PAGE surface docked in an
661
+ occupies lg. A whole PAGE surface docked in an
705
662
  overlay (a record screen in a master-detail drawer) carries its own gutters and so sits in the
706
663
  container's BARE slot, keeping the page rungs; nesting overlays never compounds.
707
664
  `SubsectionHeadingTitle` is the `###` lg-semibold level-3
@@ -713,8 +670,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
713
670
  `primary` `Button` beside the title, rendered whether the collection is empty or full, never
714
671
  under the rows it extends and never repeated in the `EmptyState`
715
672
  (composition.md § The add-placement law). An overlay surface loses only the type size, never an
716
- affordance — `icon`, `description` and `info` are the same slots at both altitudes. **`description`
717
- is on every rung** — the ramp's slots do not vary by level; only the type size does. Inside an
673
+ affordance — `icon`, `description` and `info` are the same slots at both altitudes. Inside an
718
674
  overlay `####` is the ramp's FLOOR: `SubsectionHeadingTitle` has one altitude and would out-rank
719
675
  the section holding it, so a named group there is `Text size="sm" weight="semibold"`. The heading
720
676
  ramp is FIXED: `#` xxl / `##` xl / `###` lg / `####` md, no size props.
@@ -723,9 +679,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
723
679
  rung. The kit's content regions publish it, so an ordinary screen never imports this. Reach for
724
680
  it when you build your OWN pane — a docked side panel, a split view, any self-contained region
725
681
  whose own chrome caps at `lg` and which therefore uses none of those regions: wrap its content
726
- in `<HeadingAltitudeContext.Provider value="panel">` and every heading inside is right. Scoping
727
- the rule to the panes the KIT renders would make it an allowlist short by however many you
728
- build. Flat, never a depth counter — a popover inside a drawer is one rung down, not two.
682
+ in `<HeadingAltitudeContext.Provider value="panel">` and every heading inside is right.
683
+ Flat, never a depth counter a popover inside a drawer is one rung down, not two.
729
684
  - **`section_stack`** — `SectionStack`, `SubsectionStack` — stacks that own the
730
685
  between-block law, skipping null children: `SectionStack` = a fixed 56px beat + a hairline
731
686
  `Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only while
@@ -752,16 +707,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
752
707
  `left`/`right` form a separate nav row above. Split `trailing` from `actions` by what the
753
708
  control acts on: `actions` do something to the page's CONTENT (create, sort, export),
754
709
  `trailing` changes what is AROUND it (a side-panel toggle, a view switch). A panel toggle
755
- filed under `actions` reads as a peer of "create one of these". It sits AFTER the title
756
- because the title is the page's first word and nothing should come between the reader
757
- and it. Under a title too long for the row the TITLE gives way and wraps while `trailing`
758
- and `actions` keep their width — a wrapped title is merely taller, a control squeezed
759
- below its own icon is broken.
710
+ filed under `actions` reads as a peer of "create one of these". Under a title too long for
711
+ the row the TITLE gives way and wraps while `trailing`
712
+ and `actions` keep their width.
760
713
  - **`page_content`** — `PageContent` + `PAGE_SIZES`: the page's padded, width-capped content
761
714
  region — a centred column with optional `title`/`titleRight`/`description`, `header`/`footer`
762
- slots and `fullscreen`. **Reach for it before hand-rolling a screen shell**: a scroller, a
763
- centred `maxWidth` column and a title band re-typed per screen is how two pages of one app end
764
- up on different gutters. Its side padding is `pagePad` (`@lotics/ui/spacing`), so a screen that
715
+ slots and `fullscreen`. **Reach for it before hand-rolling a screen shell.** Its side
716
+ padding is `pagePad` (`@lotics/ui/spacing`), so a screen that
765
717
  genuinely cannot use it — one with a rail, a side panel, or a scroller it must hold a ref to —
766
718
  still lands on the same edge by importing that token. It OWNS its `ScrollView` and exposes no
767
719
  scroll props, which is the real limit on adoption.
@@ -808,21 +760,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
808
760
  - **`comments_button`** — `CommentsButton`: THE discussion affordance for a listed record — the
809
761
  count and a bubble as ONE control, opening the thread. Reach for it wherever a record is listed
810
762
  and its conversation would otherwise be invisible until opened (a register row, a card, a
811
- compact header); it is `CopyButton`'s peer — the other thing a dense row lets you press — and
763
+ compact header); it is `CopyButton`'s peer and
812
764
  shares its surface, scale and focus ring, so a row carrying both reads as one grammar. The count
813
765
  is INSIDE the target, never loose text beside an `IconButton`: the number is what the reader
814
766
  aims at, and splitting them leaves half the affordance dead under the pointer. FULL INK, unlike
815
- the muted values around it — everything else on a row is a fact ABOUT the record, this is people
816
- talking about it and the one thing there that can be UNREAD. `subject` reaches the accessible
767
+ the muted values around it — the one thing on the row that can be UNREAD. `subject` reaches the
768
+ accessible
817
769
  NAME only ("3 comments on Northwind Packaging" is a destination; "3 comments" on the fortieth row
818
770
  is not) — never the visible label, which would spend row width restating whose record it is. The
819
771
  pack's `commentsButton.withSubject` builds that phrase, so the word ORDER is the pack's to choose
820
- and the join is a WORD: this string exists to be read aloud, and a screen reader drops
821
- punctuation along with the relation it was carrying.
822
- **Render it only when `count > 0`** — a zero on every quiet row is a column of noise that trains
823
- the eye to skip exactly where the signal will appear. Pair it with a jump that LANDS on the
824
- thread (`tpl_item_list` opens the record's drawer on its comments section), because a count the
825
- reader cannot act on states that a conversation exists and leaves them to find it.
772
+ and the join is a WORD a screen reader drops punctuation along with the relation it was
773
+ carrying.
774
+ **Render it only when `count > 0`** — a zero on every quiet row is a column of noise. Pair it
775
+ with a jump that LANDS on the
776
+ thread (`tpl_item_list` opens the record's drawer on its comments section).
826
777
  - **`copy_button`** — `CopyButton`: THE copy control — `value` onto the clipboard, glyph
827
778
  flips to a check and the tooltip to the pack's "Copied" for ~2s. The confirmation follows
828
779
  a REAL write: an unreachable clipboard throws and the button stays at rest rather than
@@ -844,8 +795,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
844
795
  `nav.back` — "Back" / "Quay lại"). Bare it is the 40px chevron-left disc; pass
845
796
  **`label`** to name where back GOES ("Danh sách") — the SAME disc with the destination
846
797
  beside it, ONE pressable over disc + words, never an icon button beside unclickable
847
- prose. It takes no size or colour: one purpose, one look, and both variants draw the
848
- one disc so they cannot drift.
798
+ prose. It takes no size or colour.
849
799
  **A back affordance INSIDE a header row is not this** — beside a drawer's title a 40px
850
800
  disc outweighs the name it belongs to, so that one is a small `IconButton` (see
851
801
  `screen_router` below). The distinction is prominence: this HEADS a surface, that
@@ -893,8 +843,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
893
843
  - **`floating_action_bar`** — `FloatingActionBar`: the floating bulk-select action bar.
894
844
  - **`pressable_row`** — `PressableRow`: THE register row — full-width hover/open wash,
895
845
  `selected` (the open record) vs `marked` (ticked in bulk-select), forwards `ref` for
896
- popover anchoring. **Its text is SELECTABLE**: a register row is a button wrapped around
897
- data, so dragging across a name and copying it works, and a release that selected text
846
+ popover anchoring. **Its text is SELECTABLE**: dragging across a name and copying it
847
+ works, and a release that selected text
898
848
  does not open the record (see `press_selection`). Furniture that must not smear over —
899
849
  `Badge`, `Avatar`, `Button` — opts out for itself.
900
850
  The guard behind it is internal (`press_selection`): only a real, non-empty selection
@@ -952,21 +902,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
952
902
  the section's add (that lives on the heading row, where it does not move — § The add-placement
953
903
  law in composition.md), a no-results empty is HINT-only since the filters that emptied it carry
954
904
  their own clear, and a FAILED read is `ErrorState`, not this. **`compact`** for one BAND of a
955
- record — left-aligned, glyph-less, 56px against the default's 178 because the
956
- heading above it has already proved the surface rendered; the centred default speaks for a
957
- whole region, where nothing else is in view to say so. → composition.md § Empty states.
905
+ record — left-aligned, glyph-less, 56px against the default's 178; the centred default speaks
906
+ for a whole region. composition.md § Empty states.
958
907
  - **`error_state`** — `ErrorState`: the region-scale FAILED read — `message` + optional `detail`
959
908
  + `onRetry` (the kit renders the button and words it from the locale pack, so "try again" reads
960
909
  the same everywhere). The fourth of the region states: `Skeleton`/`Loading` in flight → this on
961
- failure → `EmptyState` on nothing → `CompletionState` on done. Rendering a failure as an empty
962
- asserts the read succeeded and found nothing, when nothing is known. For a failure INSIDE a
910
+ failure → `EmptyState` on nothing → `CompletionState` on done. For a failure INSIDE a
963
911
  flow (a form that won't save) use `Callout tone="error"`; this is for a region with no content
964
912
  to show, where a tinted strip leaves the area collapsed.
965
913
  - **`completion_state`** — `CompletionState`: the "all done" terminal state.
966
914
  - **`skeleton`** — `Skeleton`: loading placeholder blocks. **Mirror the layout that is
967
915
  arriving** — same row count, same column widths, same heights. A skeleton of a different
968
- shape makes the content jump when it lands, which is more disruptive than an empty box
969
- would have been. First of the four region states; the other three are under `error_state`.
916
+ shape makes the content jump when it lands. First of the four region states; the other three are
917
+ under `error_state`.
970
918
  - **`loading`** — `Loading`: the centered indeterminate loading state (composes
971
919
  `DotsIndicator`).
972
920
  - **`activity_indicator`** — `ActivityIndicator`: the bare spinner.
@@ -984,8 +932,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
984
932
  also home of the shared `PickerOption` type. Its empty row does one of two jobs and they
985
933
  are not interchangeable: `includeEmptyOption` makes empty a **choice** the reader picks to
986
934
  clear the field, so it is NAMED ("None", from the locale pack; override per instance with
987
- `emptyOptionLabel`) and keeps that name after a selection — a name that disappears once
988
- something is chosen is missing exactly when it is the only route back to empty. Without
935
+ `emptyOptionLabel`) and keeps that name after a selection. Without
989
936
  that flag the row is a **placeholder**: it carries `placeholder` and leaves on selection.
990
937
  Don't reach for `placeholder` to name a choice — a hint and a value read differently.
991
938
  Inside a `FormField` (so inside `FormPicker`) it takes its NAME from the visible label
@@ -996,8 +943,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
996
943
  `renderSelected(item, { remove })` + `searchable` + `allowCustom` — the tag field is just
997
944
  a multi Select; opens `OptionList`. **Set `searchPlaceholder` whenever `allowCustom` is on**:
998
945
  the create row only exists once there is a query, so on an untyped menu the placeholder is the
999
- only always-visible place that can say a new value is allowed a tag field reading "Search…"
1000
- tells the reader the vocabulary is closed. `customOptionLabel` names what the create row will
946
+ only always-visible place that can say a new value is allowed. `customOptionLabel` names what
947
+ the create row will
1001
948
  do, with the value in it. Its trigger is `role="combobox"` (a `<div>`), never
1002
949
  `role="button"`: the chip box legitimately puts a remove button INSIDE the trigger, and a
1003
950
  real `<button>` may not contain one. **Any trigger that renders caller-supplied content
@@ -1010,13 +957,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1010
957
  opens — single/multi, optional internal search, create row, keyboard + native-`<select>`
1011
958
  typeahead; host it directly in a `Popover`/`Dialog` for a command palette. A chosen row is
1012
959
  `selected`: the kit's `zinc.100` resting highlight AND `aria-selected`, one weight above the
1013
- `zinc.50` the keyboard/hover row takes — a check glyph alone said nothing to a screen reader.
960
+ `zinc.50` the keyboard/hover row takes.
1014
961
  It opens SCROLLED to its seat (the selected row in `search.mode:"none"`, else the first real
1015
- option), which only shows on a list longer than its own box and is the difference between a
1016
- 96-row list opening on your value and opening at the top.
962
+ option), which only shows on a list longer than its own box.
1017
963
  `indeterminateValues` (multi) draws a MIXED tick on values only SOME of the things being
1018
- edited carry — the shape for labelling a selection, where an empty box would say "none of
1019
- these" and be wrong. Presentational and one-directional: a mixed row is not in `value`, so
964
+ edited carry — the shape for labelling a selection. Presentational and one-directional: a mixed
965
+ row is not in `value`, so
1020
966
  pressing it selects (the caller applies to all), and it announces itself in the row label
1021
967
  because `role="option"` has no ARIA state for a third value.
1022
968
  - **`combobox`** — COMPOUND single-select editable search: `Combobox` root +
@@ -1060,6 +1006,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1060
1006
  field with an empty slot reserves nothing, so its text starts and ends where it always did.
1061
1007
  - **`number_input`** — `NumberInput`: numeric input; `format` for currency/units.
1062
1008
  - **`search_input`** — `SearchInput`: the search box for toolbars/filters.
1009
+ - **`markdown_editor`** — `MarkdownEditor`: the standalone WYSIWYG whose value stays a plain
1010
+ markdown string, with the formatting toolbar as a SIBLING of the field. `variant` is
1011
+ `"document"` (the default soft sheet) or the two FIELD states `"framed"` / `"bare"`, DERIVED
1012
+ from `InlineEditVariant`; the editor draws its own surface, so a host frame never paints one
1013
+ under it. Web is ProseMirror via `@lotics/markdown-editor`, native falls back to raw-text
1014
+ editing — see § Edit a record's fields in place for the whole family and its `numberOfLines`
1015
+ reserve. **`markdown_editor_props`** is the platform-neutral `MarkdownEditorProps` /
1016
+ `MarkdownEditorVariant` that keeps the two implementations from drifting; **`markdown_editor.css`**
1017
+ is the web sheet, imported once at the app entry.
1018
+ - **`form_markdown_editor`** — `FormMarkdownEditor`: `FormField` wrapping a `MarkdownEditor` —
1019
+ the labeled prose field, for a dialog or settings form.
1063
1020
  - **`form_field`** — `FormField` + `useFormField`: label / description / warning / error /
1064
1021
  `optional` marker wrapper (the three field-annotation tones — muted / amber / danger — all
1065
1022
  wired into the input's describedby); the fieldset grid cell (`half`/`full` widths — see the
@@ -1083,7 +1040,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1083
1040
  switch (`accessibilityRole="switch"`, the inner Switch read-only). The settings-panel/menu
1084
1041
  row toggle. The leading slot takes EITHER `icon` (an `IconName`, the app's own glyph language)
1085
1042
  or `leading` (any node); `icon` wins if both are passed. A row naming an outside product passes
1086
- its logo through `leading`, because the kit ships no vendor artwork of its own.
1043
+ its logo through `leading`.
1087
1044
  - **`use_form`** — `useForm`: THE batch draft-form state hook — `values` = `initialValues` +
1088
1045
  an edits overlay (a revalidation refreshes untouched fields, no sync effect), `validate`
1089
1046
  (sync/async, gates submit, editing clears the field's error), `onSubmit(values, helpers)`
@@ -1114,7 +1071,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1114
1071
  - **`date_filter`** — `DateFilter`: the date+time period filter panel — presets
1115
1072
  (`PresetId`), calendar, optional time segments; the body `DateRangeFilterField` opens.
1116
1073
  **Every preset SETS a range** — the list holds no mode and no null case, so a hand-picked
1117
- range selects nothing rather than lighting a "custom" row whose click cleared the value. The
1074
+ range selects nothing. The
1118
1075
  presets are a `listbox` of `MenuButton` options: selection is the kit's `zinc.100` row
1119
1076
  highlight plus `aria-selected`, never a filled primary treatment, because the row reports
1120
1077
  state rather than offering an action. Sidebar at viewports of 768 and up, strip below —
@@ -1150,13 +1107,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1150
1107
  `DeadlineThresholds`, `Deadline`). Three contracts a screen must not invent for itself — how
1151
1108
  far off a date is, what to CALL that number, and how loud it reads. `daysUntil` collapses both
1152
1109
  ends to midnight because the vocabulary is day-granular: rounding the raw gap lands on the
1153
- wrong day whenever now and the deadline straddle noon (a 02:00 cut-off tomorrow reads "today";
1154
- one at 02:00 today reads "1 day overdue" 12 hours in). Wording comes from the `deadline` locale
1110
+ wrong day whenever now and the deadline straddle noon. Wording comes from the `deadline` locale
1155
1111
  slice; the 1-day/3-day tone lines are freight defaults and move per domain via
1156
1112
  `DeadlineThresholds`. **`deadlineAnnotation` spreads onto the `DetailRow` holding the date** —
1157
1113
  a deadline is a property OF that field, and the three annotation slots carry the three urgency
1158
- levels exactly (`error` / `warning` / `description`), so it never becomes a trailing badge
1159
- speaking a vocabulary the rest of the field grid does not. `nearestDeadline` returns an OVERDUE
1114
+ levels exactly (`error` / `warning` / `description`). `nearestDeadline` returns an OVERDUE
1160
1115
  milestone FIRST and expects the caller to pass only OPEN ones — done-ness is not knowable from
1161
1116
  a date, and skipping past a missed cut-off is how a record silently stops reporting the failure.
1162
1117
  Worked example: `tpl_record`'s Due rows.
@@ -1204,8 +1159,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1204
1159
  same shape `Select` gives: return `<Chip onDismiss={remove}>` for a removable tag, or a plain
1205
1160
  badge that ignores it — a dense grid column is the case for the badge, since the ✕ costs more
1206
1161
  width there than the reopen it saves. `remove` detaches that one tag whether the list is open or
1207
- shut, and never opens the list the innermost responder takes the gesture, so a inside the
1208
- trigger needs nothing to hold the press back. Single has no `remove`: unsetting one value is `onClear` and its
1162
+ shut, and never opens the list. Single has no `remove`: unsetting one value is `onClear` and its
1209
1163
  Clear row. Both modes take
1210
1164
  `allowCustom` (a create-a-tag/option row) + `searchable` + **`customOptionPlacement`** (default
1211
1165
  `"bottom"`, right for a tag field; pass `"top"` for a find-or-create REFERENCE picker over a long
@@ -1234,9 +1188,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1234
1188
  surface already promises and, down a column, draws the grid twice.
1235
1189
 
1236
1190
  It is an axis of weight, not of use: both hover and open identically. There is no `*Cell` family —
1237
- a data-grid cell is `<InlineSelect variant="bare" …/>`, not a `SelectCell` (naming a component for a
1238
- USE, and duplicating the picker stack to flip two style properties, are both things the kit forbids).
1191
+ a data-grid cell is `<InlineSelect variant="bare" …/>`, not a `SelectCell`.
1239
1192
  Hand-roll a pressable cell to match `bare`, never a background wash.
1193
+ - **`inline_markdown`** — `InlineMarkdown`: the PROSE member of the Inline\* family — a
1194
+ `MarkdownEditor` on the inline-field box metrics, `onSave` per field. It is the one member
1195
+ that does NOT swap: the editor is mounted at rest and merely gains a draft on focus, so
1196
+ focusing moves nothing and the prose stays SELECTABLE instead of sitting under a button. Its
1197
+ toolbar is off for the same reason, and `variant` passes THROUGH to the editor, which draws
1198
+ the box. Full note under § Edit a record's fields in place.
1240
1199
  - **`inline_files`** — `InlineFiles`: the FILES member of the Inline\* family — a record's
1241
1200
  attachments as a `DetailRow` value. A `FileRows` LIST (press a row to preview full-screen, ⋯
1242
1201
  to remove) with ONE CTA under it; no selection mode, no bulk bar, no upload queue — reach for
@@ -1244,8 +1203,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1244
1203
  is the FIELD's cardinality and it changes the verb**: many → the CTA reads `addMoreLabel` and
1245
1204
  the host APPENDS in `onAdd`; one → it reads `replaceLabel` and the host SETS, because "add
1246
1205
  another" is not an act a single-file field can perform and offering it is how a record ends up
1247
- holding two of something the schema says there is one of (the component owns the affordance,
1248
- it cannot own the persistence). `blockedReason` renders a sentence instead of the CTA for a
1206
+ holding two of something the schema says there is one of. `blockedReason` renders a sentence
1207
+ instead of the CTA for a
1249
1208
  field with nothing to attach TO yet — never a disabled button, which says only that you may
1250
1209
  not. A LIST and not switchable: documents are identified by NAME, images by CONTENT, so a
1251
1210
  photo field wants `FileGrid`/`FilesEditor` instead — that is a judgment about the data, so it
@@ -1295,8 +1254,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1295
1254
  unit (though "kg"/"$" belong IN the value via `InlineNumberInput format`) — composes into a
1296
1255
  row inside the value cell, costing only the row that wants it. The LABEL WRAPS inside its column and
1297
1256
  is never clipped — a fixed `labelWidth` would otherwise ellipsize every long field name
1298
- ("Registered business address"), and a name the reader can't finish is worse than a taller
1299
- row; a wrapped label's FIRST line stays level with the value's first control line while a
1257
+ ("Registered business address"); a wrapped label's FIRST line stays level with the value's first
1258
+ control line while a
1300
1259
  one-line label still centers on it (both modes, no prop). The FIELD-ANNOTATION vocabulary (same
1301
1260
  names + meanings as `FormField`), always EXPLICIT — a row never hides guidance behind an ⓘ:
1302
1261
  **`description`** = a fact / persistent guidance (muted), under the VALUE (stacked mode
@@ -1339,10 +1298,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1339
1298
  into a pressable door floating its particulars in an anchored popover (put links INSIDE the
1340
1299
  peek — never a button in a button; `reference` is the trailing-link alternative for static
1341
1300
  rows), `LedgerTotal` = the divider-set emphasized close with `zeroLabel` for settled. **`meta` is a
1342
- neutral QUALIFIER** (a date, a method) at caption weight — never a problem or a state: one
1343
- row's meta being a fact and another's a complaint is what makes a column read inconsistent,
1344
- and a caption is the wrong weight for something wanting action. A problem goes on the row
1345
- that can FIX it, and in a statement the arithmetic has usually said it already.
1301
+ neutral QUALIFIER** (a date, a method) at caption weight — never a problem or a state: a
1302
+ caption is the wrong weight for something wanting action. A problem goes on the row
1303
+ that can FIX it.
1346
1304
  **Pick the shape by what the statement IS**, because there are two and they read differently:
1347
1305
  - **`charges → total`** — `LedgerGroup`s, each **closed by its own sum** ("Charges" vs
1348
1306
  "Received"), giving three ascending rungs: row `sm/regular`, subtotal `sm/medium`, total
@@ -1368,10 +1326,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1368
1326
  whose lookup link sits INSIDE the popover, a flat `reference` on the row with nothing to
1369
1327
  expand, `meta` + `success` tone on money coming back, and a one-row `Adjustments` group that
1370
1328
  takes no `total`. `Received` is derived from the charges carrying a payment METHOD rather than
1371
- a second flag to keep in step, and the refundable deposit stays OUT because a ledger earns
1372
- trust by summing exactly what its label claims. `LedgerBasis` is demoed on the gallery's
1373
- Charts page instead — a delivery order has no base to compute from, and inventing one to
1374
- place a component is how invented needs start.
1329
+ a second flag to keep in step. `LedgerBasis` is demoed on the gallery's Charts page instead.
1375
1330
  - **`running_ledger`** — `RunningLedger`: a CHRONOLOGICAL, self-totalling ledger — a stock
1376
1331
  kardex, a statement of account — where every row carries the balance AS OF that row. Not
1377
1332
  `Ledger`: that compound closes a fixed set of charge/receipt groups into one total and has no
@@ -1379,23 +1334,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1379
1334
  `rows` (date, label, a SIGNED `amount` — positive in/debit, negative out/credit, one field
1380
1335
  rather than paired columns so the same shape serves a stock quantity and a money statement),
1381
1336
  each rendered with its running balance **computed by the component**, never accepted as a
1382
- caller-supplied number — a running balance is exactly the figure that must equal what it
1383
- accumulates, and a caller-supplied one is the one kind of drift no reader can catch by looking.
1337
+ caller-supplied number.
1384
1338
  `format` is ONE formatter for the whole ledger (`formatMoney`, or a quantity formatter with a
1385
1339
  unit suffix) — every row shares one unit, which is what lets the column state it once.
1386
1340
  `criticalAtOrBelow` reads a row's balance in danger tone once it reaches a caller-declared
1387
1341
  floor (a stock-out, a negative balance) — **the ONLY tone this component owns**, evaluated
1388
1342
  independently on EVERY balance in the column: each row, the opening anchor (so an already-critical
1389
1343
  opening reads danger even with zero rows following it), and the closing line. A row's `amount`
1390
- is never auto-toned by its arithmetic sign, because an OUT movement is routine business as often
1391
- as an IN one and colouring every negative red claims a meaning the number alone does not carry. Closes with a `Divider`-set current-balance line at `Ledger`'s own
1344
+ is never auto-toned by its arithmetic sign an OUT movement is routine business as often
1345
+ as an IN one. Closes with a `Divider`-set current-balance line at `Ledger`'s own
1392
1346
  `LedgerTotal` weight, `closingLabel` overriding the locale default ("Current balance") for a
1393
- domain the pack cannot own generically ("Tồn kho hiện tại"). No `examples/` worked example yet
1394
- — a stock kardex or an AR statement of account is a genuinely different shape from every
1395
- existing template (the 3-statement accrual grammar in `tpl_statements`, the fleet-register
1396
- drawer in `tpl_stock`), so forcing it into either would be a mismatch rather than a worked
1397
- example; proven against real, live deployed consumers instead (a warehouse and a receivables
1398
- app) until a template of its own earns its place.
1347
+ domain the pack cannot own generically ("Tồn kho hiện tại").
1399
1348
  - **`reference_field`** — `ReferenceField`: a reference to ANOTHER RECORD, rendered as a
1400
1349
  FIELD VALUE — the kit's inline-editor surface (so a pointer sits in the value column
1401
1350
  like the editors above and below it), whose press opens a PEEK of that record's facts.
@@ -1408,23 +1357,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1408
1357
  VALUE floor (200), not the control floor, because that column holds names as well as editors.
1409
1358
  The verbs live INSIDE the
1410
1359
  peek, never on the field, and they are ALL `Button`s at one altitude — a peek is
1411
- dialog-scale and a dialog's verbs are buttons, so mixing weights only made the reader rank
1412
- four acts that are four acts. The row splits by WHAT EACH TOUCHES, and the spacer is that
1360
+ dialog-scale and a dialog's verbs are buttons. The row splits by WHAT EACH TOUCHES, and the
1361
+ spacer is that
1413
1362
  seam: **left, the LINK** — `Change` (`onChange`, `secondary`) points the field at a different
1414
1363
  record, named for the intent and deliberately not `danger` since a recoverable correction must
1415
1364
  not wear the colour reserved for destruction; `Clear` (`onClear`, NO fill — the least-reached
1416
1365
  verb of the four) leaves it empty. The draft's footer follows the same rule: `Cancel` carries
1417
1366
  no fill beside a filled `Save`, so the only filled button in either mode is the one that acts. **Both are REQUIRED**, so the footer has one shape
1418
- everywhere; a reference the record cannot do without gains nothing from hiding Clear, because
1419
- Change and then declining to pick reaches the same empty state. **Right, the RECORD it points at** — `Edit` (`primary`, the one filled-dark rung,
1367
+ everywhere. **Right, the RECORD it points at** `Edit` (`primary`, the one filled-dark rung,
1420
1368
  handed to `Save` when the draft opens) and `Open` (`secondary`, rightmost, OPTIONAL, the
1421
- departure — not prioritized, because on a peek whose facts are correctable in place leaving is
1422
- the rarest move). Every verb is optional and the footer renders only when it HAS one, so an
1369
+ departure — not prioritized). Every verb is optional and the footer renders only when it HAS
1370
+ one, so an
1423
1371
  edit-only reference draws no rule under nothing.
1424
1372
  **Both unset — they differ only in FOLLOW-THROUGH.** `Clear` is the plain detach (nothing
1425
- focused); `Change` is the same write plus the caller's focus handoff. They are separate verbs
1426
- because the follow-through is not inferable from the write: nothing downstream can tell "unset
1427
- it" from "unset it, I'm about to pick another". Whether empty is VALID is the row's business — its
1373
+ focused); `Change` is the same write plus the caller's focus handoff. Whether empty is VALID is
1374
+ the row's business its
1428
1375
  `warning`, its validation — never the peek's.
1429
1376
  **Hand the picker focus after `Change`, never after `Clear`.** Both land on your empty state.
1430
1377
  Change said "wrong one", so open the picker focused (`Combobox`'s `autoFocus` both focuses AND
@@ -1470,11 +1417,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1470
1417
  Omitting `onOpen` renders a plain, non-interactive reference — never a press target wired to
1471
1418
  nothing, which is worse than no affordance. Composes as a `DetailRow`'s value for a single
1472
1419
  relationship, or repeats plainly (no per-row label) in a list for several — the surrounding
1473
- label or heading is the caller's, this renders one reference. No `examples/` worked example
1474
- yet, for the same reason as `running_ledger` above: what this component is FOR — a reference
1475
- into a genuinely separate deployed app — has no natural home inside any single-app template in
1476
- this repo without inventing a fictional sibling app to point at; proven against real, live
1477
- deployed consumers instead.
1420
+ label or heading is the caller's, this renders one reference.
1478
1421
  - **`peek`** — `Peek`: drill-down for a reference — press a name/id where it appears and get its
1479
1422
  details in an anchored popover, without leaving the screen; keep the content a summary with ONE
1480
1423
  action to the full record. **`variant` picks the register the TRIGGER sits in, and getting it
@@ -1502,9 +1445,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1502
1445
  **The row OWNS its `title`/`description` pair, so do not hand-roll one beside it.** Both
1503
1446
  lines are `sm`, `leading="tight"`, no gap between them — the stacked-pair law in
1504
1447
  [composition.md](./composition.md), applied by the component rather than restated at each
1505
- call site. It shipped rendering that pair as PROSE (two 24px boxes plus a 2px gap = 12px
1506
- between the lines, against the 6px the rule prescribes), and the height was sized around
1507
- that mistake at 64, which left a correctly-sized `md` mark adrift in it; the row is 56 now.
1448
+ call site. The row is 56.
1508
1449
  A `description` passed as a NODE keeps its own leading — the component only sets it on the
1509
1450
  string branch, because forcing a line box on somebody else's subtree re-spaces content that
1510
1451
  may not be a line of text at all.
@@ -1528,14 +1469,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1528
1469
  default.** A `SummaryLine`/`KPIStrip` figure above the register is a claim that ONE column is
1529
1470
  the reason to scan this page; left unset, `priority` defaults to column order and sheds
1530
1471
  right-to-left, so that exact column is the first thing table_fit drops the moment the
1531
- container narrows — the banner keeps promising a number the row beneath it can no longer
1532
- show. The fix is one line per column, not a rewrite: rank by what the summary already flagged
1533
- as the point, not by where the column happens to sit in the header row.
1534
- **`detail` + `expanded` reveal a row's detail BENEATH it instead of navigating away.** A
1535
- register answers "which one"; the moment it cannot answer "and what about it" the reader is
1536
- sent to a drawer, loses the surrounding rows, and pays a navigation to come back — which is
1537
- what makes scanning twenty records unaffordable. Reach for it when the detail is read or
1538
- light-edit; a heavy form still deserves its own surface. `expanded` is CONTROLLED — how many
1472
+ container narrows.
1473
+ **`detail` + `expanded` reveal a row's detail BENEATH it instead of navigating away.**
1474
+ Reach for it when the detail is read or light-edit; a heavy form still deserves its own surface.
1475
+ `expanded` is CONTROLLED how many
1539
1476
  may be open at once is the caller's rule, so the component does not pick one. The detail
1540
1477
  renders as a SIBLING of the press surface (a control inside it would otherwise be swallowed
1541
1478
  by the row's own toggle), the row shows the open wash while it is out, and the door announces
@@ -1543,8 +1480,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1543
1480
  to expand.
1544
1481
  **`Table.count` + `TableRow.ordinal` make it a COUNTED register** — a narrow gutter left of
1545
1482
  `leading` carrying each row's position, headed by the total. Reach for it on any register
1546
- long enough to scroll: it answers "how many" without paging to the end and "where am I"
1547
- without counting rows, and it is what a reader coming from a spreadsheet (STT) expects to
1483
+ long enough to scroll it is what a reader coming from a spreadsheet (STT) expects to
1548
1484
  find. `count` is the single switch — omit it and no gutter is reserved, so an uncounted
1549
1485
  register keeps every pixel it had. It is CHROME, not a column: it never sorts, never drops,
1550
1486
  and is excluded from the column band, but it IS reserved space the fit accounts for.
@@ -1564,8 +1500,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1564
1500
  `SortHeaderLabels`: the sortable column header and the sort-state helpers `Table`/
1565
1501
  `DataGrid` consumers drive. `sortBy` copies rather than mutates, and compares strings with
1566
1502
  `localeCompare` (case- and accent-folded, `numeric`) rather than `<`/`>` — code-point order
1567
- exiles every accented letter past the whole unaccented alphabet, so a Vietnamese register
1568
- reads correctly until its first Đ and looks fine to a reviewer forever. `getValue` returns a
1503
+ exiles every accented letter past the whole unaccented alphabet. `getValue` returns a
1569
1504
  NUMBER for anything ordered numerically (a timestamp, a count).
1570
1505
  - **`data_grid`** — `DataGrid` + `gridRowStyle`: the inline-managed grouped table (see
1571
1506
  [Tabular data](#tabular-data--pick-by-scale--intent)); `labels` localizes the sort-header
@@ -1597,9 +1532,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1597
1532
  `Summary.Header` / `Summary.Total` / `Summary.Metric` / `Summary.Distribution` /
1598
1533
  `Summary.Facts`, composed as children. **Declare the buckets ONCE on the root and every part
1599
1534
  derives from them** — the headline is their sum (there is no `total` prop to go stale), the
1600
- segments and the legend swatches are the buckets' own colours. That correspondence is the
1601
- reason it exists: a `segments` array passed beside an `items` array beside a `total` drifts
1602
- three ways, invisibly. A `SummaryBucket` must PARTITION the set; an aggregate over the same
1535
+ segments and the legend swatches are the buckets' own colours: a `segments` array passed
1536
+ beside an `items` array beside a `total` drifts three ways, invisibly. A `SummaryBucket`
1537
+ must PARTITION the set; an aggregate over the same
1603
1538
  rows that does not (a condition like "overdue" beside "draft / sent / paid", counted again
1604
1539
  from a different question) goes to `Summary.Facts` as `extra`, where it renders with NO swatch
1605
1540
  — the visual difference says it is not a slice of the bar. Ordering is the caller's and is
@@ -1635,7 +1570,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1635
1570
  springing to a filled check when done, distinct from the square checkbox; the
1636
1571
  task/to-do/checklist control. `partial` half-fills it, for the row that is underway but not
1637
1572
  finished (a task at "doing", a step whose children are half ticked) — a binary ring reports
1638
- that row as untouched, in the column readers scan fastest. The CLICK stays binary
1573
+ that row as untouched. The CLICK stays binary
1639
1574
  (`onChange(done)`) so the ring means one thing everywhere; `partial` is reached through
1640
1575
  whatever NAMES it — a status cell, or children ticking off — never by cycling the ring. Keep
1641
1576
  it monochrome and let colour live in the status cell.
@@ -1684,9 +1619,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1684
1619
  never dismiss it — clicks, scrolls, and Escape belong to that layer until it closes. In a
1685
1620
  NARROW VIEWPORT (`small`, under 768) it presents instead as a modal bottom sheet (scrim, close
1686
1621
  button, slide-up) — decided by `PopoverContent` itself off the screen size, with nothing to pass
1687
- and no way to answer it differently: an overlay fills the viewport whatever box it was opened
1688
- from, so the width of the column holding the trigger is never the question. The viewport, not
1689
- the device — a few-hundred-pixel side panel on a desktop gets the sheet too.
1622
+ and no way to answer it differently: the VIEWPORT, never the container the trigger sits in,
1623
+ and never the device a few-hundred-pixel side panel on a desktop gets the sheet too.
1690
1624
  `PopoverContent` already insets its
1691
1625
  body 12px — put content directly in it, NEVER add your own padding `View` (that double-pads);
1692
1626
  title/actions go in `PopoverHeader` / `PopoverFooter`. **A fixed width is the `width` PROP,
@@ -1695,8 +1629,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1695
1629
  then stretches the panel and leaves the content adrift in a box wider than itself. A popover
1696
1630
  with no bands never sees this.
1697
1631
  The built-in scroller is **full-bleed horizontally** and re-insets its content by the same
1698
- amount, so a scrolling body reaches the panel's real edges its content slides under the
1699
- panel edge rather than under an invisible margin 12px inside it — while the text stays on the
1632
+ amount, so a scrolling body reaches the panel's real edges while the text stays on the
1700
1633
  same column the header and footer use. Nothing to opt into; `disableBodyScroll` consumers
1701
1634
  (which own their scroll) are untouched.
1702
1635
  - **`popover_nav`** — `usePopoverNav` + `PopoverScreen` + `PopoverNavHeader` — the popover's
@@ -1739,8 +1672,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1739
1672
  section wrappers must be DIRECT children of the ScrollView content. The highlight and the
1740
1673
  jump both re-measure the live DOM on web, so content loading in above a section never
1741
1674
  strands them. Reaching the END of the scroll activates the LAST key regardless of offsets —
1742
- a final section shorter than the viewport never scrolls its top past the trigger line, so
1743
- without that it could never be the active one. On NARROW containers
1675
+ a final section shorter than the viewport never scrolls its top past the trigger line.
1676
+ On NARROW containers
1744
1677
  the rail becomes a PINNED bar naming the CURRENT section that opens a full-page
1745
1678
  section-picker `Modal` — never a horizontal tab strip. Worked example:
1746
1679
  [`tpl_record`](../examples/tpl_record.tsx).
@@ -1751,11 +1684,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1751
1684
  ### Numbers & charts
1752
1685
 
1753
1686
  **These are where a work screen gets its visual interest, and it is the only kind that survives
1754
- contact with a real dataset.** Software with no photography has to find its imagery in the data —
1755
- a figure at display size, a delta's colour, a line's shape, a bar's proportion, digits aligning
1756
- down a column. Each of these is information first, so none of it is the decoration that gets cut
1757
- in review. A screen reported as flat or boring is usually a screen with numbers in it rendered as
1758
- sentences. → [composition.md](./composition.md) §"Character comes from the DATA".
1687
+ contact with a real dataset.** Each of these is information first, so none of it is the
1688
+ decoration that gets cut in review. A screen reported as flat or boring is usually a screen with
1689
+ numbers in it rendered as sentences.
1690
+ [composition.md](./composition.md) §"Character comes from the DATA".
1759
1691
 
1760
1692
  Two failure modes to hold against them. A figure nobody compares to anything is not a metric — it
1761
1693
  is a number with a large font, and the size promises a significance it cannot deliver. And a
@@ -1767,7 +1699,7 @@ component rather than showing it at zero.
1767
1699
  (ⓘ), so the reader can find out what "on-time %" counts without leaving the screen.
1768
1700
  - **`kpi_strip`** — `KPIStrip`: the boxed dashboard stat band. Every item has to answer a
1769
1701
  question the surface below it cannot — a strip restating totals the table already carries is
1770
- the trim test failing, and it costs the reader a pass over the screen to discover that.
1702
+ the trim test failing.
1771
1703
  - **`metric`** — `Metric`: a bare headline figure (`format`
1772
1704
  currency|number|percentage|none, `tone`, `size` sm|md|lg|hero). Bare = no card, no label of its
1773
1705
  own, so whatever contains it has to name it. Always `tabular` — it is set that way here, and it
@@ -1781,8 +1713,7 @@ component rather than showing it at zero.
1781
1713
  Direction words via the `trendFooter` locale slice; goes in `SectionCard footer`.
1782
1714
  - **`sparkline`** — `Sparkline`: the inline mini trend line. Sized for a ROW or a card corner:
1783
1715
  it carries SHAPE (rising, spiky, flat) and no readable values, which is exactly what a
1784
- register wants a column of them turns "12 numbers per row" into a pattern the eye reads
1785
- without stopping. Reach for a real chart the moment someone needs to read a value off it.
1716
+ register wants. Reach for a real chart the moment someone needs to read a value off it.
1786
1717
  - **`bar_chart`** / **`line_chart`** / **`pie_chart`** — `BarChart` / `LineChart` /
1787
1718
  `PieChart`: the canonical SVG chart set (no recharts). `LineChart` prints as many x labels
1788
1719
  as the track fits and thins the rest, anchored on the LAST point — the newest reading is the
@@ -1822,8 +1753,7 @@ component rather than showing it at zero.
1822
1753
  horizontal rows (take rows for a narrow container or long step names; a 70px column and a
1823
1754
  rotated label is not an answer). The span always includes zero, so a bridge that closes at a
1824
1755
  loss hangs below the baseline instead of floating on a scale that exaggerates every step.
1825
- `waterfallLayout` is the pure geometry, exported and tested on its own: a bridge is only worth
1826
- drawing if the floating bars land where the arithmetic says, and no screenshot confirms that.
1756
+ `waterfallLayout` is the pure geometry, exported and tested on its own.
1827
1757
  Pick it over `ledger` when the reader is scanning for SHAPE (a dashboard), and `ledger` when
1828
1758
  the figures must be read exactly and reconciled line by line (a record). Over `bar_chart`
1829
1759
  whenever the bars are steps in ONE arithmetic — five columns all starting at zero state a
@@ -1849,17 +1779,17 @@ component rather than showing it at zero.
1849
1779
  `Matrix.Header` (corner + axis labels) + `Matrix.Grid` (`display` number|heat|both — the
1850
1780
  cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) +
1851
1781
  `Matrix.Legend`. Pick over `Heatmap` when the NUMBER and totals matter. Figures are
1852
- RIGHT-aligned — they are `tabular`, and centring them defeats the aligned column that is
1853
- the reason to read a pivot at all — and row labels carry default ink, because a row's
1854
- label is its identity, not chrome. `MATRIX_COL_GAP` is exported for a caller budgeting
1782
+ RIGHT-aligned — they are `tabular`, and centring them defeats the aligned column and row
1783
+ labels carry default ink, because a row's label is its identity, not chrome. `MATRIX_COL_GAP` is
1784
+ exported for a caller budgeting
1855
1785
  its own column widths.
1856
1786
  - **`matrix_totals`** — the data layer for `Matrix`: `matrixTotals` (the cross-tab
1857
1787
  aggregation) + `MatrixAxisItem` / `MatrixCellRef` / `MatrixTotalsResult`; React-free, so a
1858
1788
  KPI can be driven off the same numbers the grid shows.
1859
1789
  - **`highlight_segments`** — `splitHighlightSegments`: cuts a string into plain and
1860
- highlighted runs for `Composer`'s `highlightRanges`. React-free, and the reason it is
1861
- separate: it feeds a mirror sitting behind a real text field, so a boundary off by one
1862
- paints the tint adrift while everything still looks like a working composer. Overlapping,
1790
+ highlighted runs for `Composer`'s `highlightRanges`. React-free: it feeds a mirror sitting
1791
+ behind a real text field, so a boundary off by one paints the tint adrift while everything
1792
+ still looks like a working composer. Overlapping,
1863
1793
  unsorted and out-of-bounds ranges all normalize; no input drops a character.
1864
1794
  - **`legend_item`** — `LegendItem`: one swatch + label of a chart legend.
1865
1795
  - **`remainder_meter`** — `RemainderMeter`: allocated-vs-remaining meter
@@ -1869,7 +1799,7 @@ component rather than showing it at zero.
1869
1799
  whole model is one source distributed one way, and a caller with a second relationship to show
1870
1800
  reaches for a second instance before reaching for a wider one. Extract a true 3-column strip
1871
1801
  only once a SECOND real caller needs the exact same 3-way shape (the kit's own
1872
- extract-on-the-second-hand-roll rule) — one confirmed need is not yet a pattern.
1802
+ extract-on-the-second-hand-roll rule).
1873
1803
  - **`allocation_row`** — `AllocationRow`: one target's row in an allocation surface (pairs
1874
1804
  with `RemainderMeter`).
1875
1805
 
@@ -1899,19 +1829,16 @@ component rather than showing it at zero.
1899
1829
  composition.md §registers); and **`right` renders beside the press target, not inside
1900
1830
  it**, so a control there is valid HTML and keeps its own click — though on a row that
1901
1831
  EXPANDS, verbs belong in the BODY and `right` is better kept for a fact (a duration, a
1902
- badge). A control sharing the label row with the press target is what produced the nesting,
1903
- the first-line box and the chevron-pairing problems in the first place.
1832
+ badge).
1904
1833
  - **`checklist`** — `Checklist` + `ChecklistGroup` + `ChecklistItem` + `ChecklistNote` /
1905
1834
  `ChecklistActions` / `ChecklistField`: THE run of tickable rows, at any
1906
1835
  altitude — a ring, a title, a value beside it, a muted note, a detail block, an optional
1907
1836
  grouping level. **`connected` is the LINE joining the rings, and it is the whole
1908
- difference**: on for ONE record walking positions (it asserts
1909
- sequence), off for a SET picked in any order, where a line claims an order the work does not
1910
- have. Rows are ticked ONE AT A TIME; where the run is a sequence, derive position from the
1837
+ difference between a SEQUENCE and a set picked in any order.** Rows are ticked ONE AT A TIME; where the run is a sequence, derive position from the
1911
1838
  highest stamp so one click marks everything below it — a parent that ticked its children wrote
1912
1839
  what the reader never said (today's date onto milestones that happened on unrecorded days),
1913
- and a rung passed without a recorded day shows a BLANK date, which is the honest answer to
1914
- "when". **`ChecklistGroup` is a phase — a name, and at most a way to fold its rows away**: no
1840
+ and a rung passed without a recorded day shows a BLANK date. **`ChecklistGroup` is a phase a
1841
+ name, and at most a way to fold its rows away**: no
1915
1842
  COMPLETION control, because a phase
1916
1843
  owns no completion of its own and a ring there is a control that never responds (one identical
1917
1844
  to the pressable rings beneath it invites the press it refuses); and no BODY, because a
@@ -1971,7 +1898,7 @@ component rather than showing it at zero.
1971
1898
  `accept`, `label`/`hint`/`dropLabel`, `height`); click falls back to a picker, and while
1972
1899
  mounted it ALSO takes a paste (Ctrl/Cmd+V) via its inner `FileDropTarget` `paste`, scoped to
1973
1900
  the well's region. `label`/`dropLabel`/`hint` default from the locale's `fileDropzone` slice
1974
- (the hint now names the paste) — pass them only to override.
1901
+ (the hint names the paste) — pass them only to override.
1975
1902
  - **`file_drop_target`** — `FileDropTarget`: makes ANY region accept a file drag-drop —
1976
1903
  `{ onFiles, accept?, multiple?, paste?, disabled?, children, style? }` wrapped around a
1977
1904
  section, a card, a whole panel, so nobody hunts for a dropzone. While a drag hovers, the
@@ -2008,8 +1935,8 @@ component rather than showing it at zero.
2008
1935
  needs, so withholding `onAdd`/`onRemove` IS the read-only shape — there is no `readOnly`
2009
1936
  mode. **A HOST verb is a plain `Button`** reading **`useFilesEditorSelection()`**
2010
1937
  (`{selected, selectedIds, files, selectMode, clear, exit}`) — an AI read over the picked
2011
- papers, "send to the broker", a ZIP: acts the kit has never heard of, which is why there is
2012
- no props-per-act toolbar and no generic Menu. **No children means no bar** (a grid that only
1938
+ papers, "send to the broker", a ZIP: acts the kit has never heard of. **No children means no
1939
+ bar** (a grid that only
2013
1940
  previews). Bar words resolve prop → `LoticsLocale.filesEditor`; in-flight tile words →
2014
1941
  `LoticsLocale.fileUpload`. **No empty state:** with zero files it is a bare bar, so pair it
2015
1942
  with a `FileDropzone` for the well a records screen opens on. Use `FileGrid`/`FileRows` bare
@@ -2038,8 +1965,8 @@ component rather than showing it at zero.
2038
1965
  - **`file_thumbnail`** — **`diff`** puts a `DiffKind` corner mark on a tile (top-LEFT, the one
2039
1966
  corner remove/selection/uploading do not claim) and fades a `removed` one. The mark rides a
2040
1967
  white RING: a tile's ground is an arbitrary photo, and a pale tint over a pale scan is a
2041
- smudge. A `removed` tile fades its BODY only — the mark stays at full strength, since a faded
2042
- mark is the smudge the ring exists to prevent. Ignored at the ROW rung: a corner disc on a row-sized tile is the tile, and such a tile lives
1968
+ smudge. A `removed` tile fades its BODY only — the mark stays at full strength. Ignored at
1969
+ the ROW rung: a corner disc on a row-sized tile is the tile, and such a tile lives
2043
1970
  inside a row that carries the change instead. A grid says MEMBERSHIP well (arriving, leaving) and PAIRING not at
2044
1971
  all — nowhere on a 96px tile to name what a replacement supersedes — so a set where things
2045
1972
  are being REPLACED wants `FileRow`. Also: `FileThumbnail` + `DisplayFile` + `THUMBNAIL_SIZE` /
@@ -2071,7 +1998,7 @@ component rather than showing it at zero.
2071
1998
  + a ⋯ Download/Open-external/Remove menu; composes `FileRow` + `ActionMenu` +
2072
1999
  `FileGalleryModal`; localized via the `gallery` locale slice. **`meta` has NO default**: the
2073
2000
  badge states the type as colour and as a label, and the filename ends in the extension, so a
2074
- type caption was a fourth statement of one fact costing a line of row height. Pass a second
2001
+ type caption is a fourth statement of one fact costing a line of row height. Pass a second
2075
2002
  line only when it carries something the NAME does not — what an invoice covers, who uploaded
2076
2003
  it, when it landed.
2077
2004
  - **`file_badge`** — `FileBadge`: the two-tone file-type mark (PDF / XLSX / DOCX / video /
@@ -2101,8 +2028,7 @@ component rather than showing it at zero.
2101
2028
  absolutely inside a positioning wrapper the component owns) because a `<video>` is a
2102
2029
  replaced element: once metadata loads it reports the source's intrinsic size, and any
2103
2030
  ancestor sized by its content grows to match — with RN-web's `flexShrink: 0` default,
2104
- nothing pushes back, so a 720p capture measurably widened a 342px chat column to 367px
2105
- the moment it loaded. **Do not add your own fullscreen/expand control**: the native
2031
+ nothing pushes back. **Do not add your own fullscreen/expand control**: the native
2106
2032
  transport already carries fullscreen, picture-in-picture and download, and a second
2107
2033
  affordance beside them is both redundant and a claim about where it leads. A source it
2108
2034
  cannot load is surfaced, never silent — a media element that fails paints an EMPTY BOX and
@@ -2167,13 +2093,27 @@ component rather than showing it at zero.
2167
2093
  state }`); `renderToolOutput(call)` replaces a step's default Output panel (the one render escape
2168
2094
  hatch — input/error untouched). A per-tool failure shows amber with the reason in its expanded
2169
2095
  Error panel; a run-level breaking `error` (outside `parts`) renders as a terminal danger row, with
2170
- an optional `onRetry` Button under it. Fixed chrome (Thinking / Input / Output / Error / awaiting /
2171
- Retry) localizes via the `agentRun` locale slice. Needs `ai` as an optional type-only peer. AI
2096
+ an optional `onRetry` Button under it. While it runs the whole feed is ONE row and NO prose: the
2097
+ row names the call in flight ("Searching records") and says "Thinking…" whenever the
2098
+ model is writing rather than calling. The agent's between-call narration is never shown — the one
2099
+ message needing a response is a bulk-mutation confirmation, and the agent waits there, so the run
2100
+ stops and that text becomes the answer. A reply with no tool calls is all answer and still streams.
2101
+ At settle the work folds to "{last action}" with the answer below. Expanding is the one
2102
+ place live and settled differ: opened mid-run the timeline is capped and self-pinning (220px),
2103
+ opened settled it is uncapped. Stays fully open while a call is parked, when a SETTLED run ended on
2104
+ a tool call, and when the work is already one row. `collapseProcess={false}` hands the job back to
2105
+ the caller (a surface that already frames the run — it then owns the `FollowScroll` too);
2106
+ `summarizeRun(steps)` renames the row.
2107
+ Fixed chrome (Thinking / Input / Output /
2108
+ Error / awaiting / Retry) localizes via the `agentRun` locale slice. Needs `ai` as an optional type-only peer. AI
2172
2109
  work ONLY — deterministic work (template fill, an export) shows a `loading` state on its trigger
2173
2110
  and delivers at once; dressing it as an agent run is theater.
2174
2111
  - **`agent_transform`** — internal (no component): the one place that folds an ai-sdk `UIMessage`'s
2175
2112
  `parts` into the render timeline (`AgentUIPart`, `toSegments`, `lastRunningStep`), shared by
2176
- `AgentRun` and `AgentProgress`. The sole file permitted to `import type` from `ai`.
2113
+ `AgentRun` and `AgentProgress`. Also `splitTimeline` `AgentTimeline` (`{ process, result,
2114
+ steps }`): the run's work/answer split, cut POSITIONALLY at the last tool call because a
2115
+ model emits its plan, its asides and its report all as `text`. The sole file permitted to
2116
+ `import type` from `ai`.
2177
2117
  - **`agent_progress`** — `AgentProgress`: `AgentRun`'s compact, floating, expandable form (same
2178
2118
  `parts` prop) — a composer's working state.
2179
2119
  - **`approval_prompt`** — `ApprovalPrompt`: the surface that ANSWERS an agent's approval-gated
@@ -2201,10 +2141,9 @@ component rather than showing it at zero.
2201
2141
  - **`confidence`** — `Confidence` + `ConfidenceLevel` + `levelFromScore`: confidence IS a
2202
2142
  callout — the meter + level phrase head the block, the REQUIRED `children` carry the basis
2203
2143
  (what was checked, what failed); the box stays white with a hairline border at every
2204
- level — the meter is the only colored element. There is NO standalone meter — a level without its reasons cannot
2205
- be rendered; where a compact meter used to decorate a ranked row or proposal card, rank /
2206
- badges / severity carry the standing instead. Also: calibrated
2207
- high/med/low; localized via the provider.
2144
+ level — the meter is the only colored element. There is NO standalone meter — a level without
2145
+ its reasons cannot be rendered; rank / badges / severity carry the standing instead. Also:
2146
+ calibrated high/med/low; localized via the provider.
2208
2147
  - **`diff_value`** — **`DiffValue`, THE atom of a review surface**: a value that changed,
2209
2148
  rendering `before` struck above/before `after`. Owns no container, no context, no decision —
2210
2149
  so it drops into a table cell, a `DetailRow`, a total, a chip or a sentence and inherits that
@@ -2212,16 +2151,15 @@ component rather than showing it at zero.
2212
2151
  a chip for a link being repointed — and a node `before` is struck by a DRAWN rule rather than
2213
2152
  by text decoration, because `line-through` on a `Text` does not cross a `View` child (the old
2214
2153
  value would otherwise render at full strength beside its replacement with nothing saying it is
2215
- the one going away — type-checked, rendered, and wrong). Reach for a node when the value
2216
- genuinely is not text: an avatar carries identity a string cannot, and a review that swapped
2217
- the record's own chip for a name would show the operator a surface they have never seen at the
2218
- one moment they are asked to check something. A node belongs where it fits at FULL size — a
2219
- form row, a card. Shrinking a composite into a table cell takes its own type off the scale
2220
- (a file badge sized to a cell rendered its label at 5px), so a file in a CELL is a filename at
2221
- the column's own size, not a miniature badge. Worked in `dev/pages/ai.tsx` § every shape.
2154
+ the one going away). Reach for a node when the value
2155
+ genuinely is not text: an avatar carries identity a string cannot. A node belongs where it
2156
+ fits at FULL size a
2157
+ form row, a card. Shrinking a composite into a table cell takes its own type off the scale,
2158
+ so a file in a CELL is a filename at the column's own size, not a miniature badge. Worked in
2159
+ `dev/pages/ai.tsx` § every shape.
2222
2160
  **`before === after` collapses to ONE plain value** —
2223
2161
  agreement is not a change, and a document confirming what the record already says is the
2224
- GOOD case, not a diff of a value against itself. **`delta`** (a host-formatted string, the
2162
+ GOOD case. **`delta`** (a host-formatted string, the
2225
2163
  same contract as `Finding.delta`) prints HOW FAR it moved, because
2226
2164
  `1.600.000 → 1.481.481` otherwise makes the reader subtract to find out whether a
2227
2165
  correction is trivial or alarming. `layout` stacked (a column of figures) | inline (prose, dense
@@ -2229,9 +2167,9 @@ component rather than showing it at zero.
2229
2167
  for an addition, `after` for a removal. BOTH values stay on screen: the old one is the only
2230
2168
  evidence the reader has that the correction is the right SIZE.
2231
2169
  **`placeholder` is YOUR string — translate it.** The component renders no word of its own
2232
- (its only fallbacks are `—` and empty), so nothing here reaches the locale packs. That is
2233
- deliberate rather than an omission: `DiffMark` beside it DOES ship localized words, but the
2234
- two are different sentences even when English collides them — the mark says what happened to
2170
+ (its only fallbacks are `—` and empty), so nothing here reaches the locale packs.
2171
+ `DiffMark` beside it DOES ship localized words, but the two are different sentences even when
2172
+ English collides them — the mark says what happened to
2235
2173
  the ROW ("Removed" / "Xóa"), the placeholder says what is in the FIELD now, which reads "đã
2236
2174
  xóa" or "bỏ trống", never the bare verb. The same prop also carries "Pick a candidate below"
2237
2175
  and "Not on the document". Pull it from your own catalog.
@@ -2240,9 +2178,8 @@ component rather than showing it at zero.
2240
2178
  accessible word (never colour alone — "added" vs "removed" by hue is invisible to a reader who
2241
2179
  cannot separate them, and that is the distinction a review turns on). **ONE treatment
2242
2180
  everywhere: a filled 22px DISC**, 14px glyph, 700 ink on a 100 ground. A form marked with bare
2243
- glyphs beside a table marked with filled shapes reads as two systems saying the same thing; a
2244
- reader parses shape before meaning, so an unexplained difference reads as an accident however
2245
- principled the reason. The disc also gives the glyph its own ground, which is what lets one
2181
+ glyphs beside a table marked with filled shapes reads as two systems saying the same thing.
2182
+ The disc also gives the glyph its own ground, which is what lets one
2246
2183
  mark sit on a white row, a tinted cell, or a photograph. **`unchanged` renders as an empty
2247
2184
  disc-sized HOLE** — silence is the honest rendering of "nothing happened", it keeps the
2248
2185
  column's width so nothing shifts left, and it announces nothing (eleven "Unchanged"es is the
@@ -2253,8 +2190,7 @@ component rather than showing it at zero.
2253
2190
  an `Inline*` editor draws (40px band, 8px horizontal padding inside a 1px transparent border),
2254
2191
  minus the editor. A record's value column mixes editors with things that are not editors, and
2255
2192
  anything rendered raw beside them starts at the cell edge, 20px tall, at the top — so the
2256
- column quietly acquires TWO left edges and two baselines, and `DetailRow`'s label (which pads
2257
- down to meet a control) agrees with one kind of row and not the other. `InlineStatic` is the
2193
+ column quietly acquires TWO left edges and two baselines. `InlineStatic` is the
2258
2194
  STRING case and composes it; reach for the slot when the value is a node, which a review
2259
2195
  surface always is. An ANNOTATED row needs the grid even with no editor in sight, because the
2260
2196
  annotation stack is inset to a control's words — data_entry.md §The editability affordance