@lotics/ui 45.10.0 → 45.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,11 +14,9 @@ file). Every component here is in the [catalog](./catalog.md) inventory.
14
14
  The AI surfaces share ONE law: the agent never commits **invisibly**. The agent owns judgment
15
15
  (recognition, estimation, intent→parameters); the app owns geometry, math, and the write. How
16
16
  the human stays in charge follows **who supplied the values**, not whether a before-state
17
- exists. A diff is one way to show a person what a machine decided; it is not the only reason
18
- they need to see it. Values a HUMAN typed need no gate — they already read them, on the way in.
17
+ exists. Values a HUMAN typed need no gate they already read them, on the way in.
19
18
  Values a MACHINE read off a document need one, and a create is not exempt just because there is
20
- nothing to compare against: a receipt saying "Created 3 cases" is the first moment the operator
21
- could have caught a misread reference, and by then it is in the data.
19
+ nothing to compare against.
22
20
 
23
21
  - **Modifying an existing record** — a diff exists, and a wrong value can clobber a right one →
24
22
  review BEFORE apply: `DiffValue` + `DiffMark` + `useChangeSet`, composed into the screen; nothing auto-applies.
@@ -26,20 +24,18 @@ could have caught a misread reference, and by then it is in the data.
26
24
  so nothing to diff, but the values are still proposed rather than typed → **a full EDITABLE
27
25
  preview of every record before the write**, not a summary of it. Lay the records out as they
28
26
  will be stored — every field, editable in place, nothing behind a "3 fields" count — and
29
- commit once. There is no diff to mark, so the whole record is the review; `DiffMark` still
30
- earns its place to say which records are new versus matched onto something existing.
27
+ commit once. `DiffMark` still earns its place to say which records are new versus matched onto something existing.
31
28
  - **Creating new records a human TYPED** (the manual create form) — they read every value on
32
29
  the way in → **apply directly, then end on the RECEIPT**
33
- (`@lotics/ui/result_header`) — the receipt states the outcome and ROUTES; it is never a
34
- record editor. Run DETERMINISTIC checks over what the save STORED (a checksum, a count
30
+ (`@lotics/ui/result_header`) — the receipt states the outcome and ROUTES. Run DETERMINISTIC
31
+ checks over what the save STORED (a checksum, a count
35
32
  reconciliation — binary math, never a fabricated score). ONE record: `ResultHeader` (tone,
36
33
  outcome title) → the RECEIPT LINES — a `DetailTable` of spread `DetailRow`s (who, how
37
34
  much, how many, the schedule) — → **`Confidence`**: the level WITH its basis as
38
35
  one block, always (the happy path says what was checked; the flagged path NAMES the exact
39
- failing values — the user knows what to verify BEFORE opening). Confidence never floats
36
+ failing values). Confidence never floats
40
37
  alone as a bare meter on a result surface. NOTHING in the receipt edits: the RECORD is the edit surface (its
41
- flagged cells carry the same amber marks), one press away correction uses the record's
42
- ordinary verbs. SEVERAL records: a compact attention-first RESULT REGISTER (`ListItem` per
38
+ flagged cells carry the same amber marks), one press away. SEVERAL records: a compact attention-first RESULT REGISTER (`ListItem` per
43
39
  record — tone mark, outcome title, key figures, the needs-checking count) whose rows
44
40
  press STRAIGHT THROUGH to the record — no intermediate screen. Never tabs (they cap out),
45
41
  never stacked collapsed cards, never a field wall duplicating the record page. A
@@ -51,9 +47,7 @@ could have caught a misread reference, and by then it is in the data.
51
47
  The closing footer button is dismissal (secondary "Done"), never primary. Correction beyond
52
48
  the receipt uses the record's ORDINARY verbs — open it, edit or delete (make the delete
53
49
  workflow CASCADE the record's children, so deletion is always safe) — never a special undo
54
- affordance duplicating the delete verb. A review GATE on a create degenerates into
55
- proofreading a wall of cards the user blind-confirms — and a human cannot eyeball-verify a
56
- reference number anyway; deterministic post-save checks catch what proofreading cannot.
50
+ affordance duplicating the delete verb.
57
51
 
58
52
  Either way the deterministic app applies the write. Compose the surfaces as a loop, and reach
59
53
  for the right one by job.
@@ -71,14 +65,12 @@ Three shapes:
71
65
  operator acts on what was found) → **in-app agent** + **`Finding`**. Still bounded and
72
66
  prompt-free (an optional instructions brief at most): the brief is fixed, the output is
73
67
  structured display-only findings read IN the record's context, and there is no conversation
74
- to have — the loop closes when the operator acts in the app. Chat would add prompting and
75
- tear the findings away from the record they judge.
68
+ to have — the loop closes when the operator acts in the app.
76
69
  3. **A file or an open-ended answer** (edit this document, draft from context, explain) →
77
70
  **hand off to the chat agent** (`askAi` in `@lotics/app-sdk` — see
78
71
  [the SDK's fields-vs-file razor](../../app-sdk/docs/ai.md)). The loop is multi-turn with
79
72
  no output schema, judged by looking — and the chat harness already owns it: preview beside
80
- the thread, version chains, branching, session memory. An in-app "edit chat" would
81
- re-implement all of that inside every app.
73
+ the thread, version chains, branching, session memory.
82
74
 
83
75
  The Document desk's Use-AI fork IS this table as UI: Extract data (1), Cross-check (2),
84
76
  Edit with AI (3) — one entry point, three outcome shapes
@@ -124,37 +116,32 @@ A CTA that starts an agent run (or any paid/mutating action) must make the PRESS
124
116
  transition — never leave a live button waiting on a network round-trip:
125
117
 
126
118
  - **Same-tick feedback.** On press, synchronously close the dialog / swap to the run surface
127
- (`AgentRun` with its streaming state) so there is nothing left to click. A button that stays
128
- pressable while an attach/start request round-trips invites double-fires.
119
+ (`AgentRun` with its streaming state) so there is nothing left to click.
129
120
  - **Single-flight in app code too.** Guard the handler with a ref (state alone races the
130
121
  re-render): first press wins, later presses no-op until the flow settles.
131
122
  - **The SDK backstops runs, not workflows.** `useAgentRun().run()` is single-flight by default
132
123
  (a duplicate call joins the in-flight run; `replace: true` is the deliberate restart) — but
133
- `useWorkflow` calls are NOT deduped (rapid distinct calls are legitimate), so a workflow CTA
124
+ `useWorkflow` calls are NOT deduped, so a workflow CTA
134
125
  must disable itself (`loading`) while pending or a double-click writes twice.
135
126
 
136
127
  ## Stopping — the exit is part of the trigger
137
128
 
138
129
  The press being irreversible is a property of the BUTTON, not of the run. Every streaming surface
139
- owes the operator a way out, because the reason to leave is usually visible in the first few
140
- steps: it read the wrong document, it is grouping the wrong way, the brief was wrong. Without an
141
- exit they pay for a result they already know they will discard.
130
+ owes the operator a way out.
142
131
 
143
132
  - **`cancel`, never `abort`.** `useAgentRun` exposes both and they are not synonyms. `abort` stops
144
133
  LISTENING — the run keeps executing server-side and lands in the session history. `cancel` stops
145
134
  the run itself. A user-facing Stop wires to `cancel`; `abort` is for unmount.
146
- - **Closing a dialog must CANCEL.** This is the one that leaks silently. The hook aborts on
147
- unmount by design — right for a page you navigate away from and will come back to, wrong for a
148
- dialog just dismissed, where the result is abandoned and every remaining token is spent on
149
- nobody. Unmount is not cancellation; the close handler calls `cancel` itself.
135
+ - **Closing a dialog must CANCEL.** The hook aborts on unmount by design right for a page you
136
+ navigate away from and will come back to, wrong for a dialog just dismissed, where the result is
137
+ abandoned. Unmount is not cancellation; the close handler calls `cancel` itself.
150
138
  - **Stop lands where the run STARTED**, not on a dead transcript — the intake, with the files, the
151
- brief and the answers still there, because the operator stopped in order to change one of them.
152
- It is the same destination the parked wizard's Cancel uses; only the verb differs, and it
153
- differs truthfully — you STOP something running, you CANCEL something waiting.
139
+ brief and the answers still there.
140
+ It is the same destination the parked wizard's Cancel uses; only the verb differs you STOP
141
+ something running, you CANCEL something waiting.
154
142
  - **The control is PINNED, in the footer.** A feed that follows its newest part scrolls a control
155
- placed inside it away from the reader exactly while the run is doing the thing they want to
156
- stop. `AgentRunActions` renders Stop for you; a surface composing its own footers states it
157
- directly ([`tpl_item_list`](../examples/tpl_item_list.tsx) and
143
+ placed inside it away from the reader. `AgentRunActions` renders Stop for you; a surface
144
+ composing its own footers states it directly ([`tpl_item_list`](../examples/tpl_item_list.tsx) and
158
145
  [`tpl_record`](../examples/tpl_record.tsx) respectively).
159
146
 
160
147
  ## Show the work — `AgentRun`
@@ -170,12 +157,10 @@ bespoke transcript type. It renders three part kinds:
170
157
  | `reasoning` | Thinking — COLLAPSED, a muted "Thinking" row; press to reveal the Markdown |
171
158
  | a tool part — `dynamic-tool` (app agents) or `tool-<name>` (chat) | An activity row with a status dot |
172
159
 
173
- Parts appear in the order they happened a real run is think text a burst of calls more
174
- text, NOT all text on top of a flat step list. Content parts with no feed representation
160
+ Parts appear in the order they happened, NOT all text on top of a flat step list. Content parts with no feed representation
175
161
  (`source-*`, `file`, `data-*`, `step-start`, `custom`) are skipped. Transparent work, NEVER a bare spinner.
176
162
 
177
- **Progressive disclosure.** The feed shows the label + state; the detail is revealed on demand. A
178
- tool part's `input`/`output` are hidden in the row and **expand in place** on press — auto-built
163
+ **Progressive disclosure.** A tool part's `input`/`output` are hidden in the row and **expand in place** on press — auto-built
179
164
  Input / Output `JsonPanel`s roll out under the row (the same disclosure pattern as "Thinking",
180
165
  never a popover). A tool in an `output-error` state flows like any other call — the dot goes
181
166
  amber and its `errorText` shows in the expanded Error panel. To render a tool's OUTPUT yourself
@@ -192,33 +177,30 @@ PUT and rolls the calls out BELOW it on press. The run ALWAYS ends on the agent'
192
177
  no global terminal node.
193
178
 
194
179
  **Tool labels.** A tool part carries the RAW tool name (`update_records` from `tool-update_records`,
195
- or a `dynamic-tool`'s `toolName`), resolved through the active locale's **`agentRun.tools`** map
196
- so a `vi` provider renders Vietnamese tool rows with no per-app wiring, the same way the rest of the
197
- feed's chrome already resolved. An optional **`labelForCall`** override still wins, for phrasing a
198
- call by what it TARGETS rather than by which tool ran. It gets the whole call — `{ toolName, input, state }` (`AgentToolCall`) so a caller can phrase by what the
199
- call targets or by whether it's awaiting; return `undefined` to fall back. Unknown names fall back
180
+ or a `dynamic-tool`'s `toolName`), resolved through the active locale's **`agentRun.tools`** map.
181
+ An optional **`labelForCall`** override still wins, for phrasing a call by what it TARGETS rather
182
+ than by which tool ran. It gets the whole call — `{ toolName, input, state }` (`AgentToolCall`);
183
+ return `undefined` to fall back. Unknown names fall back
200
184
  to a prettified form. `stepsLabel` localizes the "{n} steps" suffix. The tool `state` folds ai's
201
185
  7-state lifecycle to **running / awaiting / done / error**: `approval-requested` → `awaiting` (the
202
- call is parked on a human decision — an amber dot + an "awaiting" annotation, and it NEVER pulses,
203
- because it isn't working); `approval-responded` → running; `output-denied` → error carrying the
186
+ call is parked on a human decision — an amber dot + an "awaiting" annotation, and it NEVER
187
+ pulses); `approval-responded` → running; `output-denied` → error carrying the
204
188
  denial reason. The whole-run `state` prop (`"streaming" | "done" | "error"`) defaults to `streaming`
205
189
  while any tool is running, else `done` (an awaiting call is not running, so it settles).
206
190
 
207
191
  **Errors — two kinds, both surfaced, neither shouting.** An INTERMEDIARY tool failure rides in
208
192
  that tool's `output-error` part and flows like a normal call — the amber dot marks it (a settled
209
- group's header goes amber too), the reason waits in the expanded Error panel; real runs retry and
210
- move on, so the feed doesn't dramatize it. A BREAKING error that terminates the whole run lives
193
+ group's header goes amber too), the reason waits in the expanded Error panel. A BREAKING error that terminates the whole run lives
211
194
  OUTSIDE `parts` (chat persists it in the message's `errors`; `useAgentRun().error` carries it) —
212
195
  pass it as the `error` prop and it terminates the feed with an error `Callout` — a contained,
213
- tinted panel rather than another step row, because the run did not advance, it stopped, and it
214
- carries `role="alert"` so a screen reader announces it. Pass **`onRetry`** alongside it and a Retry
196
+ tinted panel rather than another step row, and it carries `role="alert"` so a screen reader announces it. Pass **`onRetry`** alongside it and a Retry
215
197
  action (a secondary `Button`) sits INSIDE that panel so the operator can re-fire the run; omit it
216
198
  and the panel carries the message alone.
217
199
 
218
200
  **In a bounded container, wrap it in `FollowScroll`.** An `AgentRun` streaming inside a dialog,
219
201
  drawer, or fixed-height panel grows BELOW the fold — a plain scroll container doesn't follow. Wrap
220
202
  the feed: `<FollowScroll style={{ maxHeight: … }}><AgentRun … /></FollowScroll>` — the same
221
- inverted-list mechanism as a chat message list, packaged as a wrapper: the follow happens at
203
+ inverted-list mechanism as a chat message list: the follow happens at
222
204
  LAYOUT level (each growth paints already pinned — no scroll-after-paint flash), scrolling up
223
205
  releases it, and the feed stays at the newest content when the run settles (`AgentProgress`'s
224
206
  expanded panel does this). When settling REPLACES the feed with different content (a results
@@ -233,7 +215,7 @@ for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={
233
215
  install; the imports are type-only (purity-enforced), so no `ai` runtime ever enters a bundle.
234
216
  **A run with no parts yet renders the breathing "Starting…" row** (streaming state only) — the
235
217
  live dot plus a label that pulses, covering the gap between the CTA press and the first streamed
236
- part (upload + run creation + first token); a silent feed reads as broken. **The law: pass
218
+ part (upload + run creation + first token). **The law: pass
237
219
  `state="streaming"` from the moment the paid CTA fires — uploads included — and render
238
220
  `AgentRun` even with zero parts. Never hand-roll a text/skeleton placeholder in front of the
239
221
  feed**; the starting row IS the placeholder, consistent and localized.
@@ -275,17 +257,15 @@ into it while running and reveals again when done.
275
257
  ## Review before apply — TWO atoms and a hook, composed into your screen
276
258
 
277
259
  **When to reach for it — the one law's boundary.** A review gates values the MACHINE
278
- supplied, whether or not they replace anything. Modifying an existing record is the obvious
279
- case and takes the full diff. Creating records FROM A DOCUMENT takes the same gate in a
280
- different shape no diff, because nothing is being replaced, but the whole record laid out
281
- and editable before the write (`examples/tpl_item_list.tsx`). Only values a HUMAN TYPED skip
282
- it: they read them on the way in, so the manual form saves direct and ends on the receipt.
260
+ supplied, whether or not they replace anything. Modifying an existing record takes the full
261
+ diff. Creating records FROM A DOCUMENT takes the same gate in a different shape — no diff, but
262
+ the whole record laid out and editable before the write (`examples/tpl_item_list.tsx`). Only
263
+ values a HUMAN TYPED skip it: the manual form saves direct and ends on the receipt.
283
264
 
284
265
  **There is no review CONTAINER, deliberately.** A review is not a place you go; it is something
285
266
  that happens to values already on screen. So the diff renders wherever the value renders — a
286
267
  table cell, a `DetailRow`, a total, a chip, a sentence — and inherits that container's width,
287
- alignment and rhythm. A compound family with a provider at its root can only produce the one
288
- shape its root draws, and every surface needing a diff somewhere else rebuilds it by hand.
268
+ alignment and rhythm.
289
269
 
290
270
  ### The three pieces
291
271
 
@@ -294,39 +274,28 @@ shape its root draws, and every surface needing a diff somewhere else rebuilds i
294
274
  dense rows; `align`/`tabular` for money so the struck value and its replacement line up;
295
275
  `tone` when the change itself is the bad news. **The three ops are the same component:**
296
276
  an ADD omits `before`, a REMOVAL omits `after` (the struck value IS the change), an UPDATE
297
- passes both, and **equal values collapse to one** — agreement is not a change, and an invoice
298
- confirming the amount that was claimed is the GOOD case, not a diff of a value against
299
- itself. **Both values stay on screen** on a real change (the old one is the only evidence the
300
- correction is the right SIZE), and **`delta`** prints the movement itself — "+250 pcs",
301
- "−118.519 ₫ VAT" — so nobody subtracts in their head.
277
+ passes both, and **equal values collapse to one** — agreement is not a change.
278
+ **Both values stay on screen** on a real change, and **`delta`** prints the movement itself — "+250 pcs",
279
+ "−118.519 VAT".
302
280
 
303
281
  **The struck value is muted, never red.** An old value is not an ERROR, and red is this
304
- system's error colour every routine update would read as a problem, and a table of eight
305
- changed rows would carry sixteen coloured figures. Red-against-green is also the one pair a
306
- colour-blind reader cannot separate, and it is the distinction a review turns on. The
307
- strikethrough carries "going away" with no colour at all; weight, tone and (stacked) size
282
+ system's error colour. Red-against-green is also the one pair a colour-blind reader cannot
283
+ separate. The strikethrough carries "going away" with no colour at all; weight, tone and (stacked) size
308
284
  carry the rest.
309
285
  - **`DiffMark`** (`@lotics/ui/diff_mark`) — what happened to the row holding the value, for the
310
286
  cases no single value carries: `added` / `changed` / `removed` / `unchanged`. Glyph plus an
311
- accessible word, never colour alone. `unchanged` draws a quiet dot, because most rows in a
312
- real review are untouched.
287
+ accessible word, never colour alone.
313
288
 
314
289
  **ONE treatment, every surface: a filled 22px disc.** The form, the line table, the files
315
- list and the thumbnail grid all draw the same mark, because a reader parses shape before
316
- meaning and a form marked with bare glyphs beside a table marked with filled shapes reads as
317
- two systems saying the same thing. `unchanged` renders as an empty disc-sized HOLE: an
318
- operator opening a job of twelve fee lines to see where a dropped invoice landed should find
319
- the one filled shape in a column of blanks without reading anything or separating two hues.
320
- The hole keeps the column's width and announces nothing.
290
+ list and the thumbnail grid all draw the same mark. `unchanged` renders as an empty
291
+ disc-sized HOLE: it keeps the column's width and announces nothing.
321
292
 
322
293
  **Derive the kind from the same before/after the row's `DiffValue` carries — never from a
323
294
  boolean "did this differ".** A field with no prior value (`before` empty or absent) that a run
324
295
  now proposes a value for is an ADD, not a CHANGE, and a local review row that only asks
325
296
  "does `draft !== before`" answers yes for both and always renders `changed` — the amber pencil
326
- on a value that was never there to begin with, which reads as the AI having second-guessed a
327
- figure it actually just filled in for the first time. This bites hardest on the record that
328
- looks most ordinary: the first proposal any fresh record ever gets has an empty `before` on
329
- every field, so every mark on that one run is wrong until the kind is derived per field
297
+ on a value that was never there to begin with. The first proposal any fresh record ever gets
298
+ has an empty `before` on every field, so every mark on that one run is wrong until the kind is derived per field
330
299
  (`before === after` → no mark; `before === ""` → `added`; otherwise → `changed`).
331
300
  - **`useChangeSet(ids, { initial })`** (`@lotics/ui/use_change_set`) — the bookkeeping:
332
301
  `status`/`accept`/`reject`/`undo`/`acceptAll`/`reset`, `accepted`/`rejected`/`pending`,
@@ -339,33 +308,28 @@ shape its root draws, and every surface needing a diff somewhere else rebuilds i
339
308
  - **A record's fields** — a `DetailTable` of rows, each a label plus a `DiffValue`, with
340
309
  Keep/Drop on the row and a collapsed state once decided. That row is ~20 lines and it belongs
341
310
  to your screen: `examples/tpl_record.tsx` and `dev/pages/ai.tsx` each write their own and they
342
- read differently, which is the system working.
311
+ read differently.
343
312
  - **A record SET** — a `Table`, one row per proposed record, `DiffMark` leading and `DiffValue`
344
- inside the figure cells. A set of ledger lines reads like the ledger it is about.
313
+ inside the figure cells.
345
314
  - **A conflict** — two documents disagree, so the value is the read-only OUTCOME of a pick:
346
315
  a `DiffValue` showing the placeholder until resolved, over `CardSelectItem` candidates (each
347
316
  naming its source document) and a type-another-value option. **Never pre-select**, and gate
348
- Keep until one is chosen — a pre-selected candidate is a decision the system made and
349
- attributed to the person.
317
+ Keep until one is chosen.
350
318
  - **An ATTACHMENT** — a dropped document's proposal is often not a value at all: it is WHICH
351
- LINE the file belongs to. That decision is as wrong-able as a misread figure and gets the
352
- same treatment — the file rendered on the row it lands on, inside a `DiffValue` whose sides
319
+ LINE the file belongs to. That decision gets the same treatment the file rendered on the row it lands on, inside a `DiffValue` whose sides
353
320
  are NODES (a `FileBadge` plus its name). A file replacing one already filed shows both, and
354
321
  the superseded one is struck by a DRAWN rule: `line-through` set on a `Text` does not cross
355
- a `View` child, so without it the old scan renders at full strength beside its replacement
356
- with nothing saying which is going away. Worked in `examples/tpl_record.tsx`.
322
+ a `View` child. Worked in `examples/tpl_record.tsx`.
357
323
  - **A DOCUMENT SET** — a files section is not a field. It is a set of objects with identity,
358
324
  so what changes is MEMBERSHIP plus per-document properties, and the kinds do not share a
359
325
  treatment. **Added** and **removed** are membership: the row's existence and its mark are
360
326
  the whole change. **Superseded** is one slot whose bytes changed — the row names the new
361
327
  file and its second line names the one it displaces. **Reclassified** is the one a review
362
328
  surface gets wrong: the bytes are untouched and only the FILING moved, so the `DiffValue`
363
- belongs on the property a mark with no visible diff reads as though the file itself
364
- changed. `FileRow` takes the mark in `leading` (so the names still form a column) and a node
329
+ belongs on the property. `FileRow` takes the mark in `leading` (so the names still form a column) and a node
365
330
  `meta` (so the second line can carry a diff).
366
331
 
367
- **In a GRID** the same set reads differently again, and that is a property of the shape
368
- rather than a gap: a grid is scanned whole, so it carries membership (this is arriving, that
332
+ **In a GRID** the same set reads differently again: a grid is scanned whole, so it carries membership (this is arriving, that
369
333
  is leaving — `FileThumbnailGrid diffs`, a corner mark plus a fade) at a glance and carries
370
334
  pairing not at all. Pick the surface for the change; do not shrink the change to fit a shape.
371
335
 
@@ -373,56 +337,45 @@ shape its root draws, and every surface needing a diff somewhere else rebuilds i
373
337
  a tile — the badge earns its space and the row is built around it. Where the file is one
374
338
  VALUE among several columns, render the filename as a value at the column's own text size:
375
339
  a badge shrunk to fit a table cell takes its label off the type scale entirely (5px, at the
376
- size a cell allows), and sizing it back up makes it set the row's height for a fact the
377
- extension already carries.
340
+ size a cell allows), and sizing it back up makes it set the row's height.
378
341
 
379
342
  **The strikethrough has a discriminator.** Strike the superseded file when it shares a CELL
380
343
  with its replacement, adjacent — a table's document column, where the pairing is what the
381
344
  cell means. Do NOT strike it on a files-section line, where the two sit on different rows:
382
- there the strike says "going away" without saying what replaces it, so a word names the
383
- relation and the chip only identifies the file. Both shapes are worked in `dev/pages/ai.tsx`.
345
+ a word names the relation and the chip only identifies the file. Both shapes are worked in `dev/pages/ai.tsx`.
384
346
  - **A record being CREATED from a document** — no before-state, so no diff: the whole record
385
347
  is the review. Lay every field out as it will be stored, editable in place, and mark only
386
348
  the fact that IS relational (matched onto something existing, or new alongside it). One
387
349
  commit named for the outcome. Worked in `examples/tpl_item_list.tsx`.
388
350
  - **Display-only** — a finding that informs but has nothing to decide simply is not in the
389
- change set. There is no "display-only" mode to remember.
351
+ change set.
390
352
 
391
353
  ### The laws that survive the container
392
354
 
393
355
  - **Nothing auto-applies.** The commit is one explicit press, named for its outcome
394
356
  ("Update record (3)", "Add 4 tasks") — never "Apply" alone, never "OK".
395
357
  - **Editing IS the review.** A value the operator can correct in place beats accept-or-reject
396
- on a value they cannot touch: a wrong proposal you can fix costs a keystroke, one you can
397
- only reject costs the whole re-entry.
358
+ on a value they cannot touch.
398
359
  - **Gate the commit on what will actually be written**, not on the proposal count — disable at
399
360
  zero kept.
400
361
  - **A decided row's Keep/Drop must be reachable from BOTH directions when `initial: "accepted"`.**
401
362
  `undo` returns a row to its DEFAULT, not to `"pending"` — so on the common default
402
- (`initial: "accepted"`, the operator drops the exceptions rather than re-approving every
403
- identical field) a row starts already decided and Undo is a no-op with nothing to reach:
404
- there is no path back to a `pending`-style Keep/Drop pair, and the row is stuck accepted
405
- forever. The collapsed-to-Undo anatomy (value + one "Undo" button, swapping to Keep/Drop only
363
+ (`initial: "accepted"`) a row starts already decided and Undo is a no-op with nothing to
364
+ reach. The collapsed-to-Undo anatomy (value + one "Undo" button, swapping to Keep/Drop only
406
365
  while `pending`) is correct ONLY under `initial: "pending"`, where every row genuinely starts
407
366
  undecided. Under `initial: "accepted"`, render Keep/Drop as an always-visible TOGGLE instead —
408
- both buttons present, the one matching the row's current status disabled, the other live
409
- so either direction is one press away regardless of where the row started.
367
+ both buttons present, the one matching the row's current status disabled, the other live.
410
368
  - **The mark rides the LABEL, and the value sits in an `InlineSlot`.** Both are alignment
411
369
  before they are semantics. A mark beside the VALUE indents every row it marks by its own
412
370
  width plus a gap, so an unmarked row needs a spacer of exactly the glyph's width to keep up —
413
- and they drift (17 against 13 shipped that way). The label column is a fixed width with one
371
+ and they drift. The label column is a fixed width with one
414
372
  left edge, so a mark placed there aligns down the page for free. Give the slot a fixed HEIGHT
415
373
  as well as width: it is an inline-flex box inside `DetailRow`'s label `Text`, so an empty one
416
374
  baselines differently from one holding a glyph. On the value side, a form mixes 40px editors
417
- with 20px flat diffs; without the slot the column has two left edges and two baselines, and
418
- the label agrees with one kind of row and not the other.
375
+ with 20px flat diffs; without the slot the column has two left edges and two baselines.
419
376
  - **The reason goes above the verdict.** A `why` passed as `DetailRow`'s `description` renders
420
- under the whole cell — which is under the Keep/Drop buttons. A justification printed after
421
- the controls that act on it is one nobody reads. Render it inside the value block.
422
- - **Show the WHOLE thing, not just the parts that moved.** The operator's question is rarely
423
- "what did it find" — it is "is this right, and where does it sit". A surface showing only
424
- the changed rows answers the first and leaves the second to a second screen. Render the
425
- record entire and let `DiffValue` collapse what agrees (equal values render as one plain
377
+ under the whole cell — which is under the Keep/Drop buttons. Render it inside the value block.
378
+ - **Show the WHOLE thing, not just the parts that moved.** Render the record entire and let `DiffValue` collapse what agrees (equal values render as one plain
426
379
  value, so the same component tree serves a record with one change and a record with twenty).
427
380
  Emphasis, not omission, is what makes the changes findable.
428
381
  - **The fixture must not flatter.** A review demo whose every row carries a change proves
@@ -435,8 +388,7 @@ shape its root draws, and every surface needing a diff somewhere else rebuilds i
435
388
  `Clarify` (`@lotics/ui/clarify`): when the agent is unsure, it asks a question with quick-reply
436
389
  options and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run: `question` (the
437
390
  prompt, rendered as-is — no eyebrow chrome), `options` (`{ label, value, description }` — every
438
- answer carries a REQUIRED one-line description so the human picks without re-deriving it from the
439
- question; a `ChoiceList`), `onAnswer(value)` resumes the run, the controlled `answer` keeps the pick
391
+ answer carries a REQUIRED one-line description; a `ChoiceList`), `onAnswer(value)` resumes the run, the controlled `answer` keeps the pick
440
392
  switchable until committed. **`allowCustom`** adds an always-visible, borderless, auto-growing multiline field below the
441
393
  options — typing it IS the selection; its text becomes the value (any value not matching an option
442
394
  reads as the custom answer).
@@ -446,7 +398,7 @@ Back / Next / Cancel / Submit (Next/Submit primary), the step position an eyebro
446
398
  question — no progress bar, and no eyebrow at all for a single question. Each step is a `Clarify`; advance only once the
447
399
  current question is answered; `onSubmit` gives one `{ value, custom }` per question (aligned by index,
448
400
  `custom` true when the answer came from "Other…"), `onCancel` dismisses. All navigation chrome is
449
- locale-wired (the `clarify` slice). The multi-question form of the ask-back. **Placement follows
401
+ locale-wired (the `clarify` slice). **Placement follows
450
402
  the dialog grammar**: standalone (riding a parked run's stream, where no footer exists) the wizard
451
403
  renders its own inline action row; as a DIALOG phase, wrap the `Dialog` with `ClarifyWizardScope`
452
404
  and put `ClarifyWizardActions` in the `DialogFooter` — the wizard suppresses the inline row and
@@ -474,29 +426,25 @@ const run = useAgentRun("intake"); // @lotics/app-sdk
474
426
  after a question it is the only thing holding how the answered leg ended — your own `run()` promise
475
427
  already resolved, with `parked`, and never resolves again. Give `onLanding` the same handler you
476
428
  give `run()`: it is typed from your run (no cast), and it re-fires if answering leads to a follow-up
477
- ask. Omit it and an answered run completes with the host none the wiser — the transcript reads
478
- "done", your review step never opens, and the paid read is discarded. A host that instead reads its
479
- result off `status`/`output` does not need it, but is then re-deriving from a render what it was
480
- handed directly.
429
+ ask. Omit it and an answered run completes with the host none the wiser — your review step never
430
+ opens, and the paid read is discarded. A host that instead reads its result off `status`/`output`
431
+ does not need it.
481
432
 
482
433
  **The parked question REPLACES the feed — that is the contract, not a style.** The run is blocked
483
434
  on the answer, so the question is the only thing to act on; it gets the dialog's own scroller and
484
- its actions sit outside it. Stacked under the transcript in an unscrollable box the arrangement
485
- every app reached for first — a multi-question ask runs past the dialog's height and clips its own
486
- Submit, leaving a live `awaiting_input` run readable and unanswerable until it expires. The pane
435
+ its actions sit outside it. Stacked under the transcript in an unscrollable box, a multi-question
436
+ ask runs past the dialog's height and clips its own Submit, leaving a live `awaiting_input` run readable and unanswerable until it expires. The pane
487
437
  also swaps `FollowScroll` for `DialogScrollArea` on the park, so the question opens at the top
488
438
  rather than wherever the feed was scrolled to.
489
439
 
490
440
  **Mount `AgentRunActions` wherever the run drives the surface — never gated on one phase.** It
491
441
  holds the wizard's verbs while a question is up, **Stop** while the run streams, and nothing once
492
- it settles; the RUN decides, which is the whole point of handing it the run. A host that names a
493
- single phase out here duplicates that decision and gets it wrong the moment the bar learns a new
494
- one.
442
+ it settles; the RUN decides. A host that names a single phase out here duplicates that decision.
495
443
 
496
444
  **`run` is a SHAPE, not an import** (`AgentRunLike`: `status`, `parts`, `pendingChoice`,
497
445
  `answerChoice`, `cancel`, `error`; `AgentRunQuestion` names what `pendingChoice` carries, so a template or
498
446
  test declares one directly — an option is a label + description and the answer's value IS the
499
- label, which is the `ask_user_choice` wire shape, so nothing maps between two question types). `useAgentRun()` satisfies it structurally — `@lotics/ui` never depends on
447
+ label, which is the `ask_user_choice` wire shape). `useAgentRun()` satisfies it structurally — `@lotics/ui` never depends on
500
448
  `@lotics/app-sdk` — which also means a template or test can hand it a plain object and exercise the
501
449
  whole pane with no backend ([`tpl_item_list`](../examples/tpl_item_list.tsx) does exactly that).
502
450
 
@@ -533,28 +481,24 @@ list renders nothing.
533
481
  reader is deciding about it. The harder surface is the one they open a week later: a stored
534
482
  record whose fields were written by a person, by a chat agent, by an extraction off an upload,
535
483
  and by an automation that fired when a file landed — and which carries no memory of which was
536
- which. Increasingly that is the normal shape, because the app's own form is the LEAST used way
537
- in. Three rules, each of which a screen built for a single writer gets wrong for free.
484
+ which. Three rules, each of which a screen built for a single writer gets wrong for free.
538
485
 
539
486
  **An unwritten value must not render like a written one.** A row assembled by an automation
540
487
  arrives with its enums set and its prose empty, and the natural repair — composing a label out
541
488
  of the enums that ARE set — produces a real-looking sentence in body ink. "Nobody has written
542
- this up" and "this is what happened" then look identical, which is the one distinction the
543
- reader most needs. Say the value is missing, in the ink an unfilled field already uses
489
+ this up" and "this is what happened" then look identical. Say the value is missing, in the ink an unfilled field already uses
544
490
  (`Timeline`'s `placeholder`; the same idea wherever a component takes a `label: string` and
545
491
  therefore cannot express absence — that type is the trap, so give it a way to say so).
546
492
 
547
- **A machine's prose and a person's must not share a treatment.** They are different claims and
548
- they earn different trust, so rendering them alike teaches the reader to skim both at one
549
- speed. Carry the difference with SHAPE, not weight: name the author and the evidence on the
493
+ **A machine's prose and a person's must not share a treatment.** Carry the difference with SHAPE, not weight: name the author and the evidence on the
550
494
  line above ("Written by AI from the recording" — the source alone is not authorship), and drop
551
495
  the machine's text into a recessed `Inset`, which says it was not written on this page. Leave
552
496
  the person's conclusion on the page's own ground; that contrast IS the signal.
553
497
 
554
498
  **Model prose arrives with its own type ladder — take it away at the door.** A model asked for
555
499
  a summary emits `##` headings and heavy `**bold**` knowing nothing of what surrounds it, and
556
- document-scale markdown hands those an `h1` equal to your section heading so the
557
- least-trustworthy text on the screen ends up the loudest. Render every embedded value with
500
+ document-scale markdown hands those an `h1` equal to your section heading. Render every
501
+ embedded value with
558
502
  `<Markdown variant="embedded">`.
559
503
 
560
504
  And one that is not about treatment at all: **check that the read path projects every field the
@@ -568,22 +512,18 @@ Confidence IS a callout (`@lotics/ui/confidence`) — the ONLY form it takes: th
568
512
  meter + level phrase head the block, and the REQUIRED `children` carry the basis — what was
569
513
  checked and what came of it ("all 7 references pass their checksum" / "2 of 7 fail (X, Y) —
570
514
  verify against the source"). The box stays white with a hairline border at EVERY level — the
571
- meter is the only colored element (the level already lives in the meter and the phrase; a
572
- tinted fill would say it a third time). Pass `level` or a 0–1 `score` (≥0.8 high,
515
+ meter is the only colored element. Pass `level` or a 0–1 `score` (≥0.8 high,
573
516
  ≥0.5 medium, else low; `levelFromScore` exported). There is NO standalone meter: a level
574
- without its reasons cannot be acted on, so it cannot be rendered and where a compact meter
575
- used to decorate a ranked row or a proposal card, remove it (rank, badges, and severity
576
- already convey standing). Derive levels HONESTLY (deterministic checks: all pass → high, any
517
+ without its reasons cannot be acted on, so it cannot be rendered. Derive levels HONESTLY (deterministic checks: all pass → high, any
577
518
  fail → low) — never render a fabricated score.
578
519
 
579
520
  ## Findings — evidence, not writes
580
521
 
581
522
  `Finding` (`@lotics/ui/finding`) is one ranked insight from an AI check — a cross-check
582
523
  discrepancy, an audit observation, a briefing item — laid out HORIZONTALLY, in the order a reader
583
- needs it: a severity dot, the `title`, and the `delta` right-aligned on that same line, then one
584
- dim line of `readings` (`source value · source value`) beneath. Two lines carry all four things a
585
- reader wants, and scanning a stack gives severity down the left edge and magnitude down the
586
- right. `detail` is the CONSEQUENCE and is optional — a second sentence restating the title in
524
+ needs it: a severity dot, the `title`, and the `delta` right-aligned on that same line, then the
525
+ `readings` beneath ONE PER LINE, each a dim `source` plus its `value`, never joined by a
526
+ separator. `detail` is the CONSEQUENCE and is optional a second sentence restating the title in
587
527
  longer words costs a line and adds nothing.
588
528
 
589
529
  BOTH readings stay on screen and NEITHER is struck through or marked wrong: a finding reports
@@ -602,7 +542,7 @@ persisted check-status goes stale on the next edit). Stack several most-severe f
602
542
 
603
543
  The outputs accrue as a history the user can CLEAR ("New session"); the APP owns the evolving
604
544
  state, each run is a discrete task. (The agent may re-read the session for "make it a bit less",
605
- but it's a run LOG, not a conversation transcript — that's why it's not a chat.) The session key
545
+ but it's a run LOG, not a conversation transcript.) The session key
606
546
  and history reads are the SDK's: `sessionId` on each run, `useAgentRuns` for the history — see
607
547
  [the SDK doc](../../app-sdk/docs/ai.md).
608
548
 
@@ -684,13 +624,11 @@ kit does:
684
624
  (white centre — pulses only when live), `done` a filled accent dot + **white check**,
685
625
  `upcoming` a faint **grey** ring, the terminal `complete` a **blackish ring + black check**,
686
626
  `warning` **amber**. Default accent is neutral ink (`Stepper`'s `color` prop themes it);
687
- `AgentRun` keeps the neutral default — an active group's tail pulses (`current`), a settled
688
- group is a `done`/`warning` row, and there is no global terminal node (the run ends on text).
627
+ `AgentRun` keeps the neutral default.
689
628
  - **ChangeBand** — the removed value on the light **red** band with the `−` marker, the incoming
690
629
  value on the light **emerald** band with `+` (the GitHub-diff idiom, markers in one aligned
691
630
  column); everything else in a review stays neutral — a decided row reads a single emerald
692
631
  check, an add/remove record card wears the quiet tone wash (50 body, 100 header, 200 border).
693
- Colour marks the change, never the chrome.
694
632
  - **Finding severity** — the coloured dot badge (red / amber / zinc / emerald), most severe
695
633
  first.
696
634
 
@@ -698,5 +636,5 @@ Card chrome (borders, microlabels) stays neutral — colour marks the *state*, n
698
636
  The **composer keeps its icons**: `Composer` compact is a single-row pill with an optional
699
637
  circular attach `actionsButton` + a circular send button; expanded it adds a `FileThumbnail`
700
638
  attachment row (each `onRemove`-able) above the input; `AgentProgress` is the `WaveAvatar` pill.
701
- "No icons" was only ever about the review surfaces' sparkles/severity glyphs, not functional
639
+ The "no icons" rule covers the review surfaces' sparkles and severity glyphs, not functional
702
640
  affordances.