@lotics/ui 43.3.0 → 43.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -16,7 +16,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
- | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
19
+ | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
21
21
  | [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
22
22
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
@@ -55,8 +55,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
55
55
  order, where a line claims an order the work does not have. **`mark`** asks *does ticking mean
56
56
  DONE or CHOSEN* — a ring for completion, the square box for picking (which forms to produce,
57
57
  which records to export). Spend the ring on a choice and a reader who learned that a filled
58
- ring means "done" reads a ticked pick-list as work already carried out. It replaced separate
59
- task/pipeline families that had converged on one anatomy. A GROUP is a phase — a name, optionally a
58
+ ring means "done" reads a ticked pick-list as work already carried out. A GROUP is a phase — a name, optionally a
60
59
  Show/Hide disclosure, and no body: it owns no COMPLETION, and a ring that never responds invites
61
60
  a press it refuses. Rows tick ONE at a time; derive position from the highest stamp and one click marks
62
61
  everything below it without inventing dates. A checklist REPORTS — a field owned by a section is
@@ -101,17 +100,13 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
101
100
  padding. `PressableRow` (all variants), `Table`, `ChoiceList`, `ListItem` and `FileRow`
102
101
  already do this, so a screen composed from the kit aligns with **no per-site padding at all**.
103
102
  The moment one component insets instead, every neighbour has to be hand-padded by the same
104
- amount to catch up, and that compensation spreads to each new surface built beside it which
105
- is how a `SummaryLine`, a `Clarify` question and a bare table's footer each acquired a
106
- padding they should never have needed. **An indent is legible only when something VISIBLE
103
+ amount to catch up, and that compensation spreads to each new surface built beside it. **An indent is legible only when something VISIBLE
107
104
  occupies it** — a checkbox, an ordinal, a diff mark. An empty indent is a defect.
108
105
  - **The one exception is a self-contained OVERLAY.** A menu or a selector's popover
109
106
  (`MenuListItem`, `OptionList`) has nothing outside itself to align with, so its rows keep
110
107
  plain padding. Bleeding there would push rows past the popover's own edge.
111
108
  - **A slot holding another component's part takes THAT component's constant**, never a
112
- hand-picked number that happens to look close: `AgentRun`'s dot column was 18 while the
113
- `Marker` it holds is `NODE` (24), so a 24px dot centred in an 18px column put the whole run
114
- rail 3px left of every other band in the dialog. Import the owner's constant.
109
+ hand-picked number that happens to look close. Import the owner's constant.
115
110
  - Inside a `Dialog`, every band reads `dialogGutter(small)` — header, scroll area, footer and
116
111
  any pane a caller drops in. A hand-copied `small ? 16 : 24` is a fifth copy of a number
117
112
  that has to agree with four others.
@@ -124,8 +124,7 @@ transition — never leave a live button waiting on a network round-trip:
124
124
 
125
125
  - **Same-tick feedback.** On press, synchronously close the dialog / swap to the run surface
126
126
  (`AgentRun` with its streaming state) so there is nothing left to click. A button that stays
127
- pressable while an attach/start request round-trips invites double-fires — observed in
128
- production as stacked paid runs.
127
+ pressable while an attach/start request round-trips invites double-fires.
129
128
  - **Single-flight in app code too.** Guard the handler with a ref (state alone races the
130
129
  re-render): first press wins, later presses no-op until the flow settles.
131
130
  - **The SDK backstops runs, not workflows.** `useAgentRun().run()` is single-flight by default
@@ -242,14 +241,10 @@ feed**; the starting row IS the placeholder, consistent and localized.
242
241
  (Sonnet / Opus, not Haiku); the renderer shows every reasoning part it's handed.
243
242
 
244
243
  **Localization.** The feed's fixed chrome — the "Thinking" label, the "Input"/"Output"/"Error"
245
- panel titles, the "awaiting" annotation, and the "Retry" action resolves through the
246
- `LoticsLocale` `agentRun` slice (translate once at the provider; a `vi` app gets them for free).
247
- **and so are the tool labels** (`agentRun.tools`, keyed by raw tool name). Translating a tool
248
- row is no longer each app's job: an English row under a Vietnamese "Đang suy nghĩ…" was the kit
249
- contradicting itself. The "{n} steps" suffix is locale-backed too (`agentRun.steps`). `labelForCall`
250
- and `stepsLabel` remain as per-call-site OVERRIDES — for phrasing a row by what
251
- it targets — not as the only route to a translation. A tool name in neither the locale map nor
252
- `labelForCall` falls back to its prettified form.
244
+ panel titles, the "awaiting" annotation, the "Retry" action, the tool labels (`agentRun.tools`,
245
+ keyed by raw tool name) and the "{n} steps" suffix (`agentRun.steps`) resolves through the
246
+ `LoticsLocale` `agentRun` slice (translate once at the provider; a `vi` app gets them for free);
247
+ `labelForCall` and `stepsLabel` remain as per-call-site OVERRIDES.
253
248
 
254
249
  **Answering an awaiting call — `ApprovalPrompt`.** The `awaiting` row inside `AgentRun` is
255
250
  READ-ONLY — it only NAMES that a call is parked on a human decision. The surface that ANSWERS it
@@ -466,8 +461,7 @@ rather than wherever the feed was scrolled to.
466
461
  holds the wizard's verbs while a question is up, **Stop** while the run streams, and nothing once
467
462
  it settles; the RUN decides, which is the whole point of handing it the run. A host that names a
468
463
  single phase out here duplicates that decision and gets it wrong the moment the bar learns a new
469
- one — which is precisely how two streaming phases ended up with an empty footer and no way to
470
- stop.
464
+ one.
471
465
 
472
466
  **`run` is a SHAPE, not an import** (`AgentRunLike`: `status`, `parts`, `pendingChoice`,
473
467
  `answerChoice`, `cancel`, `error`; `AgentRunQuestion` names what `pendingChoice` carries, so a template or
@@ -610,8 +604,8 @@ ranked set, don't cram it into chat prose:
610
604
  document-driven records) — the record's files block feeds ONE "Use AI" entry that FORKS into
611
605
  the two document tasks, each a specialized run with a task-pure result: **Extract** (files
612
606
  read → fields already matching fold into one quiet line → every add / update / conflict a
613
- a `DiffValue` per field (the struck current value, the proposed one, candidate rows), the record's current value
614
- a first-class choice — plus proposed new lines as record-body `Change`s → one outcome-named
607
+ `DiffValue` per field (the struck current value, the proposed one, candidate rows), the record's current value
608
+ a first-class choice — plus proposed new lines as record-body `Change`s →
615
609
  an explicit outcome-named commit) and **Cross-check** (documents compared against the record and
616
610
  each other → ranked `Finding`s — severity, title, the prominent metric, sources — separated
617
611
  by hairlines; the findings ARE the outcome the human acts on). The fork carries an OPTIONAL
@@ -642,12 +636,11 @@ ranked set, don't cram it into chat prose:
642
636
  ## The visual vocabulary — functional colour, no gimmicks
643
637
 
644
638
  The AI vocabulary has **no purple accent and no gimmick glyphs** (no sparkles) — but it is NOT
645
- monochrome: **colour is used where it carries meaning, not for decoration.** What the violet
646
- sparkle used to carry now reads structurally — **provenance** is a quiet sentence-case microlabel
639
+ monochrome: **colour is used where it carries meaning, not for decoration.** **Provenance** is a quiet sentence-case microlabel
647
640
  naming the artifact ("Proposed", "Match", "Mismatch", "Suggested edit", "Question" — xs,
648
- muted, medium; all-caps is banned kit-wide), and the agent's **reasoning** is a left-ruled margin
641
+ muted, medium; all-caps is banned kit-wide), the agent's **reasoning** is a left-ruled margin
649
642
  note (a hairline rule + muted text — `ChangeReasoning`), quoted apart from the facts and the
650
- human's controls while **status/severity/diffs use functional colour** the way the rest of the
643
+ human's controls, and **status/severity/diffs use functional colour** the way the rest of the
651
644
  kit does:
652
645
 
653
646
  - **Confidence** — the 3-tick meter + the full phrase, emerald / amber / zinc by level.
package/docs/catalog.md CHANGED
@@ -80,8 +80,7 @@ renders these per option.)
80
80
 
81
81
  **Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 28 / 40 / 72
82
82
  (`@lotics/ui/avatar_size`, shared by `Avatar`, `MemberChip` and `GroupAvatar`). Default `md`.
83
- The number was removed because the same concept had drifted to eleven different values across
84
- the product and its apps, so one person looked different on every screen. Pick by density:
83
+ Pick by density:
85
84
  `sm` for a dense row or inline cell, `md` for a control band, register or list (the default),
86
85
  `lg` for a prominent row or the account menu, `xl` for a profile's own header.
87
86
 
@@ -89,9 +88,7 @@ the product and its apps, so one person looked different on every screen. Pick b
89
88
  avatar is IN a control or IS one.** `md` is `CONTROL_CONTENT_HEIGHT` (28) — the largest rung a
90
89
  40px control band seats without growing, and the height `InlineButton` and a `md` `IconButton`
91
90
  already used to sit inside a field. An avatar is the tallest thing in whatever row holds it, so
92
- a rung that overshoots doesn't look slightly big, it RESIZES the control: at the old 36 the same
93
- member chip measured 46 in a `Select` trigger, 50 in an inline editor and 54 in an `InlineSlot`,
94
- because each surface had picked its own inset with no number to pick it from. Put nothing taller
91
+ a rung that overshoots doesn't look slightly big, it RESIZES the control. Put nothing taller
95
92
  than `md` in a control. `lg` is `CONTROL_HEIGHT` (40) — the avatar that stands in a band on its
96
93
  own, an account menu beside a row of icon buttons, so it measures what they do. Anything bigger
97
94
  belongs in a row that sets its own height.
@@ -155,8 +152,7 @@ axes carry every shape it takes, and BOTH are decisions about meaning, not looks
155
152
  work already carried out, and nothing on screen corrects them. (The shapes are 24px either way
156
153
  and share one gutter, so a surface swaps between them without moving anything.)
157
154
 
158
- A record's Progress and a desk's to-do list are the same component two booleans apart; they were
159
- two families until the anatomy proved identical.
155
+ A record's Progress and a desk's to-do list are the same component two booleans apart.
160
156
 
161
157
  **A row carries its TITLE and its own stamp, and nothing else.** A checklist REPORTS: it does not
162
158
  collect, and it does not PREVIEW. A field some section owns — an assignee, an address, a portal
@@ -1068,7 +1064,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1068
1064
  `DetailTable`: the compound parent of
1069
1065
  a row STACK — `labelWidth` (default `DETAIL_LABEL_WIDTH`, 130 — THE kit's label column, and
1070
1066
  the record surface's) / `minHeight` (default 40, the inline-control grid) declared
1071
- ONCE + the `SPACE.md` (16) row gap — 8 was right while a field was a tint, but a stack of
1067
+ ONCE + the `SPACE.md` (16) row gap — a stack of
1072
1068
  BORDERED rows that close together fuses into one block. TWO columns, no third: the value
1073
1069
  FILLS what the label leaves, so nothing one row does can narrow its neighbours' editors. RESPONSIVE with no prop: it
1074
1070
  measures its own container (onLayout, not
@@ -1078,7 +1074,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1078
1074
  grammar); raise `minValueWidth` (default 160) when a cell holds
1079
1075
  MORE than one editor so the table stacks earlier. Two tables on one page share one grid by
1080
1076
  repeating the same labelWidth. Worked example:
1081
- [`tpl_record`](../examples/tpl_record.tsx). A label's FIRST line centres on the control line, not the label block: a wrapped label grows DOWNWARD past the value's first line rather than dragging its own first line above it, so "Registered address" over two lines still starts level with the value it names.
1077
+ [`tpl_record`](../examples/tpl_record.tsx).
1082
1078
  - **`record_summary`** — `RecordSummary`: the identity band of a record detail/drawer — ONE
1083
1079
  row: `title` xxl semibold tabular, `subtitle` sm muted, `status` Badge slot, optional
1084
1080
  `metric` {label,value,tone,note} pinned right, the band's ONE accent. The record's FIELDS
@@ -1175,8 +1171,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1175
1171
  and Save writes the pre-action values back over what it just fetched.
1176
1172
  The peek uses the popover's OWN anatomy: `PopoverHeader` for the identity, a scrolling body,
1177
1173
  and a **pinned `PopoverFooter`** for the verbs. A long block therefore scrolls with its actions
1178
- still reachable — the earlier hand-rolled footer was a body child and scrolled away, hiding its
1179
- own Save.
1174
+ still reachable.
1180
1175
  `Edit` swaps the SAME grid's value cells for inputs — a DRAFT, so nothing commits until `Save`,
1181
1176
  which fires `onSave` with **only the facts that CHANGED** (never a snapshot, so a lock or
1182
1177
  `before_update` sees the real edit). This is what lets a peek hold editors at all: a
@@ -1365,7 +1360,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1365
1360
  never `style`**: `style` reaches only the BODY, and the panel is `max-content`, so a width set
1366
1361
  there governs the scroller while the header and footer sit outside it — the first long title
1367
1362
  then stretches the panel and leaves the content adrift in a box wider than itself. A popover
1368
- with no bands never sees this, which is why `width` had to exist before one had both.
1363
+ with no bands never sees this.
1369
1364
  The built-in scroller is **full-bleed horizontally** and re-insets its content by the same
1370
1365
  amount, so a scrolling body reaches the panel's real edges — its content slides under the
1371
1366
  panel edge rather than under an invisible margin 12px inside it — while the text stays on the
@@ -1437,8 +1432,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1437
1432
  `PieChart`: the canonical SVG chart set (no recharts).
1438
1433
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
1439
1434
  plain sm tabular count beside it.
1440
- - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one
1441
- (it replaced `RingGauge`). Same API shape as the bar — a real `value`/`max` rather than a
1435
+ - **`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
1442
1436
  pre-computed percentage, optional `label`/`caption`, and a `format` that turns the centre
1443
1437
  figure off. `format="none"` with no label leaves the arc bare at any size: that is the
1444
1438
  TRIGGER form, for a control row where a figure would be unreadable and a bar would not
@@ -1505,10 +1499,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1505
1499
  the first-line box and the chevron-pairing problems in the first place.
1506
1500
  - **`checklist`** — `Checklist` + `ChecklistGroup` + `ChecklistItem` + `ChecklistNote` /
1507
1501
  `ChecklistActions` / `ChecklistField`: THE run of tickable rows, at any
1508
- altitude. It replaced a `task` family and a `pipeline` family that had converged on one
1509
- anatomy a ring, a title, a value beside it, a muted note, a detail block, an optional
1510
- grouping level differing only in whether a LINE joined the rings. **`connected` is that
1511
- line, and it is the whole difference**: on for ONE record walking positions (it asserts
1502
+ altitude a ring, a title, a value beside it, a muted note, a detail block, an optional
1503
+ grouping level. **`connected` is the LINE joining the rings, and it is the whole
1504
+ difference**: on for ONE record walking positions (it asserts
1512
1505
  sequence), off for a SET picked in any order, where a line claims an order the work does not
1513
1506
  have. Rows are ticked ONE AT A TIME; where the run is a sequence, derive position from the
1514
1507
  highest stamp so one click marks everything below it — a parent that ticked its children wrote
@@ -1662,10 +1655,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1662
1655
  NODE** as well as a string, because a second line can itself be carrying a change (a file
1663
1656
  being RECLASSIFIED is the same bytes filed differently — a `DiffValue` on the property); a
1664
1657
  node renders outside the muted single-line `Text`, which would clip it and fight its colour.
1665
- Both `sm` variants share ONE beat (48px min + 6px vertical padding): the static row used to
1666
- have neither, so it came out 37px against the pressable's 49 — whether a row was a door
1667
- decided how tall it was — and 37 sat under every other row beat in the kit (`DetailRow` 40,
1668
- `TableRow` 52, this component's own `md` 56), which is what "squeezed" literally was.
1658
+ Both `sm` variants share ONE beat (48px min + 6px vertical padding).
1669
1659
  - **`file_rows`** — `FileRows`: batteries-included file list — row press → built-in gallery
1670
1660
  + a ⋯ Download/Open-external/Remove menu; composes `FileRow` + `ActionMenu` +
1671
1661
  `FileGalleryModal`; localized via the `gallery` locale slice. **`meta` has NO default**: the
@@ -1838,9 +1828,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1838
1828
  `added`/`changed`/`removed`/`unchanged`, for the cases no single value carries. Glyph + an
1839
1829
  accessible word (never colour alone — "added" vs "removed" by hue is invisible to a reader who
1840
1830
  cannot separate them, and that is the distinction a review turns on). **ONE treatment
1841
- everywhere: a filled 22px DISC**, 14px glyph, 700 ink on a 100 ground. It briefly had two — a
1842
- bare glyph for dense surfaces, a filled shape for sparse ones and a form marked with bare
1843
- glyphs beside a table marked with filled shapes read as two systems saying the same thing; a
1831
+ everywhere: a filled 22px DISC**, 14px glyph, 700 ink on a 100 ground. A form marked with bare
1832
+ glyphs beside a table marked with filled shapes reads as two systems saying the same thing; a
1844
1833
  reader parses shape before meaning, so an unexplained difference reads as an accident however
1845
1834
  principled the reason. The disc also gives the glyph its own ground, which is what lets one
1846
1835
  mark sit on a white row, a tinted cell, or a photograph. **`unchanged` renders as an empty
@@ -380,12 +380,7 @@ holds another component's PART takes that component's constant, never a hand-pic
380
380
  centred in an 18px column put the whole run rail 3px outside the surface's edge — every row
381
381
  wrong by the same amount, so it read as internally consistent and nothing flagged it.
382
382
 
383
- The register used to pad 20 WITHOUT the negative margin, and the cost was not
384
- the 20px — it was that everything nearby had to be hand-padded by the same 20 to
385
- line up with it. A `Table`'s header band, its static rows and its expanded detail
386
- all inherited the number; a `SummaryLine` above the register got it at the call
387
- site; a bare table's footer borrowed `CardFooter` and picked up a *card's*
388
- interior gutter. **An indent is legible only when something VISIBLE occupies it**
383
+ **An indent is legible only when something VISIBLE occupies it**
389
384
  — a checkbox, an ordinal, a diff mark. An empty one is a defect, and the
390
385
  compensation it forces spreads to every new surface that sits beside it.
391
386
 
@@ -425,8 +420,7 @@ content off the register entirely.
425
420
  ornament sits beside a text block and centres on it — a `Timeline`'s disc, a marker, an avatar,
426
421
  a rail glyph — the ornament's position is a function of the text's height, so an unbounded text
427
422
  block moves it. A feed is where this actually bites, because a feed is the one row primitive
428
- whose text is written by the USER: measured on a real interaction log, one 700-character summary
429
- drew a 180px label and put the disc 80px below the line it names. Bound the text; the row keeps
423
+ whose text is written by the USER. Bound the text; the row keeps
430
424
  its beat and the ornament keeps its line. Where the full value still has to be readable, the row
431
425
  EXPANDS to it — a truncation and its source, not a second copy.
432
426
 
@@ -530,9 +524,7 @@ non-interactive by construction. `TableRow` already composes `PressDoor`.
530
524
 
531
525
  **A trailing slot is not a PEER of the row's own affordance — space them apart.** A disclosure
532
526
  chevron belongs to the row's button; the slot beside it does not, and if the two are spaced like
533
- siblings the eye reads one cluster. Measured on a feed row: 10px from the label to the chevron
534
- and 12px from the chevron to the action menu, so the only thing saying they were different
535
- targets was a 4px break in the hover wash — invisible to a reader who never hovers. Put clearly
527
+ siblings the eye reads one cluster. Put clearly
536
528
  more space BETWEEN the groups than inside one (2:1 is enough) and the pair separates without a
537
529
  divider, a weight change or any new element. It is the ordinary grouping rule — one uniform gap
538
530
  makes every child a peer — applied to a row's right edge, where it is easy to miss because both
@@ -550,7 +542,7 @@ Actions stay **visible** — a hover-only action is invisible to keyboard and to
550
542
  exception is a DENSE tree/register where a persistent per-row action would clutter: reveal on
551
543
  hover **OR focus-within**, keeping the action in the DOM + tab order and gating only its opacity,
552
544
  so it's still keyboard-reachable. Do that locally — there is deliberately no shared hover-reveal
553
- primitive (one existed and made the keyboard-inaccessible anti-pattern easy; it was removed).
545
+ primitive.
554
546
 
555
547
  **A row is a button wrapped around DATA, so its text stays SELECTABLE and a drag is not a press.**
556
548
  The surface carries no blanket `userSelect: "none"` — that made a customer's own name and number
@@ -892,6 +884,15 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
892
884
  the row is filled. In a label-left grid, ONE FIELD PER ROW is not a failure to compact; it is
893
885
  the only shape that keeps the column a reader scans. Both attempts are recorded here so nobody
894
886
  re-runs them.
887
+ **A spread `DetailRow` gives its VALUE the squeeze, never its label.** The two share one flex
888
+ row, so whichever of them cannot shrink forces the other to. A flex child defaults to
889
+ `min-width: auto`, which floors the value at its longest word — and the label, having no floor
890
+ of its own, absorbs everything left: on a narrow register a two-word label collapsed to a 4px
891
+ column 200px tall, one character per line. The `minWidth: 0` on the value is what stops it, and
892
+ it is load-bearing rather than housekeeping. **The probe generalises past this component**:
893
+ wherever a fixed-ish label shares a row with a `flex: 1` value, measure the LABEL's width and
894
+ height across rows whose values differ in length — a label whose box changes shape from row to
895
+ row is the signature, and it is invisible at any width where every value happens to fit.
895
896
  The other caveat: that research studies COMPLETION forms, where the failure mode is a skipped
896
897
  required field, while a record page is read-mostly.
897
898
  A SUMMARY belongs to the RECORD, not to a section. The highlights-panel precedent (a strip of
@@ -1033,10 +1034,7 @@ When it IS a status, match weight to prominence — lightest → heaviest:
1033
1034
  a surface (a drawer/detail header). Opt IN explicitly and sparingly; one per view, never
1034
1035
  sprinkled through rows.
1035
1036
 
1036
- **`tonal` IS allowed on a register's ONE primary status column, and that is the rule this
1037
- paragraph used to get wrong.** It read "a register's dense rows read lighter, the row's primary
1038
- status is `dot`" while `badge.tsx` named "a register's primary Status column" as a legitimate
1039
- tonal use — two docs, opposite answers, so the choice fell to whoever read which. What "one per
1037
+ **`tonal` IS allowed on a register's ONE primary status column.** What "one per
1040
1038
  view" governs is the number of badge KINDS on a surface, not the number of rows: badging the
1041
1039
  stage AND the type AND the city is the clutter the rule exists to stop; one status column that
1042
1040
  happens to render 133 times is one badge.
@@ -86,11 +86,7 @@ whole point), and it commits on blur (Enter saves, Escape reverts) or via `contr
86
86
 
87
87
  **`numberOfLines` declares what ENTER means — wrapping does not.** A field left at one line holds a
88
88
  single-line value that may WRAP when it runs long (a title, a summary), and Enter finishes it.
89
- Declaring 2+ lines declares PROSE: Enter is a paragraph break and blur is what saves. The two used
90
- to be one flag, so `autoGrow` — a prop about fitting the value — silently turned Enter into a
91
- newline, and a one-line summary field that grew became a field you could only leave by clicking
92
- away. Nothing at the call site said so, which is what made it worth separating rather than
93
- documenting.
89
+ Declaring 2+ lines declares PROSE: Enter is a paragraph break and blur is what saves.
94
90
 
95
91
  **How "nothing moves" is achieved differs by editor, and the difference is a rule.** A field whose
96
92
  resting value is the RAW string (`InlineTextInput`) is ONE `<input>` in both states — it never
@@ -225,16 +221,12 @@ something belongs elsewhere.
225
221
 
226
222
  It is wrong wherever the length is the AUTHOR's choice, and it fails in the worst available way.
227
223
  The box is drawn at the budget and the value simply exceeds it: no ellipsis, no clamp, no
228
- scrollbar, nothing to scroll. Measured on a record note, 285 characters drew **76px of a 116px
229
- value** — two full lines gone with nothing on screen admitting it. A truncation mark would have
230
- been kinder; this is silent. So the test is not "is this field long" but **"who decides the
224
+ scrollbar, nothing to scroll — a truncation mark would have been kinder; this is silent. So the test is not "is this field long" but **"who decides the
231
225
  length"** — the field, or whoever is typing.
232
226
 
233
- **And it gets worse as the surface narrows**, which is the opposite of the intuition that a
234
- desktop check is the demanding one. A budget is a count of LINES and the wrap point moves: the
235
- same value measured 60px hidden at 1280 and **96px hidden at 375**, because the narrower column
236
- wrapped it into more lines against the same three-line box. A fixed reserve verified on a wide
237
- screen is not verified.
227
+ **And it gets worse as the surface narrows**: a budget is a count of LINES and the wrap point
228
+ moves, so the narrower column wraps the same value into more hidden lines. A fixed reserve
229
+ verified on a wide screen is not verified.
238
230
 
239
231
  `autoGrow` makes the budget a minimum and fits the value. It costs nothing the fixed reserve was
240
232
  protecting, and the reason is worth stating because it is easy to assume otherwise: an
@@ -490,8 +482,7 @@ closes the list as its add-affordance.
490
482
 
491
483
  **`connected` decides which shape you are building.** ON for one record walking positions — the
492
484
  line asserts sequence, which is what a desk handoff IS. OFF for N items ticked in any order, where
493
- a line claims an order the work does not have. It is the same compound either way; they were two
494
- families until the anatomy turned out to be identical.
485
+ a line claims an order the work does not have. It is the same compound either way.
495
486
 
496
487
  **A GROUP is a name and nothing else.** No control: a phase owns no completion of its own, so a
497
488
  ring there reports without responding — and one identical to the pressable rings beneath it
@@ -660,13 +651,6 @@ A record field whose value is documents. `<DetailRow label="Scan"><InlineFiles
660
651
  — a `FileRows` list (press a row to preview it full-screen, ⋯ to remove) with one CTA under it,
661
652
  sitting on the inline-control grid via `InlineSlot`.
662
653
 
663
- It exists because every other field type shipped BOTH halves — `Select`/`InlineSelect`,
664
- `TextInputField`/`InlineTextInput` — and files shipped only the section-scale one. So a record
665
- field holding documents got hand-rolled per app as `FileRows` + `pickFiles` + a `Button`, and
666
- each copy re-derived the same decisions. The one they missed is `InlineSlot`: raw children in a
667
- value column start at the cell edge and 20px tall, beside editors that are 40px with a 9px text
668
- inset, so the column acquires two left edges and two baselines and stops reading as a column.
669
-
670
654
  **Cardinality is a property of the FIELD, and it changes the verb.**
671
655
 
672
656
  | `multiple` | picker | CTA once something is attached | the host's `onAdd` must |
package/docs/templates.md CHANGED
@@ -235,15 +235,12 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
235
235
  ANCHORED to its bar button rather than a full-page `Modal` that would escape the drawer.
236
236
  **The discussion follows the same measure**: docked beside the record where there is room for
237
237
  its column, and a SECTION under General where there is not — which a drawer always is. It is
238
- never behind a control. It was briefly a toggle in the top-right of a narrow page and a
239
- body-SWAP inside a drawer; both made the reader know a control existed, press it, and give up
240
- the record to read a note, which is where an unread note goes to be missed.
238
+ never behind a control.
241
239
  - **Linked records** still push an editable screen for that record inside the drawer via the
242
240
  hosted `ScreenRouter` — while `canGoBack` the drawer header swaps to a back button + the
243
241
  pushed id and the ◀ ▶ sequencer hides; back pops with scroll preserved.
244
- - One thing this template no longer demonstrates, so look elsewhere: the drawer does NOT
245
- write back to its row (settling a ledger used to flip the register's paid state live — the
246
- drawer's money is the record's now). The communications log lives on the record surface as
242
+ - The drawer does NOT write back to its row the drawer's money is the record's. The
243
+ communications log lives on the record surface as
247
244
  [`tpl_record`](../examples/tpl_record.tsx) § Activity.
248
245
  - **The "Enter data" INTAKE FORK** (the New CTA) — AI FIRST, the form as fallback, one phased
249
246
  dialog. The whole register page is ALSO a `<FileDropTarget … paste>` (the whole-surface intake
@@ -338,15 +335,11 @@ billing, and quick-capture templates. Top → bottom:
338
335
  reader has after what it IS, ahead of its files, its route and its money, which are all
339
336
  reference. The desk handoff as the ordered positions it is: a `Checklist connected` where the
340
337
  desks are `ChecklistGroup`s (Sales → Operations → Accounting) and the milestones they produce
341
- are the rows. A GROUP carries no COMPLETION control (a phase owns none, and
342
- a ring that never responds invites the press it refuses) and no body (a condition hung off a
343
- heading leaves the reader working out which row it was about). The row that OWES the work
338
+ are the rows. The row that OWES the work
344
339
  carries all three: how long it has been owed (`meta`), its condition (`ChecklistNote`, e.g.
345
340
  past the due date — with an `action` jumping to the section that owns a missing field rather
346
341
  than editing it here) and the single act that leaves the desk (`ChecklistActions` → the handoff
347
- dialog). Rows tick ONE at a time and position is the HIGHEST stamp, so one click marks
348
- everything below it without writing dates nobody recorded; a rung it passes shows a BLANK date,
349
- which is the honest answer to "when" and stays one click from being filled. `meta` is prose
342
+ dialog). `meta` is prose
350
343
  the reader can't set, never the date that the stage's own field already shows. See § The
351
344
  handoff shape for the rules; for a record that genuinely needs a CHECKLIST, the task-list
352
345
  grammar is worked in `tpl_item_list`.
@@ -464,15 +457,12 @@ billing, and quick-capture templates. Top → bottom:
464
457
  **The GIST is what the feed is scanned by** — what came of the exchange, in the reader's own
465
458
  words. Building it the other way round is the natural mistake and it makes the feed unreadable:
466
459
  every entry renders as a pair of enum values ("Outbound Meeting"), the substance sits behind a
467
- chevron, and a column of near-identical labels defeats the scan the feed exists for. Measured on
468
- a real one, that shape spent 504px of chrome on 70px of content.
460
+ chevron, and a column of near-identical labels defeats the scan the feed exists for.
469
461
 
470
462
  **An entry is a COMMENT, not a `Timeline` row.** `Timeline` renders its label inside the row's
471
463
  press target, which is right for derived read-only text ("Stage changed to Won") and wrong for
472
464
  the one thing on an entry a person WROTE — text inside a button cannot be edited where it sits.
473
- Four repairs came out of working around that (the verbs moved off the row, the label learned to
474
- un-clamp, the body's second copy of the gist came out, and Edit ended up a scroll from the
475
- sentence it edits) and none were independent. So the gist is a RESIDENT field and the derived
465
+ So the gist is a RESIDENT field and the derived
476
466
  facts sit above it on a muted `xs` **byline** — which way it went, over what, when. Order does
477
467
  not decide the scan; weight does. The byline is small and grey, the prose is `sm` on the page's
478
468
  own ground, and the eye lands on the substance even though it reads second.
@@ -604,9 +594,7 @@ billing, and quick-capture templates. Top → bottom:
604
594
 
605
595
  **The blocks SHOW; they are not folded behind a disclosure.** They are what the entry IS, and a
606
596
  control that hides them makes the reader click to discover what the entry already says is there
607
- — on every entry, forever. The fold that used to sit here was defended as "each is tall enough to
608
- bury the next entry", which measurement did not support: an audio player is 54px and a file tile
609
- row is 96, so hiding one behind a 20px disclosure buys 34px and costs a click. A video is the
597
+ — on every entry, forever. A video is the
610
598
  honest counter-case at 358px and it still shows — it is the most informative thing on a recorded
611
599
  call, the feed folds its own tail, and the byline and gist stay at the top of every entry, so the
612
600
  scan survives a tall one.
@@ -639,12 +627,8 @@ billing, and quick-capture templates. Top → bottom:
639
627
  compare. Reveal it whole rather than into a scroll box; a scroller inside a scrolling drawer
640
628
  traps the wheel, and a reader who pressed it asked for the length.
641
629
 
642
- **The toggle is underlined text, and that is a deliberate exception.** Three controls were
643
- tried. `Button color="muted"` measures transparent, borderless and undecorated at rest the
644
- wash arrives only on hover, so the affordance is hover-only (banned in §"Commit & feedback surfaces") and it
645
- reads as a stray label between two content blocks. `Accordion` fixes the affordance and
646
- overcorrects: it is a LIST-ROW primitive, a full-width header with its own chevron, nested
647
- inside a row that already has one. What is left is a muted underlined `TextLink` in your own
630
+ **The toggle is underlined text, and that is a deliberate exception** (the in-prose disclosure,
631
+ composition.md §"Commit & feedback surfaces"): a muted underlined `TextLink` in your own
648
632
  `Pressable` — visible at rest, on the column's own left edge, and carrying no more weight than
649
633
  the words. The ink keeps underline honest: blue leaves the surface, muted stays on it.
650
634
 
@@ -661,9 +645,7 @@ billing, and quick-capture templates. Top → bottom:
661
645
  few with a toggle naming how many are behind it — a register pages, a record's feed folds.
662
646
 
663
647
  The toggle is a `TextDisclosure`, the same control as the transcript's, because it is the same
664
- act: it reveals more of what is already here. A `Button color="muted"` is the reach that feels
665
- right and is not — measured, it is transparent, borderless and undecorated at rest, so the
666
- affordance only arrives on hover and it reads as a stray line under the feed. It sits on the
648
+ act: it reveals more of what is already here. It sits on the
667
649
  SECTION's left edge, not the rows': the rows are inset by the disc rail, and at the fold's line
668
650
  the rail has ended, so nothing visible occupies that indent. Same for any other fold on the
669
651
  surface — a document set's "show the rest" is this control too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "43.3.0",
3
+ "version": "43.3.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -252,7 +252,16 @@ const styles = StyleSheet.create({
252
252
  // is the one that gives way, because it is the one that is long. Both stay
253
253
  // shrinkable on purpose: a long LABEL against a short value has to wrap too,
254
254
  // and pinning the label unshrinkable would just overflow the row the other way.
255
- flexLabel: { flexGrow: 1, flexShrink: 1, flexBasis: "auto" },
255
+ // Spread mode's label. It does NOT shrink, and that is the whole rule: both
256
+ // items in this row are floorless under React Native Web (which defaults flex
257
+ // children to `min-width: 0`, unlike the web's `auto`), so with shrink on both
258
+ // the flex algorithm splits the deficit and the LABEL loses — measured on a
259
+ // narrow register, a two-word column name collapsed to a 4px box 200px tall,
260
+ // one character per line, while its neighbour with a short value measured a
261
+ // clean 251x20. A label the reader cannot finish makes the row meaningless,
262
+ // and the value beside it wraps for free (`spreadValue` already carries the
263
+ // `minWidth: 0` that lets it), so the value is the one that gives way.
264
+ flexLabel: { flexGrow: 1, flexShrink: 0, flexBasis: "auto" },
256
265
  // `center` is what makes a WRAPPED label align sanely, and it does both jobs
257
266
  // with no measurement: a one-line label is shorter than the control band, so
258
267
  // it centers on the control line (the alignment law); a wrapped label is as