@lotics/ui 43.2.0 → 43.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -16,7 +16,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
- | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
19
+ | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
21
21
  | [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
22
22
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
@@ -55,8 +55,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
55
55
  order, where a line claims an order the work does not have. **`mark`** asks *does ticking mean
56
56
  DONE or CHOSEN* — a ring for completion, the square box for picking (which forms to produce,
57
57
  which records to export). Spend the ring on a choice and a reader who learned that a filled
58
- ring means "done" reads a ticked pick-list as work already carried out. It replaced separate
59
- task/pipeline families that had converged on one anatomy. A GROUP is a phase — a name, optionally a
58
+ ring means "done" reads a ticked pick-list as work already carried out. A GROUP is a phase — a name, optionally a
60
59
  Show/Hide disclosure, and no body: it owns no COMPLETION, and a ring that never responds invites
61
60
  a press it refuses. Rows tick ONE at a time; derive position from the highest stamp and one click marks
62
61
  everything below it without inventing dates. A checklist REPORTS — a field owned by a section is
@@ -101,17 +100,13 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
101
100
  padding. `PressableRow` (all variants), `Table`, `ChoiceList`, `ListItem` and `FileRow`
102
101
  already do this, so a screen composed from the kit aligns with **no per-site padding at all**.
103
102
  The moment one component insets instead, every neighbour has to be hand-padded by the same
104
- amount to catch up, and that compensation spreads to each new surface built beside it which
105
- is how a `SummaryLine`, a `Clarify` question and a bare table's footer each acquired a
106
- padding they should never have needed. **An indent is legible only when something VISIBLE
103
+ amount to catch up, and that compensation spreads to each new surface built beside it. **An indent is legible only when something VISIBLE
107
104
  occupies it** — a checkbox, an ordinal, a diff mark. An empty indent is a defect.
108
105
  - **The one exception is a self-contained OVERLAY.** A menu or a selector's popover
109
106
  (`MenuListItem`, `OptionList`) has nothing outside itself to align with, so its rows keep
110
107
  plain padding. Bleeding there would push rows past the popover's own edge.
111
108
  - **A slot holding another component's part takes THAT component's constant**, never a
112
- hand-picked number that happens to look close: `AgentRun`'s dot column was 18 while the
113
- `Marker` it holds is `NODE` (24), so a 24px dot centred in an 18px column put the whole run
114
- rail 3px left of every other band in the dialog. Import the owner's constant.
109
+ hand-picked number that happens to look close. Import the owner's constant.
115
110
  - Inside a `Dialog`, every band reads `dialogGutter(small)` — header, scroll area, footer and
116
111
  any pane a caller drops in. A hand-copied `small ? 16 : 24` is a fifth copy of a number
117
112
  that has to agree with four others.
package/MIGRATION.md CHANGED
@@ -4,6 +4,37 @@ 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
+ ## 43.3.0 — a `bare` inline field sits on the column, and Enter finishes a one-line one
8
+
9
+ Two fixes to `Inline*`, both of them behaviour a caller cannot see in a prop name.
10
+
11
+ **A `bare` field now aligns its TEXT, not its box.** The field surface carries 8px
12
+ of horizontal padding, which is invisible at rest on a variant that draws no
13
+ border — so every bare value rendered 8px right of whatever named it. In a
14
+ `DataGrid` that meant each column's values sat off its own header. The inset is
15
+ now bled back out with a negative LEFT margin: glyphs land on the container's
16
+ edge, the hover/focus border overhangs into the gutter, and hover moves nothing.
17
+
18
+ Left only, deliberately. A matching right bleed aligns nothing on left-aligned
19
+ text and pushes the frame past the edge its neighbours stop at, which shows the
20
+ moment the border paints. **If you compensated with your own negative margin,
21
+ remove it** — you will now be 8px out the other way. A field needs ~8px of slack
22
+ on its left to hover into; in a zero-gap column, adjacent borders will touch. A
23
+ DISABLED `framed` field is unaffected: it drops its border and fill so it cannot
24
+ promise a press, but it stays in line with the enabled fields beside it.
25
+
26
+ **`numberOfLines` now decides what ENTER does — wrapping no longer does.** The two
27
+ were one flag, so `autoGrow` — a prop about fitting the value — silently turned
28
+ Enter from "save" into "insert a newline". A one-line summary field that grew when
29
+ it ran long became a field you could only leave by clicking away, and nothing at
30
+ the call site said so.
31
+
32
+ A field left at one line (or `numberOfLines={1}`) holds a single-line value that
33
+ may WRAP, and Enter commits it. Declaring 2+ lines declares PROSE: Enter is a
34
+ paragraph break and blur saves, exactly as before. **If you wrote
35
+ `numberOfLines={1} autoGrow` and relied on Enter inserting a newline, raise the
36
+ line budget** — that is now what says so.
37
+
7
38
  ## 43.0.0 — a time is PICKED, and there is nothing to type
8
39
 
9
40
  `TimePicker` is no longer a segmented text field. It is the value on a pressable
@@ -78,6 +109,19 @@ its reasoning are in `docs/composition.md` § The pointer cursor. If a component
78
109
  needs the pointer, set `CURSOR_ACTION` in its style — and if it is built on the bare
79
110
  react-native `Pressable`, note that the pointer is what you get by DEFAULT, from
80
111
  react-native-web, unless you set `CURSOR_DEFAULT`.
112
+ ## 42.5.0 — an expanded `Timeline` row shows its whole label
113
+
114
+ The label was clamped to two lines whether the row was open or shut. Expanding a
115
+ row is a request to see more of what is there, so a label that stays truncated
116
+ through it leaves the caller exactly one way to make its own label readable:
117
+ repeat it inside `details`. Then the reader meets the same sentence twice — once
118
+ cut off, once whole — which reads as a rendering bug rather than as disclosure.
119
+
120
+ It now clamps while collapsed only. **If you duplicated a label into `details`
121
+ to work around this, delete the copy**; the row will show it in full on its own.
122
+ Nothing else changes: a collapsed row is identical, and a row whose label already
123
+ fitted in two lines is identical open or shut.
124
+
81
125
  ## 42.4.0 — `Picker` is named by its `FormField`, and its way back to empty has a name
82
126
 
83
127
  Two fixes to the same control, both about a thing on screen that the control did
@@ -124,8 +124,7 @@ transition — never leave a live button waiting on a network round-trip:
124
124
 
125
125
  - **Same-tick feedback.** On press, synchronously close the dialog / swap to the run surface
126
126
  (`AgentRun` with its streaming state) so there is nothing left to click. A button that stays
127
- pressable while an attach/start request round-trips invites double-fires — observed in
128
- production as stacked paid runs.
127
+ pressable while an attach/start request round-trips invites double-fires.
129
128
  - **Single-flight in app code too.** Guard the handler with a ref (state alone races the
130
129
  re-render): first press wins, later presses no-op until the flow settles.
131
130
  - **The SDK backstops runs, not workflows.** `useAgentRun().run()` is single-flight by default
@@ -242,14 +241,10 @@ feed**; the starting row IS the placeholder, consistent and localized.
242
241
  (Sonnet / Opus, not Haiku); the renderer shows every reasoning part it's handed.
243
242
 
244
243
  **Localization.** The feed's fixed chrome — the "Thinking" label, the "Input"/"Output"/"Error"
245
- panel titles, the "awaiting" annotation, and the "Retry" action resolves through the
246
- `LoticsLocale` `agentRun` slice (translate once at the provider; a `vi` app gets them for free).
247
- **and so are the tool labels** (`agentRun.tools`, keyed by raw tool name). Translating a tool
248
- row is no longer each app's job: an English row under a Vietnamese "Đang suy nghĩ…" was the kit
249
- contradicting itself. The "{n} steps" suffix is locale-backed too (`agentRun.steps`). `labelForCall`
250
- and `stepsLabel` remain as per-call-site OVERRIDES — for phrasing a row by what
251
- it targets — not as the only route to a translation. A tool name in neither the locale map nor
252
- `labelForCall` falls back to its prettified form.
244
+ panel titles, the "awaiting" annotation, the "Retry" action, the tool labels (`agentRun.tools`,
245
+ keyed by raw tool name) and the "{n} steps" suffix (`agentRun.steps`) resolves through the
246
+ `LoticsLocale` `agentRun` slice (translate once at the provider; a `vi` app gets them for free);
247
+ `labelForCall` and `stepsLabel` remain as per-call-site OVERRIDES.
253
248
 
254
249
  **Answering an awaiting call — `ApprovalPrompt`.** The `awaiting` row inside `AgentRun` is
255
250
  READ-ONLY — it only NAMES that a call is parked on a human decision. The surface that ANSWERS it
@@ -466,8 +461,7 @@ rather than wherever the feed was scrolled to.
466
461
  holds the wizard's verbs while a question is up, **Stop** while the run streams, and nothing once
467
462
  it settles; the RUN decides, which is the whole point of handing it the run. A host that names a
468
463
  single phase out here duplicates that decision and gets it wrong the moment the bar learns a new
469
- one — which is precisely how two streaming phases ended up with an empty footer and no way to
470
- stop.
464
+ one.
471
465
 
472
466
  **`run` is a SHAPE, not an import** (`AgentRunLike`: `status`, `parts`, `pendingChoice`,
473
467
  `answerChoice`, `cancel`, `error`; `AgentRunQuestion` names what `pendingChoice` carries, so a template or
@@ -610,8 +604,8 @@ ranked set, don't cram it into chat prose:
610
604
  document-driven records) — the record's files block feeds ONE "Use AI" entry that FORKS into
611
605
  the two document tasks, each a specialized run with a task-pure result: **Extract** (files
612
606
  read → fields already matching fold into one quiet line → every add / update / conflict a
613
- a `DiffValue` per field (the struck current value, the proposed one, candidate rows), the record's current value
614
- a first-class choice — plus proposed new lines as record-body `Change`s → one outcome-named
607
+ `DiffValue` per field (the struck current value, the proposed one, candidate rows), the record's current value
608
+ a first-class choice — plus proposed new lines as record-body `Change`s →
615
609
  an explicit outcome-named commit) and **Cross-check** (documents compared against the record and
616
610
  each other → ranked `Finding`s — severity, title, the prominent metric, sources — separated
617
611
  by hairlines; the findings ARE the outcome the human acts on). The fork carries an OPTIONAL
@@ -642,12 +636,11 @@ ranked set, don't cram it into chat prose:
642
636
  ## The visual vocabulary — functional colour, no gimmicks
643
637
 
644
638
  The AI vocabulary has **no purple accent and no gimmick glyphs** (no sparkles) — but it is NOT
645
- monochrome: **colour is used where it carries meaning, not for decoration.** What the violet
646
- sparkle used to carry now reads structurally — **provenance** is a quiet sentence-case microlabel
639
+ monochrome: **colour is used where it carries meaning, not for decoration.** **Provenance** is a quiet sentence-case microlabel
647
640
  naming the artifact ("Proposed", "Match", "Mismatch", "Suggested edit", "Question" — xs,
648
- muted, medium; all-caps is banned kit-wide), and the agent's **reasoning** is a left-ruled margin
641
+ muted, medium; all-caps is banned kit-wide), the agent's **reasoning** is a left-ruled margin
649
642
  note (a hairline rule + muted text — `ChangeReasoning`), quoted apart from the facts and the
650
- human's controls while **status/severity/diffs use functional colour** the way the rest of the
643
+ human's controls, and **status/severity/diffs use functional colour** the way the rest of the
651
644
  kit does:
652
645
 
653
646
  - **Confidence** — the 3-tick meter + the full phrase, emerald / amber / zinc by level.
package/docs/catalog.md CHANGED
@@ -80,8 +80,7 @@ renders these per option.)
80
80
 
81
81
  **Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 28 / 40 / 72
82
82
  (`@lotics/ui/avatar_size`, shared by `Avatar`, `MemberChip` and `GroupAvatar`). Default `md`.
83
- The number was removed because the same concept had drifted to eleven different values across
84
- the product and its apps, so one person looked different on every screen. Pick by density:
83
+ Pick by density:
85
84
  `sm` for a dense row or inline cell, `md` for a control band, register or list (the default),
86
85
  `lg` for a prominent row or the account menu, `xl` for a profile's own header.
87
86
 
@@ -89,9 +88,7 @@ the product and its apps, so one person looked different on every screen. Pick b
89
88
  avatar is IN a control or IS one.** `md` is `CONTROL_CONTENT_HEIGHT` (28) — the largest rung a
90
89
  40px control band seats without growing, and the height `InlineButton` and a `md` `IconButton`
91
90
  already used to sit inside a field. An avatar is the tallest thing in whatever row holds it, so
92
- a rung that overshoots doesn't look slightly big, it RESIZES the control: at the old 36 the same
93
- member chip measured 46 in a `Select` trigger, 50 in an inline editor and 54 in an `InlineSlot`,
94
- because each surface had picked its own inset with no number to pick it from. Put nothing taller
91
+ a rung that overshoots doesn't look slightly big, it RESIZES the control. Put nothing taller
95
92
  than `md` in a control. `lg` is `CONTROL_HEIGHT` (40) — the avatar that stands in a band on its
96
93
  own, an account menu beside a row of icon buttons, so it measures what they do. Anything bigger
97
94
  belongs in a row that sets its own height.
@@ -155,8 +152,7 @@ axes carry every shape it takes, and BOTH are decisions about meaning, not looks
155
152
  work already carried out, and nothing on screen corrects them. (The shapes are 24px either way
156
153
  and share one gutter, so a surface swaps between them without moving anything.)
157
154
 
158
- A record's Progress and a desk's to-do list are the same component two booleans apart; they were
159
- two families until the anatomy proved identical.
155
+ A record's Progress and a desk's to-do list are the same component two booleans apart.
160
156
 
161
157
  **A row carries its TITLE and its own stamp, and nothing else.** A checklist REPORTS: it does not
162
158
  collect, and it does not PREVIEW. A field some section owns — an assignee, an address, a portal
@@ -1068,7 +1064,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1068
1064
  `DetailTable`: the compound parent of
1069
1065
  a row STACK — `labelWidth` (default `DETAIL_LABEL_WIDTH`, 130 — THE kit's label column, and
1070
1066
  the record surface's) / `minHeight` (default 40, the inline-control grid) declared
1071
- ONCE + the `SPACE.md` (16) row gap — 8 was right while a field was a tint, but a stack of
1067
+ ONCE + the `SPACE.md` (16) row gap — a stack of
1072
1068
  BORDERED rows that close together fuses into one block. TWO columns, no third: the value
1073
1069
  FILLS what the label leaves, so nothing one row does can narrow its neighbours' editors. RESPONSIVE with no prop: it
1074
1070
  measures its own container (onLayout, not
@@ -1078,7 +1074,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1078
1074
  grammar); raise `minValueWidth` (default 160) when a cell holds
1079
1075
  MORE than one editor so the table stacks earlier. Two tables on one page share one grid by
1080
1076
  repeating the same labelWidth. Worked example:
1081
- [`tpl_record`](../examples/tpl_record.tsx). A label's FIRST line centres on the control line, not the label block: a wrapped label grows DOWNWARD past the value's first line rather than dragging its own first line above it, so "Registered address" over two lines still starts level with the value it names.
1077
+ [`tpl_record`](../examples/tpl_record.tsx).
1082
1078
  - **`record_summary`** — `RecordSummary`: the identity band of a record detail/drawer — ONE
1083
1079
  row: `title` xxl semibold tabular, `subtitle` sm muted, `status` Badge slot, optional
1084
1080
  `metric` {label,value,tone,note} pinned right, the band's ONE accent. The record's FIELDS
@@ -1175,8 +1171,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1175
1171
  and Save writes the pre-action values back over what it just fetched.
1176
1172
  The peek uses the popover's OWN anatomy: `PopoverHeader` for the identity, a scrolling body,
1177
1173
  and a **pinned `PopoverFooter`** for the verbs. A long block therefore scrolls with its actions
1178
- still reachable — the earlier hand-rolled footer was a body child and scrolled away, hiding its
1179
- own Save.
1174
+ still reachable.
1180
1175
  `Edit` swaps the SAME grid's value cells for inputs — a DRAFT, so nothing commits until `Save`,
1181
1176
  which fires `onSave` with **only the facts that CHANGED** (never a snapshot, so a lock or
1182
1177
  `before_update` sees the real edit). This is what lets a peek hold editors at all: a
@@ -1365,7 +1360,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1365
1360
  never `style`**: `style` reaches only the BODY, and the panel is `max-content`, so a width set
1366
1361
  there governs the scroller while the header and footer sit outside it — the first long title
1367
1362
  then stretches the panel and leaves the content adrift in a box wider than itself. A popover
1368
- with no bands never sees this, which is why `width` had to exist before one had both.
1363
+ with no bands never sees this.
1369
1364
  The built-in scroller is **full-bleed horizontally** and re-insets its content by the same
1370
1365
  amount, so a scrolling body reaches the panel's real edges — its content slides under the
1371
1366
  panel edge rather than under an invisible margin 12px inside it — while the text stays on the
@@ -1437,8 +1432,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1437
1432
  `PieChart`: the canonical SVG chart set (no recharts).
1438
1433
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
1439
1434
  plain sm tabular count beside it.
1440
- - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one
1441
- (it replaced `RingGauge`). Same API shape as the bar — a real `value`/`max` rather than a
1435
+ - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one. Same API shape as the bar — a real `value`/`max` rather than a
1442
1436
  pre-computed percentage, optional `label`/`caption`, and a `format` that turns the centre
1443
1437
  figure off. `format="none"` with no label leaves the arc bare at any size: that is the
1444
1438
  TRIGGER form, for a control row where a figure would be unreadable and a bar would not
@@ -1487,9 +1481,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1487
1481
  provider-wired — pass `labels` (`DEFAULT_GANTT_LABELS` is English).
1488
1482
  - **`timeline`** — `Timeline`: a heterogeneous event LOG — per-row icon + expandable
1489
1483
  details; models the past, NOT progress. Three things it decides for you, because a row
1490
- cannot be trusted to a caller's data: the **label clamps to two lines** (and the
1491
- description likewise) — a row is a scan target, and an unclamped one lets a single
1492
- user-authored note set the section's height, so put the full text in `details` too;
1484
+ cannot be trusted to a caller's data: the **label clamps to two lines WHILE COLLAPSED**
1485
+ (the description always) — a row is a scan target, and an unclamped one lets a single
1486
+ user-authored note set the section's height. Opening the row un-clamps it, so **never
1487
+ repeat the label inside `details`**: the fact belongs to one element, and a copy means
1488
+ the reader meets the same sentence twice, cut off and then whole. `details` carries
1489
+ what the label does not — the attachments, the machine's reading, the provenance;
1493
1490
  **`placeholder`** marks a label the SURFACE supplied rather than one the record
1494
1491
  holds, drawing it in the unfilled-field ink — reach for it on any feed that fills
1495
1492
  from more than one direction, where an entry routinely arrives with no words in it;
@@ -1502,10 +1499,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1502
1499
  the first-line box and the chevron-pairing problems in the first place.
1503
1500
  - **`checklist`** — `Checklist` + `ChecklistGroup` + `ChecklistItem` + `ChecklistNote` /
1504
1501
  `ChecklistActions` / `ChecklistField`: THE run of tickable rows, at any
1505
- altitude. It replaced a `task` family and a `pipeline` family that had converged on one
1506
- anatomy a ring, a title, a value beside it, a muted note, a detail block, an optional
1507
- grouping level differing only in whether a LINE joined the rings. **`connected` is that
1508
- line, and it is the whole difference**: on for ONE record walking positions (it asserts
1502
+ altitude a ring, a title, a value beside it, a muted note, a detail block, an optional
1503
+ grouping level. **`connected` is the LINE joining the rings, and it is the whole
1504
+ difference**: on for ONE record walking positions (it asserts
1509
1505
  sequence), off for a SET picked in any order, where a line claims an order the work does not
1510
1506
  have. Rows are ticked ONE AT A TIME; where the run is a sequence, derive position from the
1511
1507
  highest stamp so one click marks everything below it — a parent that ticked its children wrote
@@ -1659,10 +1655,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1659
1655
  NODE** as well as a string, because a second line can itself be carrying a change (a file
1660
1656
  being RECLASSIFIED is the same bytes filed differently — a `DiffValue` on the property); a
1661
1657
  node renders outside the muted single-line `Text`, which would clip it and fight its colour.
1662
- Both `sm` variants share ONE beat (48px min + 6px vertical padding): the static row used to
1663
- have neither, so it came out 37px against the pressable's 49 — whether a row was a door
1664
- decided how tall it was — and 37 sat under every other row beat in the kit (`DetailRow` 40,
1665
- `TableRow` 52, this component's own `md` 56), which is what "squeezed" literally was.
1658
+ Both `sm` variants share ONE beat (48px min + 6px vertical padding).
1666
1659
  - **`file_rows`** — `FileRows`: batteries-included file list — row press → built-in gallery
1667
1660
  + a ⋯ Download/Open-external/Remove menu; composes `FileRow` + `ActionMenu` +
1668
1661
  `FileGalleryModal`; localized via the `gallery` locale slice. **`meta` has NO default**: the
@@ -1835,9 +1828,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1835
1828
  `added`/`changed`/`removed`/`unchanged`, for the cases no single value carries. Glyph + an
1836
1829
  accessible word (never colour alone — "added" vs "removed" by hue is invisible to a reader who
1837
1830
  cannot separate them, and that is the distinction a review turns on). **ONE treatment
1838
- everywhere: a filled 22px DISC**, 14px glyph, 700 ink on a 100 ground. It briefly had two — a
1839
- bare glyph for dense surfaces, a filled shape for sparse ones and a form marked with bare
1840
- glyphs beside a table marked with filled shapes read as two systems saying the same thing; a
1831
+ everywhere: a filled 22px DISC**, 14px glyph, 700 ink on a 100 ground. A form marked with bare
1832
+ glyphs beside a table marked with filled shapes reads as two systems saying the same thing; a
1841
1833
  reader parses shape before meaning, so an unexplained difference reads as an accident however
1842
1834
  principled the reason. The disc also gives the glyph its own ground, which is what lets one
1843
1835
  mark sit on a white row, a tinted cell, or a photograph. **`unchanged` renders as an empty
@@ -380,12 +380,7 @@ holds another component's PART takes that component's constant, never a hand-pic
380
380
  centred in an 18px column put the whole run rail 3px outside the surface's edge — every row
381
381
  wrong by the same amount, so it read as internally consistent and nothing flagged it.
382
382
 
383
- The register used to pad 20 WITHOUT the negative margin, and the cost was not
384
- the 20px — it was that everything nearby had to be hand-padded by the same 20 to
385
- line up with it. A `Table`'s header band, its static rows and its expanded detail
386
- all inherited the number; a `SummaryLine` above the register got it at the call
387
- site; a bare table's footer borrowed `CardFooter` and picked up a *card's*
388
- interior gutter. **An indent is legible only when something VISIBLE occupies it**
383
+ **An indent is legible only when something VISIBLE occupies it**
389
384
  — a checkbox, an ordinal, a diff mark. An empty one is a defect, and the
390
385
  compensation it forces spreads to every new surface that sits beside it.
391
386
 
@@ -425,8 +420,7 @@ content off the register entirely.
425
420
  ornament sits beside a text block and centres on it — a `Timeline`'s disc, a marker, an avatar,
426
421
  a rail glyph — the ornament's position is a function of the text's height, so an unbounded text
427
422
  block moves it. A feed is where this actually bites, because a feed is the one row primitive
428
- whose text is written by the USER: measured on a real interaction log, one 700-character summary
429
- drew a 180px label and put the disc 80px below the line it names. Bound the text; the row keeps
423
+ whose text is written by the USER. Bound the text; the row keeps
430
424
  its beat and the ornament keeps its line. Where the full value still has to be readable, the row
431
425
  EXPANDS to it — a truncation and its source, not a second copy.
432
426
 
@@ -530,9 +524,7 @@ non-interactive by construction. `TableRow` already composes `PressDoor`.
530
524
 
531
525
  **A trailing slot is not a PEER of the row's own affordance — space them apart.** A disclosure
532
526
  chevron belongs to the row's button; the slot beside it does not, and if the two are spaced like
533
- siblings the eye reads one cluster. Measured on a feed row: 10px from the label to the chevron
534
- and 12px from the chevron to the action menu, so the only thing saying they were different
535
- targets was a 4px break in the hover wash — invisible to a reader who never hovers. Put clearly
527
+ siblings the eye reads one cluster. Put clearly
536
528
  more space BETWEEN the groups than inside one (2:1 is enough) and the pair separates without a
537
529
  divider, a weight change or any new element. It is the ordinary grouping rule — one uniform gap
538
530
  makes every child a peer — applied to a row's right edge, where it is easy to miss because both
@@ -550,7 +542,7 @@ Actions stay **visible** — a hover-only action is invisible to keyboard and to
550
542
  exception is a DENSE tree/register where a persistent per-row action would clutter: reveal on
551
543
  hover **OR focus-within**, keeping the action in the DOM + tab order and gating only its opacity,
552
544
  so it's still keyboard-reachable. Do that locally — there is deliberately no shared hover-reveal
553
- primitive (one existed and made the keyboard-inaccessible anti-pattern easy; it was removed).
545
+ primitive.
554
546
 
555
547
  **A row is a button wrapped around DATA, so its text stays SELECTABLE and a drag is not a press.**
556
548
  The surface carries no blanket `userSelect: "none"` — that made a customer's own name and number
@@ -1033,10 +1025,7 @@ When it IS a status, match weight to prominence — lightest → heaviest:
1033
1025
  a surface (a drawer/detail header). Opt IN explicitly and sparingly; one per view, never
1034
1026
  sprinkled through rows.
1035
1027
 
1036
- **`tonal` IS allowed on a register's ONE primary status column, and that is the rule this
1037
- paragraph used to get wrong.** It read "a register's dense rows read lighter, the row's primary
1038
- status is `dot`" while `badge.tsx` named "a register's primary Status column" as a legitimate
1039
- tonal use — two docs, opposite answers, so the choice fell to whoever read which. What "one per
1028
+ **`tonal` IS allowed on a register's ONE primary status column.** What "one per
1040
1029
  view" governs is the number of badge KINDS on a surface, not the number of rows: badging the
1041
1030
  stage AND the type AND the city is the clutter the rule exists to stop; one status column that
1042
1031
  happens to render 133 times is one badge.
@@ -84,6 +84,10 @@ layout), click OR keyboard focus opens it for editing **with nothing moving** (z
84
84
  whole point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"`
85
85
  (✓ / ✕).
86
86
 
87
+ **`numberOfLines` declares what ENTER means — wrapping does not.** A field left at one line holds a
88
+ single-line value that may WRAP when it runs long (a title, a summary), and Enter finishes it.
89
+ Declaring 2+ lines declares PROSE: Enter is a paragraph break and blur is what saves.
90
+
87
91
  **How "nothing moves" is achieved differs by editor, and the difference is a rule.** A field whose
88
92
  resting value is the RAW string (`InlineTextInput`) is ONE `<input>` in both states — it never
89
93
  swaps elements, and `editing` is a style state driven by focus. Two elements cannot be made to
@@ -217,16 +221,12 @@ something belongs elsewhere.
217
221
 
218
222
  It is wrong wherever the length is the AUTHOR's choice, and it fails in the worst available way.
219
223
  The box is drawn at the budget and the value simply exceeds it: no ellipsis, no clamp, no
220
- scrollbar, nothing to scroll. Measured on a record note, 285 characters drew **76px of a 116px
221
- value** — two full lines gone with nothing on screen admitting it. A truncation mark would have
222
- been kinder; this is silent. So the test is not "is this field long" but **"who decides the
224
+ scrollbar, nothing to scroll — a truncation mark would have been kinder; this is silent. So the test is not "is this field long" but **"who decides the
223
225
  length"** — the field, or whoever is typing.
224
226
 
225
- **And it gets worse as the surface narrows**, which is the opposite of the intuition that a
226
- desktop check is the demanding one. A budget is a count of LINES and the wrap point moves: the
227
- same value measured 60px hidden at 1280 and **96px hidden at 375**, because the narrower column
228
- wrapped it into more lines against the same three-line box. A fixed reserve verified on a wide
229
- screen is not verified.
227
+ **And it gets worse as the surface narrows**: a budget is a count of LINES and the wrap point
228
+ moves, so the narrower column wraps the same value into more hidden lines. A fixed reserve
229
+ verified on a wide screen is not verified.
230
230
 
231
231
  `autoGrow` makes the budget a minimum and fits the value. It costs nothing the fixed reserve was
232
232
  protecting, and the reason is worth stating because it is easy to assume otherwise: an
@@ -277,6 +277,17 @@ already promises and, repeated down a column, draws the grid a second time. Both
277
277
  identically, so this changes what a field looks like RESTING, never what it does. A hand-rolled
278
278
  pressable cell should match `bare`: nothing at rest, the border on hover — never a background wash.
279
279
 
280
+ **A `bare` field aligns its TEXT, not its box.** Because it looks like text at rest, it sits on the
281
+ column like text: the frame's 8px inset is pulled back out with a negative LEFT margin, so the glyphs
282
+ land on the container's edge. Without that, every bare value rendered 8px right of the header naming
283
+ it. The bleed is one-sided on purpose — a matching right bleed aligns nothing on left-aligned text and
284
+ only pushes the frame past the edge its neighbours stop at, which shows the moment the border paints.
285
+ Two consequences for callers: a `bare` field needs ~8px of slack on its LEFT to hover into (in a
286
+ zero-gap column the borders of adjacent cells will touch), and it is the wrong variant for a surface
287
+ where it must stay strictly inside its box — that is `framed`. A DISABLED `framed` field keeps the
288
+ box: it drops the border and fill so it cannot promise a press, but it stays in line with the enabled
289
+ fields beside it.
290
+
280
291
  A READ-ONLY value in the same column — a computed total, a system ID, a synced/locked field — is
281
292
  **`InlineStatic`**: it copies the editor box metrics exactly (height, padding, 1px transparent
282
293
  border) but stays FLAT and non-interactive, so editable (chip) vs read-only (flat) is legible at a
@@ -331,33 +342,46 @@ row that wants it. See the "General" section of `tpl_record`, which also reads t
331
342
  full record surface. Not every field is a same-height swap — a tag field, a status, or an
332
343
  attachment grid edit in place too (below).
333
344
 
334
- ## When the editor is a MODE instead of a resident field
345
+ ## A value rendered TWICE is the bug not the resident editor
335
346
 
336
- The section above is the default and holds almost everywhere: the value IS the editor, nothing
337
- moves, and there is no verb to find. It has exactly one exception, and it is decided by a
338
- question with a factual answer — **does this value already appear somewhere else on the same
339
- surface?**
347
+ The section above is the default and holds everywhere. There is one thing that appears to
348
+ contradict it, and the contradiction is worth naming because the wrong resolution is the
349
+ tempting one: **a value that already appears elsewhere on the same surface.**
340
350
 
341
- On a FEED, it does. A row's identity line is the value, and the body opens under it, so a
342
- resident editor renders the same sentence twice, stacked and identical. Quieting the frame does
343
- not help: a sentence appearing twice reads as a mistake at any weight. So the editor becomes a
344
- mode, reached by an **Edit** verb in the row's trailing slot beside its other actions.
351
+ A feed used to be the example. Its row LABEL was the gist, the body opened underneath, and a
352
+ resident editor rendered the same sentence twice stacked and identical, which reads as a
353
+ mistake at any weight, so quieting the frame does not help. The fix looked like demoting the
354
+ editor to a mode behind an **Edit** verb, and that is a workaround: it keeps the duplication and
355
+ buys silence by hiding one copy behind a click. What it costs shows up immediately after —
356
+ the verb has to live somewhere (a button nested in the row's own button, or a footer a scroll
357
+ away from the sentence it edits), and every repair from there is a repair to the arrangement,
358
+ not to the surface.
345
359
 
346
- **Except when the value is EMPTY, and that half matters more.** There is nothing to duplicate,
347
- and a surface fed by automations and extractions receives entries with no words in them
348
- routinely a recording lands, the row says it has not been written up, and the empty field is
349
- the invitation to write it. Withhold it there and the feed can RECEIVE an entry it gives the
350
- reader no way to finish. **An empty value shows its field; a filled one waits to be asked.**
360
+ **Render it once, and the question dissolves.** Text inside a press target cannot be edited where
361
+ it sits, so a value a person WROTE does not belong in a row's label: the label is for derived,
362
+ read-only text ("Stage changed to Won"). Move the prose out and it is an ordinary resident field
363
+ with no mode, no verb, and one copy `tpl_record`'s activity feed is the worked example, and the
364
+ byline above it carries the derived facts that used to fight it for the row.
365
+
366
+ So: if a resident editor would duplicate a value, **delete the duplicate**. Make the editor a mode
367
+ only when the second appearance is genuinely load-bearing and cannot move.
351
368
 
352
369
  Two things follow that are easy to get wrong in the other direction:
353
370
 
354
- - **Name the verb for the words, not the row.** "Edit", not "Edit entry" — on a feed the entry
355
- is a record of something that happened, and only the prose a PERSON wrote is editable at all.
356
- Derived values (a header's participants), verbatim ones (a transcript), artifacts (media,
357
- files) and a model's output (a summary you re-run it, you do not hand-edit it) are all
358
- read-only, and a verb naming the whole entry claims otherwise.
359
- - **One row in edit mode at a time.** Two half-finished sentences on one feed leave the reader
360
- unable to say which one the next keystroke lands in.
371
+ - **Only the prose a PERSON wrote is editable.** Derived values (a header's participants),
372
+ verbatim ones (a transcript), artifacts (media, files) and a model's output (a summary you
373
+ re-run it, you do not hand-edit it) are all read-only. If a surface does end up with an edit
374
+ verb, name it for the words rather than the row: "Edit", not "Edit entry", which claims the
375
+ whole entry is yours to rewrite.
376
+ - **An EMPTY value still shows its field, and shows it FRAMED.** A surface fed by automations
377
+ receives entries with no words in them routinely — a recording lands and nobody has written it
378
+ up — so withholding the field lets a feed RECEIVE an entry it gives the reader no way to
379
+ finish. Empty is also the state where `bare` fails: grey placeholder text on an invisible box
380
+ does not read as somewhere you can type. Follow the value — `variant={value ? "bare" : "framed"}`
381
+ — so a written-up entry reads as prose and an empty one is recognisable as an input from across
382
+ the page. A button that reveals a field is the wrong trade: `InlineTextInput` has no
383
+ `autoFocus` (permanently mounted, every field on a record would fight for focus on load), so
384
+ the revealed field cannot take the caret and the reader pays a second click for less clarity.
361
385
 
362
386
  ## Fieldset form — fields lay out on a RESPONSIVE two-column grid
363
387
 
@@ -458,8 +482,7 @@ closes the list as its add-affordance.
458
482
 
459
483
  **`connected` decides which shape you are building.** ON for one record walking positions — the
460
484
  line asserts sequence, which is what a desk handoff IS. OFF for N items ticked in any order, where
461
- a line claims an order the work does not have. It is the same compound either way; they were two
462
- families until the anatomy turned out to be identical.
485
+ a line claims an order the work does not have. It is the same compound either way.
463
486
 
464
487
  **A GROUP is a name and nothing else.** No control: a phase owns no completion of its own, so a
465
488
  ring there reports without responding — and one identical to the pressable rings beneath it
@@ -628,13 +651,6 @@ A record field whose value is documents. `<DetailRow label="Scan"><InlineFiles
628
651
  — a `FileRows` list (press a row to preview it full-screen, ⋯ to remove) with one CTA under it,
629
652
  sitting on the inline-control grid via `InlineSlot`.
630
653
 
631
- It exists because every other field type shipped BOTH halves — `Select`/`InlineSelect`,
632
- `TextInputField`/`InlineTextInput` — and files shipped only the section-scale one. So a record
633
- field holding documents got hand-rolled per app as `FileRows` + `pickFiles` + a `Button`, and
634
- each copy re-derived the same decisions. The one they missed is `InlineSlot`: raw children in a
635
- value column start at the cell edge and 20px tall, beside editors that are 40px with a 9px text
636
- inset, so the column acquires two left edges and two baselines and stops reading as a column.
637
-
638
654
  **Cardinality is a property of the FIELD, and it changes the verb.**
639
655
 
640
656
  | `multiple` | picker | CTA once something is attached | the host's `onAdd` must |