@lotics/ui 41.1.0 → 41.4.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,106 @@ 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.4.0 — embedded markdown is demoted in the OUTLINE, not only on the type ladder
8
+
9
+ `<Markdown variant="embedded">` sized its headings down and left them as the tags the author
10
+ wrote, so an `h2` inside a record value still landed in heading navigation as a PEER of the
11
+ page's own sections. Measured on a record surface, the outline read:
12
+
13
+ > Activity · "Tóm tắt cuộc họp (AI) — …" · "Bài học …" · Details · Next action
14
+
15
+ — a model's call-summary title between two real sections, contributed from inside ONE row of a
16
+ feed that can hold twenty more. Nothing visual finds this, and fixing the size is what makes it
17
+ invisible: the defect stops looking wrong at the moment it stops being measurable.
18
+
19
+ Embedded headings now render `h4`/`h5`/`h6` (h1→h4, h2→h5, h3 and deeper→h6). They stay
20
+ headings, so a reader can still navigate inside a long summary; they simply can no longer reach
21
+ a section's level. **Nothing to migrate** — document-scale markdown is untouched, and the
22
+ embedded type scale is unchanged.
23
+
24
+ ## 41.3.0 — `Timeline` rows, embedded markdown, and a disclosure that is not a link
25
+
26
+ Nothing here removes an API or breaks a type. Two of the changes alter what your rows render on
27
+ sight; the rest is additive.
28
+
29
+ ### `Timeline` bounds its own text, and `right` leaves the press target
30
+
31
+ **The label clamps to two lines** (the description too). It was unbounded, which is invisible
32
+ until a caller puts user-authored prose in it: measured on a real interaction log, one
33
+ 700-character summary drew a **180px label** — nine lines for one entry — and the disc, which
34
+ centred on the label row, ended up 80px below the first line it was meant to sit beside.
35
+
36
+ If your label can run long, put the full text in `details` as well. The row is then a
37
+ truncation and the detail its source, which is the drill-down every expandable register row
38
+ already makes:
39
+
40
+ ```diff
41
+ label: entry.summary,
42
+ + details: <Text size="sm">{entry.summary}</Text>,
43
+ ```
44
+
45
+ **`right` renders beside the press target, not inside it.** On a row with `details` the whole
46
+ row was a `<button>`, so an interactive `right` — a row-action menu, an `IconButton` — nested a
47
+ button inside a button: invalid HTML, and one click reaching two handlers. It is now a sibling,
48
+ and the chevron closes the pressable region, so the order reads `label … ⌄ right`. A
49
+ non-interactive `right` (a duration, a `Badge`) moves 14px right and is otherwise unaffected.
50
+ The hover wash now stops where the press target does, which is the point: a wash that ran under
51
+ a row action made that action invisible exactly when the pointer reached it.
52
+
53
+ **Everything in the band aligns to the label's FIRST LINE, not to the label block** — the disc,
54
+ the chevron and whatever you pass as `right`. Centring and first-line alignment are the same
55
+ answer until a label wraps and then they are 10px apart, so making two-line labels ordinary made
56
+ the old behaviour wrong. **A single-line row is pixel-identical** (measured: 40px band, zero
57
+ drift on all three ornaments); only wrapped rows change, and they change to correct.
58
+
59
+ **`TimelineItem` takes `placeholder`.** It marks a label the SURFACE supplied rather than one the
60
+ record holds, and draws it in the ink an unfilled field uses. A feed fed by automations and
61
+ extractions routinely receives an entry with no prose in it, and `label: string` forces the
62
+ caller to invent some — which then renders in body ink, so "nobody has written this up" and
63
+ "this is what happened" look identical.
64
+
65
+ ```diff
66
+ - label: entry.gist || `${entry.direction} ${entry.kind}`,
67
+ + label: entry.gist || "Not written up yet",
68
+ + placeholder: !entry.gist,
69
+ ```
70
+
71
+ ### `Markdown` takes `variant`
72
+
73
+ `"document"` (the default, and what every current caller keeps) is markdown that IS the surface
74
+ — its `h1` is the page's `h1`. `"embedded"` is markdown that is a VALUE on a page someone else
75
+ designed, and collapses every heading to the body's own size.
76
+
77
+ ```diff
78
+ - <Markdown>{record.aiSummary}</Markdown>
79
+ + <Markdown variant="embedded">{record.aiSummary}</Markdown>
80
+ ```
81
+
82
+ Reach for it whenever the text was not written by whoever built the screen — now the common
83
+ case, because the writer is routinely a model. Measured on a real feed, a machine summary at
84
+ document scale drew 18px/600 headings, one rung under the section heading containing it.
85
+
86
+ `MarkdownProps` is reached from `@lotics/ui/markdown_types`, the same way `MediaPlayerProps` is;
87
+ the component entry points do not re-export it.
88
+
89
+ ### `TextDisclosure`, and what underlined text is allowed to mean
90
+
91
+ New component: muted underlined text that REVEALS adjacent content in place — a transcript under
92
+ its player, a long value under its summary. It exists because the rule used to be "underlined
93
+ text is the NAVIGATION affordance and nothing else", and following that produced worse screens:
94
+ the quiet control it left you (`Button color="muted"`) measures transparent, borderless and
95
+ undecorated at rest, so its affordance arrives only on hover.
96
+
97
+ The rule is now scoped rather than absolute, and the INK carries the difference:
98
+
99
+ | | |
100
+ |---|---|
101
+ | blue + `role="link"` (`Link`) | leaves this surface |
102
+ | muted underline (`TextDisclosure`) | reveals more of it, in place |
103
+
104
+ Anything that MUTATES still carries a control surface — `Button` in chrome, `InlineButton` on a
105
+ field. Nothing to migrate: existing `Link`/`TextLink` usage is unaffected.
106
+
7
107
  ## 41.1.0 — `FileRows` no longer captions every row with the file type
8
108
 
9
109
  `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,16 @@ 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`. Embedded demotes the heading LEVEL as well as
417
+ the scale (h1→h4, h2→h5, h3+→h6): sizing alone leaves a model's `##` announcing as a PEER of
418
+ your own sections in heading navigation, which no visual check can see.
419
+ - **`markdown_types`** — `MarkdownProps`; types only.
407
420
  - **`markdown.css`** — import once for the web markdown styling.
408
421
  - **`format_date`** — `formatDate` / `parseDate` / `toISODate` + `DateFormatStyle`.
409
422
  - **`format_money`** — `formatMoney` / `formatCompactNumber`.
@@ -488,7 +501,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
488
501
  image-less entities — groups, organizations. A person → `Avatar`/`MemberChip`.
489
502
  - **`wave_avatar`** — `WaveAvatar`: decorative animated waveform avatar (voice/audit
490
503
  history); animates on web, renders a static fallback on native.
491
- - **`member_chip`** — `MemberChip`: avatar + name; the universal person render.
504
+ - **`member_chip`** — `MemberChip`: avatar + name; the universal person render. `size` scales
505
+ BOTH — `sm` gives a 24px avatar and a 12px name, every larger rung keeps the name at body
506
+ size. Reach for `sm` when the chip sits inside a sentence or a dense row, where a body-size
507
+ name would collide with the 12px text around it.
492
508
 
493
509
  ### Layout & surfaces
494
510
 
@@ -632,8 +648,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
632
648
  with no `href` it stays NEUTRAL: plain underlined text to wrap in your own pressable,
633
649
  or a MARKER that a value leads somewhere (a record reference whose press opens a peek,
634
650
  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.
651
+ act: anything that MUTATES carries a control surface (`Button`/`InlineButton`).
652
+ - **`text_disclosure`** — `TextDisclosure`: the counterpart to `TextLink` — muted
653
+ underlined text that REVEALS adjacent content in place (a transcript under its
654
+ player, a long value under its summary). Underlined text either goes somewhere or
655
+ reveals something, and the INK says which: blue + `role="link"` leaves the surface,
656
+ muted stays on it. Takes `expanded`/`onToggle` and a `label` NOUN — it supplies the
657
+ verb, so two call sites cannot end up with "Show" and "View". Reach for it instead
658
+ of the two things that look right and are not: `Button color="muted"` measures
659
+ transparent, borderless and undecorated at rest, so its affordance arrives only on
660
+ hover (invisible to keyboard and touch, and against a text column it reads as a
661
+ label); `Accordion` is a list-ROW disclosure, heavy furniture for a verb nested
662
+ inside a row that already has a chevron. Announces `role="button"` + `aria-expanded`
663
+ — reaching for `Link` instead tells a screen-reader user they are leaving a page
664
+ that is about to unfold under them.
637
665
  - **`chip`** — `Chip`: the generic pill — pressable when `onPress` (announces as a button;
638
666
  pass `accessibilityLabel` when children aren't self-describing text) + an
639
667
  absolutely-positioned dismiss ✕ sibling when `onDismiss` (its name = `dismissTooltip` ??
@@ -1395,7 +1423,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1395
1423
  `pxPerDay`) + `GanttTask`/`GanttLabels` types. **Limitation:** labels are not
1396
1424
  provider-wired — pass `labels` (`DEFAULT_GANTT_LABELS` is English).
1397
1425
  - **`timeline`** — `Timeline`: a heterogeneous event LOG — per-row icon + expandable
1398
- details; models the past, NOT progress.
1426
+ details; models the past, NOT progress. Three things it decides for you, because a row
1427
+ cannot be trusted to a caller's data: the **label clamps to two lines** (and the
1428
+ description likewise) — a row is a scan target, and an unclamped one lets a single
1429
+ user-authored note set the section's height, so put the full text in `details` too;
1430
+ **`placeholder`** marks a label the SURFACE supplied rather than one the record
1431
+ holds, drawing it in the unfilled-field ink — reach for it on any feed that fills
1432
+ from more than one direction, where an entry routinely arrives with no words in it;
1433
+ **the disc, the chevron and `right` all sit on the label's FIRST LINE**, so a wrapped
1434
+ row lines up with an unwrapped one (the worked reference for the rule in
1435
+ composition.md §registers); and **`right` renders beside the press target, not inside
1436
+ it**, so a control there is valid HTML and keeps its own click — though on a row that
1437
+ EXPANDS, verbs belong in the BODY and `right` is better kept for a fact (a duration, a
1438
+ badge). A control sharing the label row with the press target is what produced the nesting,
1439
+ the first-line box and the chevron-pairing problems in the first place.
1399
1440
  - **`checklist`** — `Checklist` + `ChecklistGroup` + `ChecklistItem` + `ChecklistNote` /
1400
1441
  `ChecklistActions` / `ChecklistField`: THE run of tickable rows, at any
1401
1442
  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,