@lotics/ui 41.1.0 → 41.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -15,10 +15,10 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
- | [docs/data_entry.md](./docs/data_entry.md) | 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, 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; 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
- | [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), 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
- | [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). |
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 |
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
+ | [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. |
22
22
 
23
23
  ## Iron rules
24
24
 
package/MIGRATION.md CHANGED
@@ -4,6 +4,89 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 41.3.0 — `Timeline` rows, embedded markdown, and a disclosure that is not a link
8
+
9
+ Nothing here removes an API or breaks a type. Two of the changes alter what your rows render on
10
+ sight; the rest is additive.
11
+
12
+ ### `Timeline` bounds its own text, and `right` leaves the press target
13
+
14
+ **The label clamps to two lines** (the description too). It was unbounded, which is invisible
15
+ until a caller puts user-authored prose in it: measured on a real interaction log, one
16
+ 700-character summary drew a **180px label** — nine lines for one entry — and the disc, which
17
+ centred on the label row, ended up 80px below the first line it was meant to sit beside.
18
+
19
+ If your label can run long, put the full text in `details` as well. The row is then a
20
+ truncation and the detail its source, which is the drill-down every expandable register row
21
+ already makes:
22
+
23
+ ```diff
24
+ label: entry.summary,
25
+ + details: <Text size="sm">{entry.summary}</Text>,
26
+ ```
27
+
28
+ **`right` renders beside the press target, not inside it.** On a row with `details` the whole
29
+ row was a `<button>`, so an interactive `right` — a row-action menu, an `IconButton` — nested a
30
+ button inside a button: invalid HTML, and one click reaching two handlers. It is now a sibling,
31
+ and the chevron closes the pressable region, so the order reads `label … ⌄ right`. A
32
+ non-interactive `right` (a duration, a `Badge`) moves 14px right and is otherwise unaffected.
33
+ The hover wash now stops where the press target does, which is the point: a wash that ran under
34
+ a row action made that action invisible exactly when the pointer reached it.
35
+
36
+ **Everything in the band aligns to the label's FIRST LINE, not to the label block** — the disc,
37
+ the chevron and whatever you pass as `right`. Centring and first-line alignment are the same
38
+ answer until a label wraps and then they are 10px apart, so making two-line labels ordinary made
39
+ the old behaviour wrong. **A single-line row is pixel-identical** (measured: 40px band, zero
40
+ drift on all three ornaments); only wrapped rows change, and they change to correct.
41
+
42
+ **`TimelineItem` takes `placeholder`.** It marks a label the SURFACE supplied rather than one the
43
+ record holds, and draws it in the ink an unfilled field uses. A feed fed by automations and
44
+ extractions routinely receives an entry with no prose in it, and `label: string` forces the
45
+ caller to invent some — which then renders in body ink, so "nobody has written this up" and
46
+ "this is what happened" look identical.
47
+
48
+ ```diff
49
+ - label: entry.gist || `${entry.direction} ${entry.kind}`,
50
+ + label: entry.gist || "Not written up yet",
51
+ + placeholder: !entry.gist,
52
+ ```
53
+
54
+ ### `Markdown` takes `variant`
55
+
56
+ `"document"` (the default, and what every current caller keeps) is markdown that IS the surface
57
+ — its `h1` is the page's `h1`. `"embedded"` is markdown that is a VALUE on a page someone else
58
+ designed, and collapses every heading to the body's own size.
59
+
60
+ ```diff
61
+ - <Markdown>{record.aiSummary}</Markdown>
62
+ + <Markdown variant="embedded">{record.aiSummary}</Markdown>
63
+ ```
64
+
65
+ Reach for it whenever the text was not written by whoever built the screen — now the common
66
+ case, because the writer is routinely a model. Measured on a real feed, a machine summary at
67
+ document scale drew 18px/600 headings, one rung under the section heading containing it.
68
+
69
+ `MarkdownProps` is reached from `@lotics/ui/markdown_types`, the same way `MediaPlayerProps` is;
70
+ the component entry points do not re-export it.
71
+
72
+ ### `TextDisclosure`, and what underlined text is allowed to mean
73
+
74
+ New component: muted underlined text that REVEALS adjacent content in place — a transcript under
75
+ its player, a long value under its summary. It exists because the rule used to be "underlined
76
+ text is the NAVIGATION affordance and nothing else", and following that produced worse screens:
77
+ the quiet control it left you (`Button color="muted"`) measures transparent, borderless and
78
+ undecorated at rest, so its affordance arrives only on hover.
79
+
80
+ The rule is now scoped rather than absolute, and the INK carries the difference:
81
+
82
+ | | |
83
+ |---|---|
84
+ | blue + `role="link"` (`Link`) | leaves this surface |
85
+ | muted underline (`TextDisclosure`) | reveals more of it, in place |
86
+
87
+ Anything that MUTATES still carries a control surface — `Button` in chrome, `InlineButton` on a
88
+ field. Nothing to migrate: existing `Link`/`TextLink` usage is unaffected.
89
+
7
90
  ## 41.1.0 — `FileRows` no longer captions every row with the file type
8
91
 
9
92
  `meta` defaulted to the file-type label, so a row read `contract.pdf` with `PDF` under it. The
@@ -503,6 +503,42 @@ locator after the label ("page 2", a record code). Kinds carry recognizable glyp
503
503
  make each chip pressable (hover wash + an open glyph); the host does the navigation. An empty
504
504
  list renders nothing.
505
505
 
506
+ ## After the run — a record that FILLS UP from more than one writer
507
+
508
+ `Sources` and `Confidence` above belong to a live answer, where the run is on screen and the
509
+ reader is deciding about it. The harder surface is the one they open a week later: a stored
510
+ record whose fields were written by a person, by a chat agent, by an extraction off an upload,
511
+ and by an automation that fired when a file landed — and which carries no memory of which was
512
+ which. Increasingly that is the normal shape, because the app's own form is the LEAST used way
513
+ in. Three rules, each of which a screen built for a single writer gets wrong for free.
514
+
515
+ **An unwritten value must not render like a written one.** A row assembled by an automation
516
+ arrives with its enums set and its prose empty, and the natural repair — composing a label out
517
+ of the enums that ARE set — produces a real-looking sentence in body ink. "Nobody has written
518
+ this up" and "this is what happened" then look identical, which is the one distinction the
519
+ reader most needs. Say the value is missing, in the ink an unfilled field already uses
520
+ (`Timeline`'s `placeholder`; the same idea wherever a component takes a `label: string` and
521
+ therefore cannot express absence — that type is the trap, so give it a way to say so).
522
+
523
+ **A machine's prose and a person's must not share a treatment.** They are different claims and
524
+ they earn different trust, so rendering them alike teaches the reader to skim both at one
525
+ speed. Carry the difference with SHAPE, not weight: name the author and the evidence on the
526
+ line above ("Written by AI from the recording" — the source alone is not authorship), and drop
527
+ the machine's text into a recessed `Inset`, which says it was not written on this page. Leave
528
+ the person's conclusion on the page's own ground; that contrast IS the signal.
529
+
530
+ **Model prose arrives with its own type ladder — take it away at the door.** A model asked for
531
+ a summary emits `##` headings and heavy `**bold**` knowing nothing of what surrounds it, and
532
+ document-scale markdown hands those an `h1` equal to your section heading: measured on a real
533
+ feed, machine content rendered 18px/600 headings over 44 medium-weight runs, so the
534
+ least-trustworthy text on the screen was also the loudest. Render every embedded value with
535
+ `<Markdown variant="embedded">`.
536
+
537
+ And one that is not about treatment at all: **check that the read path projects every field the
538
+ write paths set.** A field written by an intake and never projected by the query behind the
539
+ screen is invisible in a way no visual pass can catch — the surface renders perfectly, and the
540
+ evidence is discarded at render. Diff the two lists whenever a surface has more than one writer.
541
+
506
542
  ## Confidence — `Confidence`
507
543
 
508
544
  Confidence IS a callout (`@lotics/ui/confidence`) — the ONLY form it takes: the three-tick
package/docs/catalog.md CHANGED
@@ -38,16 +38,18 @@ emphasis/risk), `IconButton` (icon-ONLY — the circular affordance; an icon-onl
38
38
  type error, so reach here. `size` `lg` 40px / `md` 28px / `sm` 24px — md/sm keep a 40px touch
39
39
  target via hitSlop — the SAME `color` palette as Button plus `none`/`white`, plus `loading` and `elevated` (white
40
40
  fill + border + shadow, for a button sitting ON imagery — a tile's remove ✕, an overlay's
41
- retry); needs `accessibilityLabel`/`tooltip`), `TextLink` (underlined text that's OPTIONALLY
42
- an action (`onPress`) or a link (`href` — a real web anchor) — or, with neither, plain
43
- underlined text you drop in your own pressable like a table cell; colour via `color`; the
44
- go-to for Clear / Select all / inline links), `Chip` (dismissible facet chip), `CopyButton`
41
+ retry); needs `accessibilityLabel`/`tooltip`), `TextLink` (underlined text that NAVIGATES with
42
+ an `href` — a real web anchor — or, without one, a plain underlined marker you drop in your own
43
+ pressable like a table cell; colour via `color`. It does not act: it has no `onPress`),
44
+ `TextDisclosure` (the reveal counterpart muted underlined text that unfolds adjacent content
45
+ in place), `Chip` (dismissible facet chip), `CopyButton`
45
46
  (put a value on the clipboard — rides the value it copies, confirms only a real write).
46
47
  A button is
47
48
  never a raw `Pressable`. For a link OUT (a URL / record / document) use `Link` — fixed
48
49
  underline+blue + `role="link"`, the destination signal (`onPress` only — the consumer wires
49
50
  the opener, e.g. the app SDK's `openExternal`); `TextLink` is the neutral,
50
- colour-configurable underlined link/action.
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.
51
53
 
52
54
  ### Pick from a list
53
55
 
@@ -128,7 +130,9 @@ Above 1 the resting box RESERVES that many lines — the full editor height, not
128
130
  particular value needs — so clicking in moves neither the field nor anything below it; Enter
129
131
  then inserts a newline and the value commits on blur. Both halves matter: a field that only
130
132
  goes multiline once focused hides the text from the reader who never clicks, and one that
131
- reserves less than it opens to shoves the rest of the form down the page on every click),
133
+ reserves less than it opens to shoves the rest of the form down the page on every click.
134
+ **`autoGrow`** makes that budget a MINIMUM and fits the value instead — see
135
+ [data_entry.md §Open prose grows](./data_entry.md)),
132
136
  `InlineNumberInput`, `InlineSelect` (single or
133
137
  `multi`), `InlineMemberSelect`, `InlineDatePicker`, `InlineTimePicker`; a
134
138
  READ-ONLY field in that same column uses `InlineStatic` (matches the editor box exactly, no
@@ -403,7 +407,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
403
407
  numbers), `weight`, `color`, alignment, tabular numerals.
404
408
  - **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
405
409
  `AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
406
- native; takes a markdown `children` string.
410
+ native; takes a markdown `children` string. **`variant`** decides whose type
411
+ ladder the headings belong to: `"document"` (default) where the markdown IS the
412
+ surface, `"embedded"` where it is a VALUE on a page someone else designed — a
413
+ field on a record, a summary in a feed row. Reach for `"embedded"` whenever the
414
+ text was not written by whoever built the screen, which is now the common case
415
+ because the writer is routinely a model; at document scale an `h1` it emits is
416
+ the same rung as your `SectionHeadingTitle`.
417
+ - **`markdown_types`** — `MarkdownProps`; types only.
407
418
  - **`markdown.css`** — import once for the web markdown styling.
408
419
  - **`format_date`** — `formatDate` / `parseDate` / `toISODate` + `DateFormatStyle`.
409
420
  - **`format_money`** — `formatMoney` / `formatCompactNumber`.
@@ -488,7 +499,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
488
499
  image-less entities — groups, organizations. A person → `Avatar`/`MemberChip`.
489
500
  - **`wave_avatar`** — `WaveAvatar`: decorative animated waveform avatar (voice/audit
490
501
  history); animates on web, renders a static fallback on native.
491
- - **`member_chip`** — `MemberChip`: avatar + name; the universal person render.
502
+ - **`member_chip`** — `MemberChip`: avatar + name; the universal person render. `size` scales
503
+ BOTH — `sm` gives a 24px avatar and a 12px name, every larger rung keeps the name at body
504
+ size. Reach for `sm` when the chip sits inside a sentence or a dense row, where a body-size
505
+ name would collide with the 12px text around it.
492
506
 
493
507
  ### Layout & surfaces
494
508
 
@@ -632,8 +646,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
632
646
  with no `href` it stays NEUTRAL: plain underlined text to wrap in your own pressable,
633
647
  or a MARKER that a value leads somewhere (a record reference whose press opens a peek,
634
648
  not a trip). `color` overrides either way; inherits every `Text` prop. It does NOT
635
- act: an act carries a control surface (`Button`/`InlineButton`), and underlined text
636
- is the NAVIGATION affordance only.
649
+ act: anything that MUTATES carries a control surface (`Button`/`InlineButton`).
650
+ - **`text_disclosure`** — `TextDisclosure`: the counterpart to `TextLink` — muted
651
+ underlined text that REVEALS adjacent content in place (a transcript under its
652
+ player, a long value under its summary). Underlined text either goes somewhere or
653
+ reveals something, and the INK says which: blue + `role="link"` leaves the surface,
654
+ muted stays on it. Takes `expanded`/`onToggle` and a `label` NOUN — it supplies the
655
+ verb, so two call sites cannot end up with "Show" and "View". Reach for it instead
656
+ of the two things that look right and are not: `Button color="muted"` measures
657
+ transparent, borderless and undecorated at rest, so its affordance arrives only on
658
+ hover (invisible to keyboard and touch, and against a text column it reads as a
659
+ label); `Accordion` is a list-ROW disclosure, heavy furniture for a verb nested
660
+ inside a row that already has a chevron. Announces `role="button"` + `aria-expanded`
661
+ — reaching for `Link` instead tells a screen-reader user they are leaving a page
662
+ that is about to unfold under them.
637
663
  - **`chip`** — `Chip`: the generic pill — pressable when `onPress` (announces as a button;
638
664
  pass `accessibilityLabel` when children aren't self-describing text) + an
639
665
  absolutely-positioned dismiss ✕ sibling when `onDismiss` (its name = `dismissTooltip` ??
@@ -1395,7 +1421,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1395
1421
  `pxPerDay`) + `GanttTask`/`GanttLabels` types. **Limitation:** labels are not
1396
1422
  provider-wired — pass `labels` (`DEFAULT_GANTT_LABELS` is English).
1397
1423
  - **`timeline`** — `Timeline`: a heterogeneous event LOG — per-row icon + expandable
1398
- details; models the past, NOT progress.
1424
+ details; models the past, NOT progress. Three things it decides for you, because a row
1425
+ cannot be trusted to a caller's data: the **label clamps to two lines** (and the
1426
+ description likewise) — a row is a scan target, and an unclamped one lets a single
1427
+ user-authored note set the section's height, so put the full text in `details` too;
1428
+ **`placeholder`** marks a label the SURFACE supplied rather than one the record
1429
+ holds, drawing it in the unfilled-field ink — reach for it on any feed that fills
1430
+ from more than one direction, where an entry routinely arrives with no words in it;
1431
+ **the disc, the chevron and `right` all sit on the label's FIRST LINE**, so a wrapped
1432
+ row lines up with an unwrapped one (the worked reference for the rule in
1433
+ composition.md §registers); and **`right` renders beside the press target, not inside
1434
+ it**, so a control there is valid HTML and keeps its own click — though on a row that
1435
+ EXPANDS, verbs belong in the BODY and `right` is better kept for a fact (a duration, a
1436
+ badge). A control sharing the label row with the press target is what produced the nesting,
1437
+ the first-line box and the chevron-pairing problems in the first place.
1399
1438
  - **`checklist`** — `Checklist` + `ChecklistGroup` + `ChecklistItem` + `ChecklistNote` /
1400
1439
  `ChecklistActions` / `ChecklistField`: THE run of tickable rows, at any
1401
1440
  altitude. It replaced a `task` family and a `pipeline` family that had converged on one
@@ -417,6 +417,31 @@ truncation, so you are buying a baseline with clipped text. Worth it for an iden
417
417
  contact; think harder for a cell whose job is prose, where the honest answer may be to move that
418
418
  content off the register entirely.
419
419
 
420
+ **This is not a table rule — it is a rule about furniture centred on text.** Anywhere a fixed
421
+ ornament sits beside a text block and centres on it — a `Timeline`'s disc, a marker, an avatar,
422
+ a rail glyph — the ornament's position is a function of the text's height, so an unbounded text
423
+ block moves it. A feed is where this actually bites, because a feed is the one row primitive
424
+ whose text is written by the USER: measured on a real interaction log, one 700-character summary
425
+ drew a 180px label and put the disc 80px below the line it names. Bound the text; the row keeps
426
+ its beat and the ornament keeps its line. Where the full value still has to be readable, the row
427
+ EXPANDS to it — a truncation and its source, not a second copy.
428
+
429
+ **Bounding the text is half the fix; the other half is aligning to the LINE, not the block.** A
430
+ clamp turns an 80px drift into a 10px one — it does not remove it, because a two-line label
431
+ still centres its ornaments half a line low while the one-line rows beside it do not. So pin
432
+ every ornament in the band to the first line's centre, from ONE constant the row derives (see
433
+ `FIRST_LINE_CENTRE` in `timeline.tsx`, the reference): the mark, the affordance, and whatever a
434
+ caller passes as trailing content all read it, and a wrapped row lines up with an unwrapped one.
435
+ Two consequences worth stating, because both look like unrelated tidying: a height FLOOR above
436
+ the content is what created the slack being distributed, so deriving the touch target from
437
+ `padding + line` instead removes the drift and the second source of truth in one move; and an
438
+ ornament OUTSIDE the row's padding needs its own box, since it does not inherit that offset.
439
+
440
+ The way it gets in is a SLOT. A component that takes a caller's string or node cannot assume
441
+ anything about it, and every fixture the component was built against is tidy, so the defect is
442
+ invisible until it ships. Bound the slot in the component — not in a prop the next caller can
443
+ re-open.
444
+
420
445
  Two corollaries, both counter-intuitive enough to state:
421
446
 
422
447
  - **Shrinking a NEIGHBOURING cell does not fix it.** The spread is a function of the varying
@@ -482,6 +507,24 @@ over it). `PressableHighlight` is the wrong base for such a row — it IS a butt
482
507
  children, so any nested control lands inside it; it stays for surfaces whose content is
483
508
  non-interactive by construction. `TableRow` already composes `PressDoor`.
484
509
 
510
+ **A trailing slot is not a PEER of the row's own affordance — space them apart.** A disclosure
511
+ chevron belongs to the row's button; the slot beside it does not, and if the two are spaced like
512
+ siblings the eye reads one cluster. Measured on a feed row: 10px from the label to the chevron
513
+ and 12px from the chevron to the action menu, so the only thing saying they were different
514
+ targets was a 4px break in the hover wash — invisible to a reader who never hovers. Put clearly
515
+ more space BETWEEN the groups than inside one (2:1 is enough) and the pair separates without a
516
+ divider, a weight change or any new element. It is the ordinary grouping rule — one uniform gap
517
+ makes every child a peer — applied to a row's right edge, where it is easy to miss because both
518
+ items are small grey glyphs sitting on the same line.
519
+
520
+ **"Non-interactive by construction" excludes any SLOT.** A trailing `right` / `actions` / `meta`
521
+ prop takes whatever the caller passes, so a row that wraps one in its own press surface is a
522
+ nested button waiting for the first caller to pass an `ActionMenu` — which is the natural thing
523
+ to pass. The browser says so out loud (`<button> cannot contain a nested <button>`) and nothing
524
+ else does: it renders, it measures, it typechecks, and the click quietly runs two handlers.
525
+ Render the slot as a SIBLING of the press target (`Timeline` is the reference), which also stops
526
+ the row's hover wash sliding under the action at the moment the pointer reaches it.
527
+
485
528
  Actions stay **visible** — a hover-only action is invisible to keyboard and touch users. The ONLY
486
529
  exception is a DENSE tree/register where a persistent per-row action would clutter: reveal on
487
530
  hover **OR focus-within**, keeping the action in the DOM + tab order and gating only its opacity,
@@ -661,13 +704,34 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
661
704
  already puts it. Chrome has TWO rungs: `Button` (a 40px control with a surface) →
662
705
  `InlineButton` (28px, filled, on a field's own surface).
663
706
 
664
- **AN ACT CARRIES A CONTROL; ONLY NAVIGATION IS UNDERLINED TEXT.** `Link`/`TextLink` are the
665
- underlined pair and they GO somewhere; everything that acts is a `Button` or an `InlineButton`.
666
- A surface-less ACT was tried as a third rung and removed: it made underline mean two things at
667
- once, separable only by ink, so the affordance stopped answering the one question a reader has —
668
- does this take me away, or does it do something here. It also competed with the `Button` colour
669
- ladder for the same job, since `muted` already IS the quiet rung. Pick by what the press DOES,
670
- and let the surface say which kind it is. Never a `muted` text action: it mutes
707
+ **AN ACT CARRIES A CONTROL; UNDERLINED TEXT REVEALS.** `Link`/`TextLink` are the underlined
708
+ pair; everything that MUTATES is a `Button` or an `InlineButton`. A surface-less act was tried
709
+ as a general third rung and removed: it made underline mean anything at all, so the affordance
710
+ stopped answering the one question a reader has — does this take me away, or does it do
711
+ something here. It also competed with the `Button` colour ladder, since `muted` already IS the
712
+ quiet rung.
713
+
714
+ **The ONE exception, and it is narrow: an IN-PROSE DISCLOSURE.** A show/hide for adjacent
715
+ content — a transcript under its player, a long value under its summary — has nowhere else to
716
+ go. It cannot move to chrome, because it belongs to the thing above it; and the two controls
717
+ that look right in chrome are both wrong against a text column. `Button color="muted"` measures
718
+ transparent, borderless and undecorated AT REST, with its wash arriving only on hover: that is
719
+ a hover-only affordance, banned above, and it reads as a stray label between two content
720
+ blocks. `Accordion` fixes the affordance and overcorrects — it is a LIST-ROW primitive, a
721
+ full-width header with its own chevron, and nesting one inside a row that already has a chevron
722
+ is list furniture borrowed for a one-off verb.
723
+
724
+ So underline is permitted here, and the INK keeps it unambiguous:
725
+
726
+ | | |
727
+ |---|---|
728
+ | blue + `role="link"` (`Link`) | leaves this surface |
729
+ | muted underline in a caller's `Pressable` (`TextLink`, no `href`) | reveals more of it, in place |
730
+
731
+ Both say "show me more"; only one takes you away, which is the question the affordance existed
732
+ to answer. It stays scoped to REVEAL: a press that saves, sends, deletes or opens an overlay
733
+ carries a control surface, always. Pick by what the press DOES,
734
+ and let the surface say which kind it is. Never a `muted` text action that MUTATES: it mutes
671
735
  the one thing carrying the act. No
672
736
  "success"/green button. Decision UIs put positive/negative color on the STATUS (dot) and verdict
673
737
  (colored `Text`), not the buttons.
@@ -903,10 +967,11 @@ selection washes).
903
967
  (rose vs orange) stays on `Badge`s.
904
968
 
905
969
  Links use `Link`/`TextLink` (blue-600), never `solid("blue")` — and blue-600 means NAVIGATION
906
- specifically, not "interactive" generally. Underlined text is the NAVIGATION affordance and nothing
907
- else (see the button ladder above): an act carries a control surface. `TextLink` takes the blue only
908
- when it has an `href`; as a passive marker on a value it stays neutral, because a press that opens a
909
- PEEK is not a trip.
970
+ specifically, not "interactive" generally. Underlined text is for going somewhere or REVEALING
971
+ something, never for mutating (see the button ladder above, which scopes the one in-prose
972
+ disclosure exception). `TextLink` takes the blue only when it has an `href`; as a passive marker on
973
+ a value, or as a muted in-place disclosure, it stays neutral — because a press that opens a PEEK,
974
+ or unfolds a transcript, is not a trip.
910
975
 
911
976
  ## `Badge` is for STATUS only — everything else is text
912
977
 
@@ -932,6 +997,19 @@ tonal. A `Badge` is never a metric value.
932
997
 
933
998
  ## Typography
934
999
 
1000
+ **A `size` rung scales the WHOLE component, not just its figure.** A composite with a graphic and
1001
+ a label — a chip, a badge with a caption, a tile — has one `size` prop and the reader expects it
1002
+ to mean one thing. Scaling only the graphic makes the small rung half-small, and the defect shows
1003
+ up nowhere near the component: it appears when the chip is dropped INSIDE a sentence, where a
1004
+ 12px label runs straight into its 14px name and the two read as different type systems meeting
1005
+ mid-phrase. Their line boxes differ too (16 against 20), so no `alignItems: "center"` can put
1006
+ them on a shared baseline — the symptom is usually reported as "the alignment is off".
1007
+
1008
+ The check is cheap and worth running whenever a component takes a size: render the small rung and
1009
+ measure the TEXT inside it, not the box. If the text did not move, the prop is only half wired.
1010
+
1011
+
1012
+
935
1013
  Only the `Text` primitive (`size` / `weight` / `color` / `transform` / `tabular` / `level` for
936
1014
  semantic heading rank). Uppercase tracking is built into `transform="uppercase"` — never hand-set
937
1015
  `letterSpacing`.
@@ -203,6 +203,41 @@ cell (noise on a dense board, and a pressable nested in the trigger is invalid D
203
203
  (the app workflow must ACCEPT null on that input — a `select`/`date`/`member` field clears on
204
204
  null). `InlineSelect multi` needs no `onClear` — an empty set is already a valid `onSave`.
205
205
 
206
+ ### Open prose GROWS; a value with a known shape keeps its reserve
207
+
208
+ `numberOfLines` reserves a fixed budget so the box is the same height resting and editing. That
209
+ is right for a value whose shape the FIELD decides — a two-line address, a payment term, a
210
+ clause — where the reserve keeps the page still and an overlong value is itself a signal that
211
+ something belongs elsewhere.
212
+
213
+ It is wrong wherever the length is the AUTHOR's choice, and it fails in the worst available way.
214
+ The box is drawn at the budget and the value simply exceeds it: no ellipsis, no clamp, no
215
+ scrollbar, nothing to scroll. Measured on a record note, 285 characters drew **76px of a 116px
216
+ value** — two full lines gone with nothing on screen admitting it. A truncation mark would have
217
+ been kinder; this is silent. So the test is not "is this field long" but **"who decides the
218
+ length"** — the field, or whoever is typing.
219
+
220
+ **And it gets worse as the surface narrows**, which is the opposite of the intuition that a
221
+ desktop check is the demanding one. A budget is a count of LINES and the wrap point moves: the
222
+ same value measured 60px hidden at 1280 and **96px hidden at 375**, because the narrower column
223
+ wrapped it into more lines against the same three-line box. A fixed reserve verified on a wide
224
+ screen is not verified.
225
+
226
+ `autoGrow` makes the budget a minimum and fits the value. It costs nothing the fixed reserve was
227
+ protecting, and the reason is worth stating because it is easy to assume otherwise: an
228
+ `InlineTextInput` is ONE input in both states, never a resting view swapped for an editor, so a
229
+ grown box is the same height focused as at rest — measured identical to the pixel, box and
230
+ origin. The reserve was guarding against a jump the one-element design had already made
231
+ impossible. Growing on a control that DOES swap (number, date, time — their resting display is
232
+ formatted) would reintroduce it, which is why the prop lives here and not on those.
233
+
234
+ Two places to keep the reserve anyway: a value that can be **arbitrarily long** (growth has no
235
+ ceiling — 406 characters is already 238px, so a field that might hold a document wants the budget,
236
+ and overflowing it is the signal the value belongs elsewhere), and a **grid cell** (`variant="bare"`
237
+ in a `DataGrid`), where a cell varying with its data breaks the row rhythm the grid promises. Grow
238
+ on a record surface; reserve in a table. Worked both ways in `dev/pages/inline_edit.tsx`
239
+ § Multiline — the same value in a reserved field and a grown one, side by side.
240
+
206
241
  ### The row stack — `DetailTable` + `DetailRow`
207
242
 
208
243
  A STACK of rows lives in a `DetailTable` (label, value laid out like a TABLE: `labelWidth` /
@@ -291,6 +326,34 @@ row that wants it. See the "General" section of `tpl_record`, which also reads t
291
326
  full record surface. Not every field is a same-height swap — a tag field, a status, or an
292
327
  attachment grid edit in place too (below).
293
328
 
329
+ ## When the editor is a MODE instead of a resident field
330
+
331
+ The section above is the default and holds almost everywhere: the value IS the editor, nothing
332
+ moves, and there is no verb to find. It has exactly one exception, and it is decided by a
333
+ question with a factual answer — **does this value already appear somewhere else on the same
334
+ surface?**
335
+
336
+ On a FEED, it does. A row's identity line is the value, and the body opens under it, so a
337
+ resident editor renders the same sentence twice, stacked and identical. Quieting the frame does
338
+ not help: a sentence appearing twice reads as a mistake at any weight. So the editor becomes a
339
+ mode, reached by an **Edit** verb in the row's trailing slot beside its other actions.
340
+
341
+ **Except when the value is EMPTY, and that half matters more.** There is nothing to duplicate,
342
+ and a surface fed by automations and extractions receives entries with no words in them
343
+ routinely — a recording lands, the row says it has not been written up, and the empty field is
344
+ the invitation to write it. Withhold it there and the feed can RECEIVE an entry it gives the
345
+ reader no way to finish. **An empty value shows its field; a filled one waits to be asked.**
346
+
347
+ Two things follow that are easy to get wrong in the other direction:
348
+
349
+ - **Name the verb for the words, not the row.** "Edit", not "Edit entry" — on a feed the entry
350
+ is a record of something that happened, and only the prose a PERSON wrote is editable at all.
351
+ Derived values (a header's participants), verbatim ones (a transcript), artifacts (media,
352
+ files) and a model's output (a summary — you re-run it, you do not hand-edit it) are all
353
+ read-only, and a verb naming the whole entry claims otherwise.
354
+ - **One row in edit mode at a time.** Two half-finished sentences on one feed leave the reader
355
+ unable to say which one the next keystroke lands in.
356
+
294
357
  ## Fieldset form — fields lay out on a RESPONSIVE two-column grid
295
358
 
296
359
  Never hard-code columns, never 3-up. A fieldset is `flexDirection:row, flexWrap:wrap,
package/docs/templates.md CHANGED
@@ -241,11 +241,10 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
241
241
  - **Linked records** still push an editable screen for that record inside the drawer via the
242
242
  hosted `ScreenRouter` — while `canGoBack` the drawer header swaps to a back button + the
243
243
  pushed id and the ◀ ▶ sequencer hides; back pops with scroll preserved.
244
- - Two things this template no longer demonstrates, so look elsewhere: the drawer does NOT
244
+ - One thing this template no longer demonstrates, so look elsewhere: the drawer does NOT
245
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), and the CRM call-log shape (outcome pills over a
247
- multiline note, an outcome-typed history `Timeline`) went with the Activity section, which
248
- the record surface has no counterpart for.
246
+ drawer's money is the record's now). The communications log lives on the record surface as
247
+ [`tpl_record`](../examples/tpl_record.tsx) § Activity.
249
248
  - **The "Enter data" INTAKE FORK** (the New CTA) — AI FIRST, the form as fallback, one phased
250
249
  dialog. The whole register page is ALSO a `<FileDropTarget … paste>` (the whole-surface intake
251
250
  standard): a file dropped or Ctrl/Cmd+V pasted ANYWHERE on the register opens this same dialog
@@ -457,11 +456,123 @@ billing, and quick-capture templates. Top → bottom:
457
456
  a label-left capture form (auto-stamped date/receipt-no, record-prefilled fields, the
458
457
  right input each) whose CTA and produced `FileRow` ride an empty-label `DetailRow` — the
459
458
  form-action alignment law (data_entry.md), worked.
460
- - **No Activity or Handoff section.** Progress owns both: it says where the record SITS and
461
- carries the act that moves it, so a second section repeating the handoff and a `Timeline` of
462
- every change beside it were two more places to look for one answer. What that costs is
463
- explicit: the sibling record a handoff creates has no home on this surface, and there is no
464
- audit trail. Both belong on the Progress stage that produced them if an app needs them back.
459
+ - **Activity what was SAID with the other party**, newest first, directly under Progress: the
460
+ two answer a reader's first two questions in order, where the record stands and what passed
461
+ between us to get it there. It is NOT an audit trail and NOT the handoff see the next bullet
462
+ for the line, which is what keeps this section from becoming the change log Progress refuses.
463
+
464
+ **The row's LABEL is the GIST** — what came of the exchange, in the reader's own words. The
465
+ medium and the direction are metadata on the muted supporting line beneath it. Building it the
466
+ other way round is the natural mistake and it makes the feed unreadable: every row renders as a
467
+ pair of enum values ("Outbound Meeting"), the substance sits behind a chevron, and a column of
468
+ near-identical labels defeats the scan the feed exists for. Measured on a real one, that shape
469
+ spent 504px of chrome on 70px of content.
470
+
471
+ **ONE anatomy, a body that VARIES.** An entry can be a call, a recorded demo, an email, a
472
+ message or a note typed between meetings — and those differ in what they CARRY, not in what
473
+ they are: all of them answer what-came-of-it, which way, over what, and when. So the four
474
+ invariants are the row, and the body is a SET OF BLOCKS rather than a shape per kind. A shape
475
+ per kind writes the same block five times and lets the copies drift, and the sixth kind — the
476
+ one nobody has thought of — needs a sixth. **Every block is optional and any combination is
477
+ legal**, because what an entry carries is independent of what it is: a call may arrive as a
478
+ recording alone, gain a transcript minutes later and a summary after that.
479
+
480
+ The blocks, in the order they render (`ActivityBody` in `tpl_record` is the worked one):
481
+
482
+ | Block | What it is | How it renders |
483
+ |---|---|---|
484
+ | Participants | ONLY what cannot be derived — a third party who joined, an email's From/To. The record is already the counterparty and the row already says who logged it, so naming the obvious attendees is padding | muted line |
485
+ | Subject | the one thing an email has that nothing else does | `sm` medium |
486
+ | Media | the exchange itself, recorded | `MediaPlayer` — **give it a box**, it fills its parent and collapses to nothing without one |
487
+ | Transcript | VERBATIM and long; the source a summary was made from | a muted underlined `TextLink` in your own `Pressable`, revealing PLAIN text — see the disclosure exception in composition.md §"Commit & feedback surfaces" |
488
+ | Prose | what a PERSON wrote — a note, a counterparty's email | `Markdown variant="embedded"` on the page's own ground |
489
+ | AI prose | what a MODEL wrote | ONE short label INSIDE the `Inset` ("AI summary") — floating above it, a 12px muted fragment over a tinted box reads as an orphan. Name the evidence only where it is NOT on screen: over a player the reader can see, "from the recording" is four words restating a video. Let the label replace the block's own `## Heading` rather than sit above it |
490
+ | Files | what came with it | documents → `FileRow`s (a name identifies them); images → `FileThumbnailGrid` (content does) |
491
+ | Source | where it happened, if openable | `Link` |
492
+ | Author | who filed it | `MemberChip` for a person; `WaveAvatar` + name for an automation |
493
+
494
+ **The verbs — Edit and Delete — live at the BOTTOM of the open body, not in the row's trailing
495
+ slot.** Three defects came out of putting a control on the label row (a button nested in the
496
+ row's own button, a fixed-height box to hold it on the first line, and a gap close enough to
497
+ the chevron to read as one cluster) and every one of them exists only because something
498
+ interactive shared the row. In the body there is nothing to share with, the verbs sit beside
499
+ the content they act on, and the expansion the reader already chose IS the look-before-you-
500
+ delete step — which makes "two clicks" the feature rather than the cost. Provenance sits left
501
+ on that footer, the verbs right; they are two groups, so space pushes them apart rather than a
502
+ uniform gap making them peers.
503
+
504
+ Delete belongs there DESPITE the feed being a record — because of what a multi-writer feed
505
+ actually produces. An extraction filing against the wrong contact and an automation
506
+ double-filing a recording are not history, and a feed with no way to remove them accumulates
507
+ garbage it also asks you to trust. Name what leaves with the entry in the confirmation: a
508
+ count somewhere else is the part nobody expects.
509
+
510
+ Only the GIST and the note are editable — everything else is derived, verbatim, an artifact or
511
+ a model's output. The editor is a MODE behind an **Edit** verb rather than a resident field,
512
+ because the gist is already the row's label and a resident one renders it twice; an EMPTY gist
513
+ shows its field regardless, because that is the write-up path for an entry an automation filed.
514
+ The rule and its two corollaries are in data_entry.md §"When the editor is a MODE instead of a
515
+ resident field".
516
+
517
+ **A block earns its place by carrying what nothing else on the row does.** The failure is not
518
+ a missing block, it is three blocks saying one thing: a participants line, a rep's note and a
519
+ machine summary all reporting that the spreadsheet owner was absent. When the summary is good
520
+ the rep usually adds nothing, and an entry with no note is the normal case rather than an
521
+ incomplete one. Cut on sight: a label above a block that already labels itself, a fact the
522
+ record or the row already states, and any block whose content the reader can see rendered
523
+ directly above it.
524
+
525
+ **Two of those rows are the whole point of the section on a modern record.** A feed fills from
526
+ more than one direction — a person, a chat agent, an extraction off an upload, an automation
527
+ firing on a file — so the surface has to say which values nobody wrote and which a machine did.
528
+ An entry with no prose renders `placeholder` (see `Timeline`), never a phrase assembled from
529
+ the enums; and a machine's text is separated from a person's by SHAPE — an attribution line
530
+ plus a recessed ground — never by a weight nudge. The reasoning and the failure modes are in
531
+ ai_patterns.md §After the run.
532
+
533
+ **The transcript folds, and it reveals in place.** It is the source a summary was made from,
534
+ read rarely and in full when at all — and the reason anyone opens it is to check a claim the
535
+ summary made, so a dialog is wrong: it takes the claim off screen at the moment they want to
536
+ compare. Reveal it whole rather than into a scroll box; a scroller inside a scrolling drawer
537
+ traps the wheel, and a reader who pressed it asked for the length.
538
+
539
+ **The toggle is underlined text, and that is a deliberate exception.** Three controls were
540
+ tried. `Button color="muted"` measures transparent, borderless and undecorated at rest — the
541
+ wash arrives only on hover, so the affordance is hover-only (banned in §"Commit & feedback surfaces") and it
542
+ reads as a stray label between two content blocks. `Accordion` fixes the affordance and
543
+ overcorrects: it is a LIST-ROW primitive, a full-width header with its own chevron, nested
544
+ inside a row that already has one. What is left is a muted underlined `TextLink` in your own
545
+ `Pressable` — visible at rest, on the column's own left edge, and carrying no more weight than
546
+ the words. The ink keeps underline honest: blue leaves the surface, muted stays on it.
547
+
548
+ **Reveal it PLAIN, with no tinted well.** The toggle directly above already says what the text
549
+ is; a panel would put two identical recessed boxes on one row meaning two different things — a
550
+ verbatim record and a machine's reading of it. Keep the `Inset` for the machine's.
551
+
552
+ **The capture sits AT the feed**, as a `Composer` — compact until typed into. Logging an entry
553
+ is this section's input, not a distinct AREA of the record, and a section of its own costs a
554
+ rail entry, which is a claim that there is somewhere else to go.
555
+
556
+ **The tail FOLDS, it does not page.** A record's feed is read newest-first and the rest is
557
+ reference; an unbounded one makes every section below it unreachable by scroll. Show the recent
558
+ few with a toggle naming how many are behind it — a register pages, a record's feed folds.
559
+
560
+ The toggle is a `TextDisclosure`, the same control as the transcript's, because it is the same
561
+ act: it reveals more of what is already here. A `Button color="muted"` is the reach that feels
562
+ right and is not — measured, it is transparent, borderless and undecorated at rest, so the
563
+ affordance only arrives on hover and it reads as a stray line under the feed. It sits on the
564
+ SECTION's left edge, not the rows': the rows are inset by the disc rail, and at the fold's line
565
+ the rail has ended, so nothing visible occupies that indent. Same for any other fold on the
566
+ surface — a document set's "show the rest" is this control too.
567
+ - **No Handoff section.** Progress owns it: it says where the record SITS and carries the act
568
+ that moves it, so a second section repeating the handoff was one more place to look for one
569
+ answer. What that costs is explicit: the sibling record a handoff creates has no home on this
570
+ surface. It belongs on the Progress stage that produced it if an app needs it back.
571
+
572
+ **Activity is not that trail, and the line is the SUBJECT.** Activity carries what a person
573
+ said or sent to a counterparty; the handoff trail carries where the record moved and which
574
+ field changed. Put desk transitions in Activity and it becomes the audit log this template
575
+ declined to build — a feed of everything, scanned for nothing.
465
576
 
466
577
  Recall is the undo: confirmed, it withdraws the sibling, clears the custody stamp that
467
578
  handoff wrote, and returns the record to the desk BEFORE it — one step, never a jump to the