@lotics/ui 45.9.0 → 45.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,23 +259,26 @@ 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
- the fill and the percentage exact) / `StackedProgressBar` / `StepProgress`, `Breakdown` (a
267
+ the fill and the percentage exact) / `StackedProgressBar` (ONE whole split across segments,
268
+ filling its own track whatever the total) / `StackedBarChart` (SEVERAL wholes compared — rows
269
+ of stacked bars on one shared scale, so length says how big and the segments say what of) /
270
+ `StepProgress`, `WaterfallChart` (the BRIDGE — an opening level, the signed steps that move
271
+ it, the level they close at; revenue walked down to net), `Breakdown` (a
289
272
  stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
290
273
  "Show N more" toggle — `labels` to localize — so several facet cards align to one height in
291
274
  a row), `Funnel` (a CONVERSION funnel — ordered stages as bars that NARROW; the step
292
275
  conversion rate is the HEADLINE (a bold aligned row across the top, the first stage = the
293
- 100% baseline), the count is the supporting figure below — the Amplitude/Mixpanel
294
- 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 |
295
278
  horizontal bars; pass `onSelect`+`selectedKey` to make the bars press-to-drill (the selected
296
279
  stays solid, others dim — the caller renders the records). The subset/drop-off sibling of
297
280
  `StackedProgressBar` — nested cohorts that shrink "calls → connected → won", NOT a whole
298
- split across stages — that's `StackedProgressBar`), `StatusGrid` + `StatusLegend`, `Heatmap`
281
+ split across stages), `StatusGrid` + `StatusLegend`, `Heatmap`
299
282
  (density: colour-only, "where does it cluster"), `Matrix` (the PIVOT cross-tab: the NUMBER
300
283
  in each cell — optionally a heat wash behind it — plus row/column/grand totals; press a cell
301
284
  to drill).
@@ -325,11 +308,8 @@ action/badge/unit), `ActionMenu` (⋯), `FloatingActionBar` (bulk-select bar).
325
308
 
326
309
  A DESIGNED row (your own pile, card, or panel) that presses open AND carries its own
327
310
  controls is `PressableRow` + **`PressDoor`** — never `PressableHighlight`, which IS a
328
- button and wraps its children (a button must not contain interactive descendants). The
329
- door is an empty absolutely-positioned SIBLING of the content carrying the tab stop,
330
- accessible name, and focus ring; lift the content above it with `zIndex: 1`. `Table`/
331
- `TableRow` does exactly this internally, so a columnar register needs no assembly —
332
- 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.
333
313
 
334
314
  ### Filters & view controls
335
315
 
@@ -362,11 +342,9 @@ into a list *and* a grid is the mistake — it gives the reader two places to lo
362
342
  thing. The minority is not stranded by this: a non-image tile in a grid renders a `DocumentCard`
363
343
  printing its filename, and a photo in a list keeps its full name plus a meta line and is ONE tap
364
344
  from the picture, because a `FileRows` row press opens the built-in gallery. (The badge is
365
- MIME-driven, so that row shows a generic image mark, not the shot itself which is the cost the
366
- majority already decided to pay.) A screen showing BOTH surfaces (`tpl_record`) does
367
- so because it holds two distinct SETS the reader asks different questions of — the paperwork
368
- that arrived on the record, and the hand-over photos taken against it — not because one set was
369
- 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.
370
348
 
371
349
  `FilesEditor` (THE attachment surface: an upload-aware grid whose bar you COMPOSE — the root
372
350
  owns selection + gallery + confirmed remove, the bar pieces and any HOST verb go below it via
@@ -388,8 +366,7 @@ action / remove; `onPress` makes the whole row a pressable door, `trailing` stay
388
366
  independently-pressable sibling; for checklists & readable lists. **`size`** — `sm` (default)
389
367
  is the compact attachment line for many files scanned as a list; **`md` is the document-desk
390
368
  row** (taller badge, `ListItem` height) for the FEW rows that ARE the section's subject — an
391
- expected-document checklist, a slot a button fills. A two-row section at `sm` reads as an
392
- 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
393
370
  type mark), `FilePreview` / `FileGalleryModal`, `ImageGallery`; picking is `pickFiles`
394
371
  (`@lotics/ui/file_picker` — opens the browser picker and resolves the chosen `File[]`, the
395
372
  imperative half behind every Add-file CTA); for gated CRUD compose locally with
@@ -422,20 +399,14 @@ that same line, over the `readings` stacked ONE PER LINE — each `source value`
422
399
  joining them (a middot claims a relation it will not name, and is the one mark a screen reader
423
400
  drops), both kept on
424
401
  screen with NEITHER marked as the wrong one, since a finding reports that two sources disagree
425
- and which is right is the reader's call. `detail` is the optional consequence. A reader scanning
426
- 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
427
403
  provenance, so there are no `Sources` chips under it repeating the same names. The children slot
428
404
  composes ANY visual result — a compact `Table` for per-line detail (danger color on the offending cells),
429
405
  `ProgressBar` for consumption-toward-a-cliff (free time, credit), dot `Badge`s for a
430
406
  present/missing checklist, `Confidence` for judgment calls. Display-only — it informs the
431
407
  verdict the host records; `finding` locale slice). `ApprovalPrompt` (the surface that ANSWERS
432
- an agent's approval-gated action the interactive counterpart to `AgentRun`'s read-only amber
433
- `awaiting` row: a warning icon + message on a bordered card, Deny/Approve right-aligned, an
434
- optional `children` input summary; slot it where the composer sits while the run is parked;
435
- `approvalPrompt` locale slice), `MessageActions` (THE single shared per-message chrome bar for a
436
- chat transcript — copy with owned copied-feedback, regenerate, edit, and the branch/version
437
- pager as an internal part; each action renders only when its handler is given, so the assistant
438
- 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).
439
410
 
440
411
  ---
441
412
 
@@ -448,15 +419,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
448
419
 
449
420
  - **`vite`** — `loticsOptimizeDeps`: the canonical `optimizeDeps.include` list a custom-code
450
421
  app's `vite.config.ts` must pre-bundle for `lotics app dev` to render (RN-ecosystem + markdown
451
- CJS-interop; dev-only — the prod rollup build resolves the interop without it). Ships WITH the
452
- kit so it can never drift from what @lotics/ui's transitive deps require across a major bump
453
- 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:
454
424
  `import { loticsOptimizeDeps } from "@lotics/ui/vite"` → `optimizeDeps: { include: loticsOptimizeDeps }`.
455
425
  To add app-specific entries, spread: `include: [...loticsOptimizeDeps, "my-dep"]`. A Node-loadable
456
426
  `.mjs` leaf (a Vite config can't import a `.ts` from `node_modules`), zero-import by contract.
457
427
  - **`vite`** — `loticsResolve()`: the whole `resolve` block, for the same reason and on the same
458
- terms — `resolve: loticsResolve()`. Every entry is dictated by the kit's internals, so the kit
459
- 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
460
430
  primitives have no web implementation and the app does not build), `.web.tsx`-first `extensions`
461
431
  (per-target variants win; `.mjs`/`.mts` kept for parity with Vite's default resolver), `dedupe`
462
432
  for React + RN-Web (the kit is consumed across many subpaths, and a second React copy in one
@@ -464,8 +434,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
464
434
  read at call time so it lasts exactly one command. To add your own alias, spread:
465
435
  `const base = loticsResolve(); resolve: { ...base, alias: [...base.alias, entry] }`.
466
436
  `lotics app codegen` writes the matching `paths` into the app's `.lotics/tsconfig.link.json`,
467
- so `tsc`, vitest, eslint and the editor resolve the same kit Vite does — the dev-link is not a
468
- 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.
469
438
 
470
439
  ### Text & formatting
471
440
 
@@ -491,18 +460,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
491
460
  `Icon` rather than rendering the wrong company's logo. Not for the app's
492
461
  own glyphs; that is `icon`. `x` is the one mark whose brand colour is BLACK, so it is the one
493
462
  that needs `muted` on a dark ground; the rest carry their own hue either way. **Not a product
494
- logo**: a monochrome stand-in for one reads as a different thing (a red "M" is not Gmail), the kit
495
- ships no vendor artwork, and official artwork is not uniformly inlinable anyway — pass your own
496
- 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.
497
466
  - **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
498
467
  `AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
499
468
  native; takes a markdown `children` string. **`variant`** decides whose type
500
469
  ladder the headings belong to: `"document"` (default) where the markdown IS the
501
470
  surface, `"embedded"` where it is a VALUE on a page someone else designed — a
502
471
  field on a record, a summary in a feed row. Reach for `"embedded"` whenever the
503
- text was not written by whoever built the screen, which is now the common case
504
- because the writer is routinely a model; at document scale an `h1` it emits is
505
- 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
506
475
  the scale (h1→h4, h2→h5, h3+→h6): sizing alone leaves a model's `##` announcing as a PEER of
507
476
  your own sections in heading navigation, which no visual check can see.
508
477
  - **`markdown_types`** — `MarkdownProps`; types only.
@@ -514,10 +483,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
514
483
  `getInputLineHeight` — the 16px-mobile/14px-desktop input contract that stops Safari iOS
515
484
  auto-zoom; only for hand-rolled raw inputs. It carries the **ink** as well as the size,
516
485
  because omitting `color` on a native `<input>` does not inherit — it falls to the UA
517
- default, pure black, against the `zinc-900` every resting value beside it uses. A field
518
- whose text darkens the instant it is focused reads as the text MOVING (at 14px a darkness
519
- change re-weights every stroke's antialiasing), which is a bug no box measurement can
520
- 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.
521
488
  - **`font_family`** — the three Inter stacks (`fontFamilyRegular` / `fontFamilyMedium` /
522
489
  `fontFamilySemiBold`). Weight in this kit is a FAMILY, not a `font-weight` axis: each rung is
523
490
  a separate file, which is why `Text`'s `weight` prop swaps the family rather than a number.
@@ -527,14 +494,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
527
494
  the ONLY themeable typeface. There is deliberately no body-face role: body weight is three
528
495
  coupled families, `text.css` hand-tunes tracking for Inter's glyphs and for Vietnamese
529
496
  diacritics at 12px, and `font-feature-settings: "cv11","ss01","ss03"` are Inter's own
530
- alternates. A variable for the body face would not fail loudly it would render every screen
531
- 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.
532
498
  - **`type_ramp`** — the type ramp as DATA: `TYPE_RAMP_MOBILE` / `TYPE_RAMP_DESKTOP` (size +
533
499
  leading per rung, the 768px breakpoint between them), `TYPE_TRACKING` (em per rung, negative
534
500
  throughout and steepening as the type grows), `TypeRung` / `TypeRungName` (`TextSize` IS this
535
501
  set), and `trackingPx` for platforms with no em unit. `Text` renders through two mechanisms —
536
502
  a `data-text-size` attribute matched by `text.css` on web, a `StyleSheet` on native — and this
537
- 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
538
504
  almost never need to import it, because `Text size=` is the way to ask for a rung. Reach for
539
505
  it only when styling something `Text` cannot wrap (a raw `TextInput`, a canvas, a chart axis)
540
506
  and the result has to sit at a rung exactly.
@@ -571,15 +537,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
571
537
  - **`spacing`** — the `SPACE` scale + `SpaceToken`, and **`pagePad(small)`** — the page's
572
538
  side gutter (`SPACE.md` on a phone, `SPACE.xl` wider). `PageContent` applies it; a screen
573
539
  that cannot use that shell — one with a rail, a side panel or its own scroller — imports it
574
- rather than picking a number, because a list and the record it opens are one reading column
575
- 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`:
576
541
  a register row bleeds its wash outward to `pagePad − ROW_WASH_BLEED`, so the two are read
577
542
  together and neither belongs to the app.
578
543
  - **`control_surface`** — `CONTROL_HEIGHT` (40), `CONTROL_CONTENT_HEIGHT` (28 — the tallest
579
544
  FIXED-height node a 40px band seats, 6 per side of air; what `InlineButton` and a `md`
580
545
  `IconButton` already used, now named so an avatar or any other child lands on it too. Text
581
- never needs it because `minHeight` governs a single line, which is exactly why three surfaces
582
- could each pick a different inset and only disagree once an avatar went in),
546
+ never needs it because `minHeight` governs a single line),
583
547
  `CONTROL_PADDING_V` (5 — derived from the pair, BORDER INCLUDED in the 6 per side; every 40px
584
548
  surface pads by it instead of a number of its own), `CONTROL_RADIUS` (10), `CONTROL_TEXT_INSET`
585
549
  (9 — how far a control insets its OWN text: 1px border + 8px padding; anything that must line
@@ -627,8 +591,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
627
591
  so a prop declared on one side typechecks everywhere and silently does nothing in the browser.
628
592
  - **`label_tone`** — `labelTone(label)`: one stable `ColorName` per free-text label (a tag, a
629
593
  topic, a category), for `Badge`. `avatarTone`'s sibling and the same discipline: pure and
630
- NEVER STORED, so a set of labels can stay a `text[]` instead of earning a table the moment a
631
- 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
632
595
  arbitrary noun red makes every red on the screen ambiguous; hue-interleaved so two labels in
633
596
  adjacent buckets still look different. Case- and space-folded, matching whatever normalizes the
634
597
  set — two spellings of one label must not be two colours. Returns a NAME (not a hex like
@@ -646,14 +609,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
646
609
  one cell. Takes `{ id, name, kind: "person" | "group", image? }`, shows `max` of them (default
647
610
  3) and makes the rest a `+N` that is the LAST MARK IN THE STACK, not loose text beside it.
648
611
  `size` is the shared avatar scale with the same `md` default as `Avatar`/`MemberChip`, and the
649
- marks, the overlap and the count's type all derive from it — no fixed pixels, so a stack at any
650
- 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
651
614
  can NAME, so the overflow never counts a mark the reader sees is missing; a group is ONE mark
652
615
  bearing the group's name, never its members' faces (expanding it states a membership the reader
653
616
  may not be entitled to); and the separating hairline is **translucent ink, never the surface
654
617
  colour** — a register row washes to `zinc[50]` on hover, `accent_wash` when open and
655
618
  `zinc[200]` while pressed, so a ring painted the background is a halo in three of the four
656
- states, while a dark edge darkens any ground (the trick `FileBadge` already uses). Marks
619
+ states, while a dark edge darkens any ground. Marks
657
620
  `announce` because they stand alone here. INERT — its home is a register row, and a row that
658
621
  already presses must not hold a second destination (see `member_peek`). A single person →
659
622
  `MemberChip`; a single group → `GroupAvatar`.
@@ -668,8 +631,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
668
631
  - **`member_profile_card`** — `MemberProfileCard`: WHO IS THIS — `xl` avatar, name, sign-in
669
632
  identity, role, join date and groups, plus one optional `action`. Every row is conditional, so a
670
633
  card told nothing is a header and nothing else. `role` comes in pre-translated (vocabulary only
671
- the product can name); `joined` comes in as an ISO DATE and the card formats it to month + year
672
- (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;
673
635
  `groups` distinguishes absent (no row) from `[]` ("None"). `inactive` draws the departed state as
674
636
  a `Badge` — a lifecycle state, not another muted line.
675
637
  - **`member_peek`** — `MemberPeek`: `MemberChip` + `Peek` + the card above, with the trigger's
@@ -696,8 +658,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
696
658
  one opt-down). **Which rung it renders is the SURFACE's answer, not a prop:** `##` xl-semibold
697
659
  on a page, `####` md-semibold inside an overlay's content region (`DrawerScrollArea` /
698
660
  `DialogScrollArea` / `ModalBody` / `PopoverContent`), where the container's own title already
699
- occupies lg. The call site is identical on both, which is the whole point there is no
700
- 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
701
662
  overlay (a record screen in a master-detail drawer) carries its own gutters and so sits in the
702
663
  container's BARE slot, keeping the page rungs; nesting overlays never compounds.
703
664
  `SubsectionHeadingTitle` is the `###` lg-semibold level-3
@@ -709,8 +670,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
709
670
  `primary` `Button` beside the title, rendered whether the collection is empty or full, never
710
671
  under the rows it extends and never repeated in the `EmptyState`
711
672
  (composition.md § The add-placement law). An overlay surface loses only the type size, never an
712
- affordance — `icon`, `description` and `info` are the same slots at both altitudes. **`description`
713
- 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
714
674
  overlay `####` is the ramp's FLOOR: `SubsectionHeadingTitle` has one altitude and would out-rank
715
675
  the section holding it, so a named group there is `Text size="sm" weight="semibold"`. The heading
716
676
  ramp is FIXED: `#` xxl / `##` xl / `###` lg / `####` md, no size props.
@@ -719,9 +679,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
719
679
  rung. The kit's content regions publish it, so an ordinary screen never imports this. Reach for
720
680
  it when you build your OWN pane — a docked side panel, a split view, any self-contained region
721
681
  whose own chrome caps at `lg` and which therefore uses none of those regions: wrap its content
722
- in `<HeadingAltitudeContext.Provider value="panel">` and every heading inside is right. Scoping
723
- the rule to the panes the KIT renders would make it an allowlist short by however many you
724
- 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.
725
684
  - **`section_stack`** — `SectionStack`, `SubsectionStack` — stacks that own the
726
685
  between-block law, skipping null children: `SectionStack` = a fixed 56px beat + a hairline
727
686
  `Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only while
@@ -748,16 +707,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
748
707
  `left`/`right` form a separate nav row above. Split `trailing` from `actions` by what the
749
708
  control acts on: `actions` do something to the page's CONTENT (create, sort, export),
750
709
  `trailing` changes what is AROUND it (a side-panel toggle, a view switch). A panel toggle
751
- filed under `actions` reads as a peer of "create one of these". It sits AFTER the title
752
- because the title is the page's first word and nothing should come between the reader
753
- and it. Under a title too long for the row the TITLE gives way and wraps while `trailing`
754
- and `actions` keep their width — a wrapped title is merely taller, a control squeezed
755
- 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.
756
713
  - **`page_content`** — `PageContent` + `PAGE_SIZES`: the page's padded, width-capped content
757
714
  region — a centred column with optional `title`/`titleRight`/`description`, `header`/`footer`
758
- slots and `fullscreen`. **Reach for it before hand-rolling a screen shell**: a scroller, a
759
- centred `maxWidth` column and a title band re-typed per screen is how two pages of one app end
760
- 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
761
717
  genuinely cannot use it — one with a rail, a side panel, or a scroller it must hold a ref to —
762
718
  still lands on the same edge by importing that token. It OWNS its `ScrollView` and exposes no
763
719
  scroll props, which is the real limit on adoption.
@@ -804,21 +760,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
804
760
  - **`comments_button`** — `CommentsButton`: THE discussion affordance for a listed record — the
805
761
  count and a bubble as ONE control, opening the thread. Reach for it wherever a record is listed
806
762
  and its conversation would otherwise be invisible until opened (a register row, a card, a
807
- 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
808
764
  shares its surface, scale and focus ring, so a row carrying both reads as one grammar. The count
809
765
  is INSIDE the target, never loose text beside an `IconButton`: the number is what the reader
810
766
  aims at, and splitting them leaves half the affordance dead under the pointer. FULL INK, unlike
811
- the muted values around it — everything else on a row is a fact ABOUT the record, this is people
812
- 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
813
769
  NAME only ("3 comments on Northwind Packaging" is a destination; "3 comments" on the fortieth row
814
770
  is not) — never the visible label, which would spend row width restating whose record it is. The
815
771
  pack's `commentsButton.withSubject` builds that phrase, so the word ORDER is the pack's to choose
816
- and the join is a WORD: this string exists to be read aloud, and a screen reader drops
817
- punctuation along with the relation it was carrying.
818
- **Render it only when `count > 0`** — a zero on every quiet row is a column of noise that trains
819
- the eye to skip exactly where the signal will appear. Pair it with a jump that LANDS on the
820
- thread (`tpl_item_list` opens the record's drawer on its comments section), because a count the
821
- 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).
822
777
  - **`copy_button`** — `CopyButton`: THE copy control — `value` onto the clipboard, glyph
823
778
  flips to a check and the tooltip to the pack's "Copied" for ~2s. The confirmation follows
824
779
  a REAL write: an unreachable clipboard throws and the button stays at rest rather than
@@ -840,8 +795,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
840
795
  `nav.back` — "Back" / "Quay lại"). Bare it is the 40px chevron-left disc; pass
841
796
  **`label`** to name where back GOES ("Danh sách") — the SAME disc with the destination
842
797
  beside it, ONE pressable over disc + words, never an icon button beside unclickable
843
- prose. It takes no size or colour: one purpose, one look, and both variants draw the
844
- one disc so they cannot drift.
798
+ prose. It takes no size or colour.
845
799
  **A back affordance INSIDE a header row is not this** — beside a drawer's title a 40px
846
800
  disc outweighs the name it belongs to, so that one is a small `IconButton` (see
847
801
  `screen_router` below). The distinction is prominence: this HEADS a surface, that
@@ -889,8 +843,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
889
843
  - **`floating_action_bar`** — `FloatingActionBar`: the floating bulk-select action bar.
890
844
  - **`pressable_row`** — `PressableRow`: THE register row — full-width hover/open wash,
891
845
  `selected` (the open record) vs `marked` (ticked in bulk-select), forwards `ref` for
892
- popover anchoring. **Its text is SELECTABLE**: a register row is a button wrapped around
893
- 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
894
848
  does not open the record (see `press_selection`). Furniture that must not smear over —
895
849
  `Badge`, `Avatar`, `Button` — opts out for itself.
896
850
  The guard behind it is internal (`press_selection`): only a real, non-empty selection
@@ -948,21 +902,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
948
902
  the section's add (that lives on the heading row, where it does not move — § The add-placement
949
903
  law in composition.md), a no-results empty is HINT-only since the filters that emptied it carry
950
904
  their own clear, and a FAILED read is `ErrorState`, not this. **`compact`** for one BAND of a
951
- record — left-aligned, glyph-less, 56px against the default's 178 because the
952
- heading above it has already proved the surface rendered; the centred default speaks for a
953
- 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.
954
907
  - **`error_state`** — `ErrorState`: the region-scale FAILED read — `message` + optional `detail`
955
908
  + `onRetry` (the kit renders the button and words it from the locale pack, so "try again" reads
956
909
  the same everywhere). The fourth of the region states: `Skeleton`/`Loading` in flight → this on
957
- failure → `EmptyState` on nothing → `CompletionState` on done. Rendering a failure as an empty
958
- 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
959
911
  flow (a form that won't save) use `Callout tone="error"`; this is for a region with no content
960
912
  to show, where a tinted strip leaves the area collapsed.
961
913
  - **`completion_state`** — `CompletionState`: the "all done" terminal state.
962
914
  - **`skeleton`** — `Skeleton`: loading placeholder blocks. **Mirror the layout that is
963
915
  arriving** — same row count, same column widths, same heights. A skeleton of a different
964
- shape makes the content jump when it lands, which is more disruptive than an empty box
965
- 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`.
966
918
  - **`loading`** — `Loading`: the centered indeterminate loading state (composes
967
919
  `DotsIndicator`).
968
920
  - **`activity_indicator`** — `ActivityIndicator`: the bare spinner.
@@ -980,8 +932,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
980
932
  also home of the shared `PickerOption` type. Its empty row does one of two jobs and they
981
933
  are not interchangeable: `includeEmptyOption` makes empty a **choice** the reader picks to
982
934
  clear the field, so it is NAMED ("None", from the locale pack; override per instance with
983
- `emptyOptionLabel`) and keeps that name after a selection — a name that disappears once
984
- 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
985
936
  that flag the row is a **placeholder**: it carries `placeholder` and leaves on selection.
986
937
  Don't reach for `placeholder` to name a choice — a hint and a value read differently.
987
938
  Inside a `FormField` (so inside `FormPicker`) it takes its NAME from the visible label
@@ -992,8 +943,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
992
943
  `renderSelected(item, { remove })` + `searchable` + `allowCustom` — the tag field is just
993
944
  a multi Select; opens `OptionList`. **Set `searchPlaceholder` whenever `allowCustom` is on**:
994
945
  the create row only exists once there is a query, so on an untyped menu the placeholder is the
995
- only always-visible place that can say a new value is allowed a tag field reading "Search…"
996
- 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
997
948
  do, with the value in it. Its trigger is `role="combobox"` (a `<div>`), never
998
949
  `role="button"`: the chip box legitimately puts a remove button INSIDE the trigger, and a
999
950
  real `<button>` may not contain one. **Any trigger that renders caller-supplied content
@@ -1006,13 +957,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1006
957
  opens — single/multi, optional internal search, create row, keyboard + native-`<select>`
1007
958
  typeahead; host it directly in a `Popover`/`Dialog` for a command palette. A chosen row is
1008
959
  `selected`: the kit's `zinc.100` resting highlight AND `aria-selected`, one weight above the
1009
- `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.
1010
961
  It opens SCROLLED to its seat (the selected row in `search.mode:"none"`, else the first real
1011
- option), which only shows on a list longer than its own box and is the difference between a
1012
- 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.
1013
963
  `indeterminateValues` (multi) draws a MIXED tick on values only SOME of the things being
1014
- edited carry — the shape for labelling a selection, where an empty box would say "none of
1015
- 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
1016
966
  pressing it selects (the caller applies to all), and it announces itself in the row label
1017
967
  because `role="option"` has no ARIA state for a third value.
1018
968
  - **`combobox`** — COMPOUND single-select editable search: `Combobox` root +
@@ -1056,6 +1006,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1056
1006
  field with an empty slot reserves nothing, so its text starts and ends where it always did.
1057
1007
  - **`number_input`** — `NumberInput`: numeric input; `format` for currency/units.
1058
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.
1059
1020
  - **`form_field`** — `FormField` + `useFormField`: label / description / warning / error /
1060
1021
  `optional` marker wrapper (the three field-annotation tones — muted / amber / danger — all
1061
1022
  wired into the input's describedby); the fieldset grid cell (`half`/`full` widths — see the
@@ -1079,7 +1040,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1079
1040
  switch (`accessibilityRole="switch"`, the inner Switch read-only). The settings-panel/menu
1080
1041
  row toggle. The leading slot takes EITHER `icon` (an `IconName`, the app's own glyph language)
1081
1042
  or `leading` (any node); `icon` wins if both are passed. A row naming an outside product passes
1082
- its logo through `leading`, because the kit ships no vendor artwork of its own.
1043
+ its logo through `leading`.
1083
1044
  - **`use_form`** — `useForm`: THE batch draft-form state hook — `values` = `initialValues` +
1084
1045
  an edits overlay (a revalidation refreshes untouched fields, no sync effect), `validate`
1085
1046
  (sync/async, gates submit, editing clears the field's error), `onSubmit(values, helpers)`
@@ -1110,7 +1071,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1110
1071
  - **`date_filter`** — `DateFilter`: the date+time period filter panel — presets
1111
1072
  (`PresetId`), calendar, optional time segments; the body `DateRangeFilterField` opens.
1112
1073
  **Every preset SETS a range** — the list holds no mode and no null case, so a hand-picked
1113
- range selects nothing rather than lighting a "custom" row whose click cleared the value. The
1074
+ range selects nothing. The
1114
1075
  presets are a `listbox` of `MenuButton` options: selection is the kit's `zinc.100` row
1115
1076
  highlight plus `aria-selected`, never a filled primary treatment, because the row reports
1116
1077
  state rather than offering an action. Sidebar at viewports of 768 and up, strip below —
@@ -1146,13 +1107,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1146
1107
  `DeadlineThresholds`, `Deadline`). Three contracts a screen must not invent for itself — how
1147
1108
  far off a date is, what to CALL that number, and how loud it reads. `daysUntil` collapses both
1148
1109
  ends to midnight because the vocabulary is day-granular: rounding the raw gap lands on the
1149
- wrong day whenever now and the deadline straddle noon (a 02:00 cut-off tomorrow reads "today";
1150
- 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
1151
1111
  slice; the 1-day/3-day tone lines are freight defaults and move per domain via
1152
1112
  `DeadlineThresholds`. **`deadlineAnnotation` spreads onto the `DetailRow` holding the date** —
1153
1113
  a deadline is a property OF that field, and the three annotation slots carry the three urgency
1154
- levels exactly (`error` / `warning` / `description`), so it never becomes a trailing badge
1155
- 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
1156
1115
  milestone FIRST and expects the caller to pass only OPEN ones — done-ness is not knowable from
1157
1116
  a date, and skipping past a missed cut-off is how a record silently stops reporting the failure.
1158
1117
  Worked example: `tpl_record`'s Due rows.
@@ -1200,8 +1159,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1200
1159
  same shape `Select` gives: return `<Chip onDismiss={remove}>` for a removable tag, or a plain
1201
1160
  badge that ignores it — a dense grid column is the case for the badge, since the ✕ costs more
1202
1161
  width there than the reopen it saves. `remove` detaches that one tag whether the list is open or
1203
- shut, and never opens the list the innermost responder takes the gesture, so a inside the
1204
- 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
1205
1163
  Clear row. Both modes take
1206
1164
  `allowCustom` (a create-a-tag/option row) + `searchable` + **`customOptionPlacement`** (default
1207
1165
  `"bottom"`, right for a tag field; pass `"top"` for a find-or-create REFERENCE picker over a long
@@ -1230,9 +1188,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1230
1188
  surface already promises and, down a column, draws the grid twice.
1231
1189
 
1232
1190
  It is an axis of weight, not of use: both hover and open identically. There is no `*Cell` family —
1233
- a data-grid cell is `<InlineSelect variant="bare" …/>`, not a `SelectCell` (naming a component for a
1234
- 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`.
1235
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.
1236
1199
  - **`inline_files`** — `InlineFiles`: the FILES member of the Inline\* family — a record's
1237
1200
  attachments as a `DetailRow` value. A `FileRows` LIST (press a row to preview full-screen, ⋯
1238
1201
  to remove) with ONE CTA under it; no selection mode, no bulk bar, no upload queue — reach for
@@ -1240,8 +1203,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1240
1203
  is the FIELD's cardinality and it changes the verb**: many → the CTA reads `addMoreLabel` and
1241
1204
  the host APPENDS in `onAdd`; one → it reads `replaceLabel` and the host SETS, because "add
1242
1205
  another" is not an act a single-file field can perform and offering it is how a record ends up
1243
- holding two of something the schema says there is one of (the component owns the affordance,
1244
- 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
1245
1208
  field with nothing to attach TO yet — never a disabled button, which says only that you may
1246
1209
  not. A LIST and not switchable: documents are identified by NAME, images by CONTENT, so a
1247
1210
  photo field wants `FileGrid`/`FilesEditor` instead — that is a judgment about the data, so it
@@ -1291,8 +1254,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1291
1254
  unit (though "kg"/"$" belong IN the value via `InlineNumberInput format`) — composes into a
1292
1255
  row inside the value cell, costing only the row that wants it. The LABEL WRAPS inside its column and
1293
1256
  is never clipped — a fixed `labelWidth` would otherwise ellipsize every long field name
1294
- ("Registered business address"), and a name the reader can't finish is worse than a taller
1295
- 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
1296
1259
  one-line label still centers on it (both modes, no prop). The FIELD-ANNOTATION vocabulary (same
1297
1260
  names + meanings as `FormField`), always EXPLICIT — a row never hides guidance behind an ⓘ:
1298
1261
  **`description`** = a fact / persistent guidance (muted), under the VALUE (stacked mode
@@ -1335,10 +1298,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1335
1298
  into a pressable door floating its particulars in an anchored popover (put links INSIDE the
1336
1299
  peek — never a button in a button; `reference` is the trailing-link alternative for static
1337
1300
  rows), `LedgerTotal` = the divider-set emphasized close with `zeroLabel` for settled. **`meta` is a
1338
- neutral QUALIFIER** (a date, a method) at caption weight — never a problem or a state: one
1339
- row's meta being a fact and another's a complaint is what makes a column read inconsistent,
1340
- and a caption is the wrong weight for something wanting action. A problem goes on the row
1341
- 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.
1342
1304
  **Pick the shape by what the statement IS**, because there are two and they read differently:
1343
1305
  - **`charges → total`** — `LedgerGroup`s, each **closed by its own sum** ("Charges" vs
1344
1306
  "Received"), giving three ascending rungs: row `sm/regular`, subtotal `sm/medium`, total
@@ -1364,10 +1326,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1364
1326
  whose lookup link sits INSIDE the popover, a flat `reference` on the row with nothing to
1365
1327
  expand, `meta` + `success` tone on money coming back, and a one-row `Adjustments` group that
1366
1328
  takes no `total`. `Received` is derived from the charges carrying a payment METHOD rather than
1367
- a second flag to keep in step, and the refundable deposit stays OUT because a ledger earns
1368
- trust by summing exactly what its label claims. `LedgerBasis` is demoed on the gallery's
1369
- Charts page instead — a delivery order has no base to compute from, and inventing one to
1370
- 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.
1371
1330
  - **`running_ledger`** — `RunningLedger`: a CHRONOLOGICAL, self-totalling ledger — a stock
1372
1331
  kardex, a statement of account — where every row carries the balance AS OF that row. Not
1373
1332
  `Ledger`: that compound closes a fixed set of charge/receipt groups into one total and has no
@@ -1375,23 +1334,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1375
1334
  `rows` (date, label, a SIGNED `amount` — positive in/debit, negative out/credit, one field
1376
1335
  rather than paired columns so the same shape serves a stock quantity and a money statement),
1377
1336
  each rendered with its running balance **computed by the component**, never accepted as a
1378
- caller-supplied number — a running balance is exactly the figure that must equal what it
1379
- accumulates, and a caller-supplied one is the one kind of drift no reader can catch by looking.
1337
+ caller-supplied number.
1380
1338
  `format` is ONE formatter for the whole ledger (`formatMoney`, or a quantity formatter with a
1381
1339
  unit suffix) — every row shares one unit, which is what lets the column state it once.
1382
1340
  `criticalAtOrBelow` reads a row's balance in danger tone once it reaches a caller-declared
1383
1341
  floor (a stock-out, a negative balance) — **the ONLY tone this component owns**, evaluated
1384
1342
  independently on EVERY balance in the column: each row, the opening anchor (so an already-critical
1385
1343
  opening reads danger even with zero rows following it), and the closing line. A row's `amount`
1386
- is never auto-toned by its arithmetic sign, because an OUT movement is routine business as often
1387
- 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
1388
1346
  `LedgerTotal` weight, `closingLabel` overriding the locale default ("Current balance") for a
1389
- domain the pack cannot own generically ("Tồn kho hiện tại"). No `examples/` worked example yet
1390
- — a stock kardex or an AR statement of account is a genuinely different shape from every
1391
- existing template (the 3-statement accrual grammar in `tpl_statements`, the fleet-register
1392
- drawer in `tpl_stock`), so forcing it into either would be a mismatch rather than a worked
1393
- example; proven against real, live deployed consumers instead (a warehouse and a receivables
1394
- app) until a template of its own earns its place.
1347
+ domain the pack cannot own generically ("Tồn kho hiện tại").
1395
1348
  - **`reference_field`** — `ReferenceField`: a reference to ANOTHER RECORD, rendered as a
1396
1349
  FIELD VALUE — the kit's inline-editor surface (so a pointer sits in the value column
1397
1350
  like the editors above and below it), whose press opens a PEEK of that record's facts.
@@ -1404,23 +1357,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1404
1357
  VALUE floor (200), not the control floor, because that column holds names as well as editors.
1405
1358
  The verbs live INSIDE the
1406
1359
  peek, never on the field, and they are ALL `Button`s at one altitude — a peek is
1407
- dialog-scale and a dialog's verbs are buttons, so mixing weights only made the reader rank
1408
- 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
1409
1362
  seam: **left, the LINK** — `Change` (`onChange`, `secondary`) points the field at a different
1410
1363
  record, named for the intent and deliberately not `danger` since a recoverable correction must
1411
1364
  not wear the colour reserved for destruction; `Clear` (`onClear`, NO fill — the least-reached
1412
1365
  verb of the four) leaves it empty. The draft's footer follows the same rule: `Cancel` carries
1413
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
1414
- everywhere; a reference the record cannot do without gains nothing from hiding Clear, because
1415
- 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,
1416
1368
  handed to `Save` when the draft opens) and `Open` (`secondary`, rightmost, OPTIONAL, the
1417
- departure — not prioritized, because on a peek whose facts are correctable in place leaving is
1418
- 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
1419
1371
  edit-only reference draws no rule under nothing.
1420
1372
  **Both unset — they differ only in FOLLOW-THROUGH.** `Clear` is the plain detach (nothing
1421
- focused); `Change` is the same write plus the caller's focus handoff. They are separate verbs
1422
- because the follow-through is not inferable from the write: nothing downstream can tell "unset
1423
- 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
1424
1375
  `warning`, its validation — never the peek's.
1425
1376
  **Hand the picker focus after `Change`, never after `Clear`.** Both land on your empty state.
1426
1377
  Change said "wrong one", so open the picker focused (`Combobox`'s `autoFocus` both focuses AND
@@ -1466,11 +1417,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1466
1417
  Omitting `onOpen` renders a plain, non-interactive reference — never a press target wired to
1467
1418
  nothing, which is worse than no affordance. Composes as a `DetailRow`'s value for a single
1468
1419
  relationship, or repeats plainly (no per-row label) in a list for several — the surrounding
1469
- label or heading is the caller's, this renders one reference. No `examples/` worked example
1470
- yet, for the same reason as `running_ledger` above: what this component is FOR — a reference
1471
- into a genuinely separate deployed app — has no natural home inside any single-app template in
1472
- this repo without inventing a fictional sibling app to point at; proven against real, live
1473
- deployed consumers instead.
1420
+ label or heading is the caller's, this renders one reference.
1474
1421
  - **`peek`** — `Peek`: drill-down for a reference — press a name/id where it appears and get its
1475
1422
  details in an anchored popover, without leaving the screen; keep the content a summary with ONE
1476
1423
  action to the full record. **`variant` picks the register the TRIGGER sits in, and getting it
@@ -1498,9 +1445,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1498
1445
  **The row OWNS its `title`/`description` pair, so do not hand-roll one beside it.** Both
1499
1446
  lines are `sm`, `leading="tight"`, no gap between them — the stacked-pair law in
1500
1447
  [composition.md](./composition.md), applied by the component rather than restated at each
1501
- call site. It shipped rendering that pair as PROSE (two 24px boxes plus a 2px gap = 12px
1502
- between the lines, against the 6px the rule prescribes), and the height was sized around
1503
- 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.
1504
1449
  A `description` passed as a NODE keeps its own leading — the component only sets it on the
1505
1450
  string branch, because forcing a line box on somebody else's subtree re-spaces content that
1506
1451
  may not be a line of text at all.
@@ -1524,14 +1469,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1524
1469
  default.** A `SummaryLine`/`KPIStrip` figure above the register is a claim that ONE column is
1525
1470
  the reason to scan this page; left unset, `priority` defaults to column order and sheds
1526
1471
  right-to-left, so that exact column is the first thing table_fit drops the moment the
1527
- container narrows — the banner keeps promising a number the row beneath it can no longer
1528
- show. The fix is one line per column, not a rewrite: rank by what the summary already flagged
1529
- as the point, not by where the column happens to sit in the header row.
1530
- **`detail` + `expanded` reveal a row's detail BENEATH it instead of navigating away.** A
1531
- register answers "which one"; the moment it cannot answer "and what about it" the reader is
1532
- sent to a drawer, loses the surrounding rows, and pays a navigation to come back — which is
1533
- what makes scanning twenty records unaffordable. Reach for it when the detail is read or
1534
- 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
1535
1476
  may be open at once is the caller's rule, so the component does not pick one. The detail
1536
1477
  renders as a SIBLING of the press surface (a control inside it would otherwise be swallowed
1537
1478
  by the row's own toggle), the row shows the open wash while it is out, and the door announces
@@ -1539,8 +1480,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1539
1480
  to expand.
1540
1481
  **`Table.count` + `TableRow.ordinal` make it a COUNTED register** — a narrow gutter left of
1541
1482
  `leading` carrying each row's position, headed by the total. Reach for it on any register
1542
- long enough to scroll: it answers "how many" without paging to the end and "where am I"
1543
- 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
1544
1484
  find. `count` is the single switch — omit it and no gutter is reserved, so an uncounted
1545
1485
  register keeps every pixel it had. It is CHROME, not a column: it never sorts, never drops,
1546
1486
  and is excluded from the column band, but it IS reserved space the fit accounts for.
@@ -1560,8 +1500,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1560
1500
  `SortHeaderLabels`: the sortable column header and the sort-state helpers `Table`/
1561
1501
  `DataGrid` consumers drive. `sortBy` copies rather than mutates, and compares strings with
1562
1502
  `localeCompare` (case- and accent-folded, `numeric`) rather than `<`/`>` — code-point order
1563
- exiles every accented letter past the whole unaccented alphabet, so a Vietnamese register
1564
- 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
1565
1504
  NUMBER for anything ordered numerically (a timestamp, a count).
1566
1505
  - **`data_grid`** — `DataGrid` + `gridRowStyle`: the inline-managed grouped table (see
1567
1506
  [Tabular data](#tabular-data--pick-by-scale--intent)); `labels` localizes the sort-header
@@ -1593,9 +1532,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1593
1532
  `Summary.Header` / `Summary.Total` / `Summary.Metric` / `Summary.Distribution` /
1594
1533
  `Summary.Facts`, composed as children. **Declare the buckets ONCE on the root and every part
1595
1534
  derives from them** — the headline is their sum (there is no `total` prop to go stale), the
1596
- segments and the legend swatches are the buckets' own colours. That correspondence is the
1597
- reason it exists: a `segments` array passed beside an `items` array beside a `total` drifts
1598
- 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
1599
1538
  rows that does not (a condition like "overdue" beside "draft / sent / paid", counted again
1600
1539
  from a different question) goes to `Summary.Facts` as `extra`, where it renders with NO swatch
1601
1540
  — the visual difference says it is not a slice of the bar. Ordering is the caller's and is
@@ -1631,7 +1570,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1631
1570
  springing to a filled check when done, distinct from the square checkbox; the
1632
1571
  task/to-do/checklist control. `partial` half-fills it, for the row that is underway but not
1633
1572
  finished (a task at "doing", a step whose children are half ticked) — a binary ring reports
1634
- that row as untouched, in the column readers scan fastest. The CLICK stays binary
1573
+ that row as untouched. The CLICK stays binary
1635
1574
  (`onChange(done)`) so the ring means one thing everywhere; `partial` is reached through
1636
1575
  whatever NAMES it — a status cell, or children ticking off — never by cycling the ring. Keep
1637
1576
  it monochrome and let colour live in the status cell.
@@ -1680,9 +1619,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1680
1619
  never dismiss it — clicks, scrolls, and Escape belong to that layer until it closes. In a
1681
1620
  NARROW VIEWPORT (`small`, under 768) it presents instead as a modal bottom sheet (scrim, close
1682
1621
  button, slide-up) — decided by `PopoverContent` itself off the screen size, with nothing to pass
1683
- and no way to answer it differently: an overlay fills the viewport whatever box it was opened
1684
- from, so the width of the column holding the trigger is never the question. The viewport, not
1685
- 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.
1686
1624
  `PopoverContent` already insets its
1687
1625
  body 12px — put content directly in it, NEVER add your own padding `View` (that double-pads);
1688
1626
  title/actions go in `PopoverHeader` / `PopoverFooter`. **A fixed width is the `width` PROP,
@@ -1691,8 +1629,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1691
1629
  then stretches the panel and leaves the content adrift in a box wider than itself. A popover
1692
1630
  with no bands never sees this.
1693
1631
  The built-in scroller is **full-bleed horizontally** and re-insets its content by the same
1694
- amount, so a scrolling body reaches the panel's real edges its content slides under the
1695
- 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
1696
1633
  same column the header and footer use. Nothing to opt into; `disableBodyScroll` consumers
1697
1634
  (which own their scroll) are untouched.
1698
1635
  - **`popover_nav`** — `usePopoverNav` + `PopoverScreen` + `PopoverNavHeader` — the popover's
@@ -1735,8 +1672,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1735
1672
  section wrappers must be DIRECT children of the ScrollView content. The highlight and the
1736
1673
  jump both re-measure the live DOM on web, so content loading in above a section never
1737
1674
  strands them. Reaching the END of the scroll activates the LAST key regardless of offsets —
1738
- a final section shorter than the viewport never scrolls its top past the trigger line, so
1739
- 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
1740
1677
  the rail becomes a PINNED bar naming the CURRENT section that opens a full-page
1741
1678
  section-picker `Modal` — never a horizontal tab strip. Worked example:
1742
1679
  [`tpl_record`](../examples/tpl_record.tsx).
@@ -1747,11 +1684,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1747
1684
  ### Numbers & charts
1748
1685
 
1749
1686
  **These are where a work screen gets its visual interest, and it is the only kind that survives
1750
- contact with a real dataset.** Software with no photography has to find its imagery in the data —
1751
- a figure at display size, a delta's colour, a line's shape, a bar's proportion, digits aligning
1752
- down a column. Each of these is information first, so none of it is the decoration that gets cut
1753
- in review. A screen reported as flat or boring is usually a screen with numbers in it rendered as
1754
- 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".
1755
1691
 
1756
1692
  Two failure modes to hold against them. A figure nobody compares to anything is not a metric — it
1757
1693
  is a number with a large font, and the size promises a significance it cannot deliver. And a
@@ -1763,7 +1699,7 @@ component rather than showing it at zero.
1763
1699
  (ⓘ), so the reader can find out what "on-time %" counts without leaving the screen.
1764
1700
  - **`kpi_strip`** — `KPIStrip`: the boxed dashboard stat band. Every item has to answer a
1765
1701
  question the surface below it cannot — a strip restating totals the table already carries is
1766
- the trim test failing, and it costs the reader a pass over the screen to discover that.
1702
+ the trim test failing.
1767
1703
  - **`metric`** — `Metric`: a bare headline figure (`format`
1768
1704
  currency|number|percentage|none, `tone`, `size` sm|md|lg|hero). Bare = no card, no label of its
1769
1705
  own, so whatever contains it has to name it. Always `tabular` — it is set that way here, and it
@@ -1777,10 +1713,13 @@ component rather than showing it at zero.
1777
1713
  Direction words via the `trendFooter` locale slice; goes in `SectionCard footer`.
1778
1714
  - **`sparkline`** — `Sparkline`: the inline mini trend line. Sized for a ROW or a card corner:
1779
1715
  it carries SHAPE (rising, spiky, flat) and no readable values, which is exactly what a
1780
- register wants a column of them turns "12 numbers per row" into a pattern the eye reads
1781
- 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.
1782
1717
  - **`bar_chart`** / **`line_chart`** / **`pie_chart`** — `BarChart` / `LineChart` /
1783
- `PieChart`: the canonical SVG chart set (no recharts).
1718
+ `PieChart`: the canonical SVG chart set (no recharts). `LineChart` prints as many x labels
1719
+ as the track fits and thins the rest, anchored on the LAST point — the newest reading is the
1720
+ one a reader looks up, and anchoring there is what keeps the spacing uniform. The first
1721
+ point is labelled only when it clears the same distance, so a series whose length does not
1722
+ divide evenly drops its opening label rather than crowding the one beside it.
1784
1723
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
1785
1724
  plain sm tabular count beside it.
1786
1725
  - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one. Same API shape as the bar — a real `value`/`max` rather than a
@@ -1792,7 +1731,34 @@ component rather than showing it at zero.
1792
1731
  bare ring renders no text, so it announces nothing — whatever owns it must say what it
1793
1732
  measures.
1794
1733
  - **`stacked_progress_bar`** — `StackedProgressBar`: one whole split across segments (a
1795
- status mix on one bar).
1734
+ status mix on one bar). Its segments size against EACH OTHER, not against `total` — the
1735
+ track is always full — because one whole is the whole of itself. That is the right model for
1736
+ one bar and the wrong one for a column of them: ten unequal totals draw ten equal bars, which
1737
+ reads as a chart and states nothing. Several wholes → `stacked_bar_chart`.
1738
+ - **`stacked_bar_chart`** — `StackedBarChart`: rows of stacked bars on ONE SHARED SCALE
1739
+ (`max` defaults to the largest row total). Bar LENGTH answers "how big is this one", the
1740
+ segment run answers "what is it made of", in one pass — spend against return per campaign,
1741
+ cost make-up per product line. `series` (key/label/colour, one hue family per dimension)
1742
+ drives a legend that is on by default; each `StackedBarRow` takes `label` + optional `meta`,
1743
+ a pre-formatted headline `value` with its `valueTone`, and a `caption` sentence, so colour is
1744
+ never the only channel. Between `stacked_progress_bar` (one whole, own track) and `breakdown`
1745
+ (one whole + ranked share rows beneath it): reach here the moment there are SEVERAL wholes to
1746
+ compare.
1747
+ - **`waterfall_chart`** — `WaterfallChart`: the BRIDGE — an opening level, the signed steps
1748
+ that move it, and the level they close at. Revenue walked down to net, budget to actual,
1749
+ opening balance to closing. `items` carry a `kind`: `basis` and `total` are LEVELS standing on
1750
+ zero, `delta` (the default) is a signed STEP floating between the level before it and the
1751
+ level after; colour derives from that — neutral basis, emerald up, red down, and a close in
1752
+ the ink of its own sign — so a caller passes no colours. `orientation` vertical columns |
1753
+ horizontal rows (take rows for a narrow container or long step names; a 70px column and a
1754
+ rotated label is not an answer). The span always includes zero, so a bridge that closes at a
1755
+ loss hangs below the baseline instead of floating on a scale that exaggerates every step.
1756
+ `waterfallLayout` is the pure geometry, exported and tested on its own.
1757
+ Pick it over `ledger` when the reader is scanning for SHAPE (a dashboard), and `ledger` when
1758
+ the figures must be read exactly and reconciled line by line (a record). Over `bar_chart`
1759
+ whenever the bars are steps in ONE arithmetic — five columns all starting at zero state a
1760
+ comparison nobody meant. The steps must be all of the arithmetic and must close on the total
1761
+ drawn; derive them from one computation, never two that can drift.
1796
1762
  - **`step_progress`** — `StepProgress`: N-of-M dots/segments progress. A real `progressbar`
1797
1763
  (valuenow/min/max) because its segments are decoration over ONE quantity — the opposite
1798
1764
  case from `stepper`, whose steps carry content a `progressbar` would hide. Pass the stage
@@ -1813,17 +1779,17 @@ component rather than showing it at zero.
1813
1779
  `Matrix.Header` (corner + axis labels) + `Matrix.Grid` (`display` number|heat|both — the
1814
1780
  cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) +
1815
1781
  `Matrix.Legend`. Pick over `Heatmap` when the NUMBER and totals matter. Figures are
1816
- RIGHT-aligned — they are `tabular`, and centring them defeats the aligned column that is
1817
- the reason to read a pivot at all — and row labels carry default ink, because a row's
1818
- 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
1819
1785
  its own column widths.
1820
1786
  - **`matrix_totals`** — the data layer for `Matrix`: `matrixTotals` (the cross-tab
1821
1787
  aggregation) + `MatrixAxisItem` / `MatrixCellRef` / `MatrixTotalsResult`; React-free, so a
1822
1788
  KPI can be driven off the same numbers the grid shows.
1823
1789
  - **`highlight_segments`** — `splitHighlightSegments`: cuts a string into plain and
1824
- highlighted runs for `Composer`'s `highlightRanges`. React-free, and the reason it is
1825
- separate: it feeds a mirror sitting behind a real text field, so a boundary off by one
1826
- 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,
1827
1793
  unsorted and out-of-bounds ranges all normalize; no input drops a character.
1828
1794
  - **`legend_item`** — `LegendItem`: one swatch + label of a chart legend.
1829
1795
  - **`remainder_meter`** — `RemainderMeter`: allocated-vs-remaining meter
@@ -1833,7 +1799,7 @@ component rather than showing it at zero.
1833
1799
  whole model is one source distributed one way, and a caller with a second relationship to show
1834
1800
  reaches for a second instance before reaching for a wider one. Extract a true 3-column strip
1835
1801
  only once a SECOND real caller needs the exact same 3-way shape (the kit's own
1836
- extract-on-the-second-hand-roll rule) — one confirmed need is not yet a pattern.
1802
+ extract-on-the-second-hand-roll rule).
1837
1803
  - **`allocation_row`** — `AllocationRow`: one target's row in an allocation surface (pairs
1838
1804
  with `RemainderMeter`).
1839
1805
 
@@ -1863,19 +1829,16 @@ component rather than showing it at zero.
1863
1829
  composition.md §registers); and **`right` renders beside the press target, not inside
1864
1830
  it**, so a control there is valid HTML and keeps its own click — though on a row that
1865
1831
  EXPANDS, verbs belong in the BODY and `right` is better kept for a fact (a duration, a
1866
- badge). A control sharing the label row with the press target is what produced the nesting,
1867
- the first-line box and the chevron-pairing problems in the first place.
1832
+ badge).
1868
1833
  - **`checklist`** — `Checklist` + `ChecklistGroup` + `ChecklistItem` + `ChecklistNote` /
1869
1834
  `ChecklistActions` / `ChecklistField`: THE run of tickable rows, at any
1870
1835
  altitude — a ring, a title, a value beside it, a muted note, a detail block, an optional
1871
1836
  grouping level. **`connected` is the LINE joining the rings, and it is the whole
1872
- difference**: on for ONE record walking positions (it asserts
1873
- sequence), off for a SET picked in any order, where a line claims an order the work does not
1874
- 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
1875
1838
  highest stamp so one click marks everything below it — a parent that ticked its children wrote
1876
1839
  what the reader never said (today's date onto milestones that happened on unrecorded days),
1877
- and a rung passed without a recorded day shows a BLANK date, which is the honest answer to
1878
- "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
1879
1842
  COMPLETION control, because a phase
1880
1843
  owns no completion of its own and a ring there is a control that never responds (one identical
1881
1844
  to the pressable rings beneath it invites the press it refuses); and no BODY, because a
@@ -1935,7 +1898,7 @@ component rather than showing it at zero.
1935
1898
  `accept`, `label`/`hint`/`dropLabel`, `height`); click falls back to a picker, and while
1936
1899
  mounted it ALSO takes a paste (Ctrl/Cmd+V) via its inner `FileDropTarget` `paste`, scoped to
1937
1900
  the well's region. `label`/`dropLabel`/`hint` default from the locale's `fileDropzone` slice
1938
- (the hint now names the paste) — pass them only to override.
1901
+ (the hint names the paste) — pass them only to override.
1939
1902
  - **`file_drop_target`** — `FileDropTarget`: makes ANY region accept a file drag-drop —
1940
1903
  `{ onFiles, accept?, multiple?, paste?, disabled?, children, style? }` wrapped around a
1941
1904
  section, a card, a whole panel, so nobody hunts for a dropzone. While a drag hovers, the
@@ -1972,8 +1935,8 @@ component rather than showing it at zero.
1972
1935
  needs, so withholding `onAdd`/`onRemove` IS the read-only shape — there is no `readOnly`
1973
1936
  mode. **A HOST verb is a plain `Button`** reading **`useFilesEditorSelection()`**
1974
1937
  (`{selected, selectedIds, files, selectMode, clear, exit}`) — an AI read over the picked
1975
- papers, "send to the broker", a ZIP: acts the kit has never heard of, which is why there is
1976
- 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
1977
1940
  previews). Bar words resolve prop → `LoticsLocale.filesEditor`; in-flight tile words →
1978
1941
  `LoticsLocale.fileUpload`. **No empty state:** with zero files it is a bare bar, so pair it
1979
1942
  with a `FileDropzone` for the well a records screen opens on. Use `FileGrid`/`FileRows` bare
@@ -2002,8 +1965,8 @@ component rather than showing it at zero.
2002
1965
  - **`file_thumbnail`** — **`diff`** puts a `DiffKind` corner mark on a tile (top-LEFT, the one
2003
1966
  corner remove/selection/uploading do not claim) and fades a `removed` one. The mark rides a
2004
1967
  white RING: a tile's ground is an arbitrary photo, and a pale tint over a pale scan is a
2005
- smudge. A `removed` tile fades its BODY only — the mark stays at full strength, since a faded
2006
- 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
2007
1970
  inside a row that carries the change instead. A grid says MEMBERSHIP well (arriving, leaving) and PAIRING not at
2008
1971
  all — nowhere on a 96px tile to name what a replacement supersedes — so a set where things
2009
1972
  are being REPLACED wants `FileRow`. Also: `FileThumbnail` + `DisplayFile` + `THUMBNAIL_SIZE` /
@@ -2035,7 +1998,7 @@ component rather than showing it at zero.
2035
1998
  + a ⋯ Download/Open-external/Remove menu; composes `FileRow` + `ActionMenu` +
2036
1999
  `FileGalleryModal`; localized via the `gallery` locale slice. **`meta` has NO default**: the
2037
2000
  badge states the type as colour and as a label, and the filename ends in the extension, so a
2038
- 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
2039
2002
  line only when it carries something the NAME does not — what an invoice covers, who uploaded
2040
2003
  it, when it landed.
2041
2004
  - **`file_badge`** — `FileBadge`: the two-tone file-type mark (PDF / XLSX / DOCX / video /
@@ -2065,8 +2028,7 @@ component rather than showing it at zero.
2065
2028
  absolutely inside a positioning wrapper the component owns) because a `<video>` is a
2066
2029
  replaced element: once metadata loads it reports the source's intrinsic size, and any
2067
2030
  ancestor sized by its content grows to match — with RN-web's `flexShrink: 0` default,
2068
- nothing pushes back, so a 720p capture measurably widened a 342px chat column to 367px
2069
- 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
2070
2032
  transport already carries fullscreen, picture-in-picture and download, and a second
2071
2033
  affordance beside them is both redundant and a claim about where it leads. A source it
2072
2034
  cannot load is surfaced, never silent — a media element that fails paints an EMPTY BOX and
@@ -2165,10 +2127,9 @@ component rather than showing it at zero.
2165
2127
  - **`confidence`** — `Confidence` + `ConfidenceLevel` + `levelFromScore`: confidence IS a
2166
2128
  callout — the meter + level phrase head the block, the REQUIRED `children` carry the basis
2167
2129
  (what was checked, what failed); the box stays white with a hairline border at every
2168
- level — the meter is the only colored element. There is NO standalone meter — a level without its reasons cannot
2169
- be rendered; where a compact meter used to decorate a ranked row or proposal card, rank /
2170
- badges / severity carry the standing instead. Also: calibrated
2171
- high/med/low; localized via the provider.
2130
+ level — the meter is the only colored element. There is NO standalone meter — a level without
2131
+ its reasons cannot be rendered; rank / badges / severity carry the standing instead. Also:
2132
+ calibrated high/med/low; localized via the provider.
2172
2133
  - **`diff_value`** — **`DiffValue`, THE atom of a review surface**: a value that changed,
2173
2134
  rendering `before` struck above/before `after`. Owns no container, no context, no decision —
2174
2135
  so it drops into a table cell, a `DetailRow`, a total, a chip or a sentence and inherits that
@@ -2176,16 +2137,15 @@ component rather than showing it at zero.
2176
2137
  a chip for a link being repointed — and a node `before` is struck by a DRAWN rule rather than
2177
2138
  by text decoration, because `line-through` on a `Text` does not cross a `View` child (the old
2178
2139
  value would otherwise render at full strength beside its replacement with nothing saying it is
2179
- the one going away — type-checked, rendered, and wrong). Reach for a node when the value
2180
- genuinely is not text: an avatar carries identity a string cannot, and a review that swapped
2181
- the record's own chip for a name would show the operator a surface they have never seen at the
2182
- one moment they are asked to check something. A node belongs where it fits at FULL size — a
2183
- form row, a card. Shrinking a composite into a table cell takes its own type off the scale
2184
- (a file badge sized to a cell rendered its label at 5px), so a file in a CELL is a filename at
2185
- the column's own size, not a miniature badge. Worked in `dev/pages/ai.tsx` § every shape.
2140
+ the one going away). Reach for a node when the value
2141
+ genuinely is not text: an avatar carries identity a string cannot. A node belongs where it
2142
+ fits at FULL size a
2143
+ form row, a card. Shrinking a composite into a table cell takes its own type off the scale,
2144
+ so a file in a CELL is a filename at the column's own size, not a miniature badge. Worked in
2145
+ `dev/pages/ai.tsx` § every shape.
2186
2146
  **`before === after` collapses to ONE plain value** —
2187
2147
  agreement is not a change, and a document confirming what the record already says is the
2188
- GOOD case, not a diff of a value against itself. **`delta`** (a host-formatted string, the
2148
+ GOOD case. **`delta`** (a host-formatted string, the
2189
2149
  same contract as `Finding.delta`) prints HOW FAR it moved, because
2190
2150
  `1.600.000 → 1.481.481` otherwise makes the reader subtract to find out whether a
2191
2151
  correction is trivial or alarming. `layout` stacked (a column of figures) | inline (prose, dense
@@ -2193,9 +2153,9 @@ component rather than showing it at zero.
2193
2153
  for an addition, `after` for a removal. BOTH values stay on screen: the old one is the only
2194
2154
  evidence the reader has that the correction is the right SIZE.
2195
2155
  **`placeholder` is YOUR string — translate it.** The component renders no word of its own
2196
- (its only fallbacks are `—` and empty), so nothing here reaches the locale packs. That is
2197
- deliberate rather than an omission: `DiffMark` beside it DOES ship localized words, but the
2198
- two are different sentences even when English collides them — the mark says what happened to
2156
+ (its only fallbacks are `—` and empty), so nothing here reaches the locale packs.
2157
+ `DiffMark` beside it DOES ship localized words, but the two are different sentences even when
2158
+ English collides them — the mark says what happened to
2199
2159
  the ROW ("Removed" / "Xóa"), the placeholder says what is in the FIELD now, which reads "đã
2200
2160
  xóa" or "bỏ trống", never the bare verb. The same prop also carries "Pick a candidate below"
2201
2161
  and "Not on the document". Pull it from your own catalog.
@@ -2204,9 +2164,8 @@ component rather than showing it at zero.
2204
2164
  accessible word (never colour alone — "added" vs "removed" by hue is invisible to a reader who
2205
2165
  cannot separate them, and that is the distinction a review turns on). **ONE treatment
2206
2166
  everywhere: a filled 22px DISC**, 14px glyph, 700 ink on a 100 ground. A form marked with bare
2207
- glyphs beside a table marked with filled shapes reads as two systems saying the same thing; a
2208
- reader parses shape before meaning, so an unexplained difference reads as an accident however
2209
- principled the reason. The disc also gives the glyph its own ground, which is what lets one
2167
+ glyphs beside a table marked with filled shapes reads as two systems saying the same thing.
2168
+ The disc also gives the glyph its own ground, which is what lets one
2210
2169
  mark sit on a white row, a tinted cell, or a photograph. **`unchanged` renders as an empty
2211
2170
  disc-sized HOLE** — silence is the honest rendering of "nothing happened", it keeps the
2212
2171
  column's width so nothing shifts left, and it announces nothing (eleven "Unchanged"es is the
@@ -2217,8 +2176,7 @@ component rather than showing it at zero.
2217
2176
  an `Inline*` editor draws (40px band, 8px horizontal padding inside a 1px transparent border),
2218
2177
  minus the editor. A record's value column mixes editors with things that are not editors, and
2219
2178
  anything rendered raw beside them starts at the cell edge, 20px tall, at the top — so the
2220
- column quietly acquires TWO left edges and two baselines, and `DetailRow`'s label (which pads
2221
- down to meet a control) agrees with one kind of row and not the other. `InlineStatic` is the
2179
+ column quietly acquires TWO left edges and two baselines. `InlineStatic` is the
2222
2180
  STRING case and composes it; reach for the slot when the value is a node, which a review
2223
2181
  surface always is. An ANNOTATED row needs the grid even with no editor in sight, because the
2224
2182
  annotation stack is inset to a control's words — data_entry.md §The editability affordance