@lotics/ui 45.10.0 → 46.0.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.
@@ -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
@@ -186,39 +171,104 @@ Output panel; `undefined` keeps it. Input + error rendering are untouched.
186
171
  **Activity grouping.** Consecutive tool parts fold into ONE group. While the agent is mid-tools
187
172
  the tail group is a SINGLE pulsing row whose label swaps in place as each call fires (no growing
188
173
  stack of dots); once prose resumes the group settles: a single call becomes one done row, and a
189
- multi-call group becomes a persistent "{final action} ({n} steps)" HEADER (a `complete` terminal
174
+ multi-call group becomes a persistent "{final action}" HEADER (a `complete` terminal
190
175
  dot — distinct from the filled `done` step dots; amber `warning` if any call errored) that STAYS
191
- PUT and rolls the calls out BELOW it on press. The run ALWAYS ends on the agent's text — there is
192
- no global terminal node.
176
+ PUT and rolls the calls out BELOW it on press.
177
+
178
+ **ONE row for the whole run, and NO prose until it ends.** While the agent works, the entire feed
179
+ is a single row. It names the call in flight — "Searching records" — and says
180
+ **"Thinking…"** whenever the model is writing rather than calling, which is every gap between
181
+ calls. The dot pulses; nothing rolls out unless the reader asks.
182
+
183
+ **No step count.** The row says WHAT is happening and stops there. A tally was a number the reader
184
+ could act on in no way: live, the label already changes on every call, so it added no motion the
185
+ row did not have; settled, it sized work nobody had asked to size. The chevron carries "there is
186
+ more inside".
187
+
188
+ **The agent's between-call text is not shown at all.** It is narration by instruction — a plan the
189
+ prompt tells it to state up front, asides capped at two sentences — and none of it is load-bearing
190
+ in flight. The one message that genuinely needs a response is a bulk-mutation confirmation, and
191
+ there the agent WAITS: the run stops, so that text becomes the trailing text and renders as the
192
+ answer. Showing the rest bought a sentence that appeared, got half-read, and vanished when the
193
+ next call superseded it. The threshold is that the run did SOME work — a reply with no tool calls
194
+ at all is all answer, and hiding *that* behind a row until settle would be the real loss.
195
+
196
+ A reader waiting on an answer is not reading a transcript, and a transcript that scrolls past
197
+ while they wait is still noise — bounding it only makes the noise a fixed size. What they need is
198
+ that something is happening and roughly what; the label and the count carry both, and the press
199
+ carries the rest. That the waits are long (production: **p50 25s, p90 94s**; five-plus tool calls
200
+ p50 52s / p90 144s) is an argument for the label CHANGING, not for showing the feed.
201
+
202
+ The cost, stated plainly: the answer appears at settle rather than streaming. Final responses are
203
+ ≤3 sentences by instruction, so what is given up is a second of typing — against never yanking a
204
+ half-read sentence out from under anyone.
205
+
206
+ Grouping alone could never deliver this, because a group breaks on every prose part AND every
207
+ reasoning part. An interleaved-thinking run therefore rebuilt the stack one group at a time — the
208
+ reader watched finished rows pile up while they waited, and was then left with that pile sitting
209
+ above the answer at the answer's weight, every row of it stale.
210
+
211
+ **Expanding is where live and settled differ, and the only place they do.** Opened mid-run the
212
+ timeline is CAPPED and self-pinning (220px, ~5 rows) — someone who opens a run in flight asked to
213
+ see the work, not to hand the page a feed that grows for another minute; `FollowScroll` follows at
214
+ LAYOUT level so each call paints already pinned. Opened once settled it is uncapped: nothing is
215
+ arriving, so there is nothing to cap. Settling never closes what a reader opened; it just stops
216
+ capping it.
217
+
218
+ The split is POSITIONAL (`splitTimeline` in `agent_transform`): what comes after the last tool
219
+ call is the answer, everything before it is the work. Nothing has to decide whether a paragraph
220
+ was a plan or a report — a model emits all of it as `text`, so only position is knowable, and
221
+ while the run is still moving that position is not yet final. Which is why the answer waits for
222
+ settle: at that moment, and only then, the trailing text is known to be the report.
223
+
224
+ The work stays fully open where collapsing would hide something needed: while any call is parked
225
+ `awaiting` a human decision (the approval surface would be asking about work the reader can no
226
+ longer see); when a SETTLED run stopped ON a tool call, leaving no answer to fold behind (mid-run
227
+ that is just "not there yet", not a dead end); when the work is already one row (folding buys
228
+ nothing and costs a second press to reach a step's I/O); and when no tool ran at all (`Thinking`
229
+ is already a collapsed row, and a summary could only name a step that never happened).
230
+ **`collapseProcess={false}`** hands the whole job back to the caller — for a surface that ALREADY
231
+ frames the run (`AgentProgress`'s pill) or one whose subject is a step's own output; such a
232
+ surface owns the `FollowScroll` too, and one on the default must NOT add one or it nests a
233
+ scroller in a scroller. **`summarizeRun(steps)`** renames the row; the default names the current
234
+ or last action and nothing more, because only the HOST knows which of its tools WROTE, and a
235
+ kit-side list of write tools would be silently incomplete the first time one was added —
236
+ reporting a run that changed data as though it had only read. A host that knows should say so
237
+ ("Updated 3 records"): that is the one fact worth reading without expanding.
238
+
239
+ An ARTIFACT the run produced — a file, an image, a record diff — belongs in the result zone the
240
+ host renders below the feed, never inside a tool's output panel where the fold puts it two
241
+ presses away.
193
242
 
194
243
  **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
200
- to a prettified form. `stepsLabel` localizes the "{n} steps" suffix. The tool `state` folds ai's
244
+ or a `dynamic-tool`'s `toolName`), resolved through the active locale's **`agentRun.tools`** map.
245
+ An optional **`labelForCall`** override still wins, for phrasing a call by what it TARGETS rather
246
+ than by which tool ran. It gets the whole call — `{ toolName, input, state }` (`AgentToolCall`);
247
+ return `undefined` to fall back. Unknown names fall back
248
+ to a prettified form. The tool `state` folds ai's
201
249
  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
250
+ call is parked on a human decision — an amber dot + an "awaiting" annotation, and it NEVER
251
+ pulses); `approval-responded` → running; `output-denied` → error carrying the
204
252
  denial reason. The whole-run `state` prop (`"streaming" | "done" | "error"`) defaults to `streaming`
205
253
  while any tool is running, else `done` (an awaiting call is not running, so it settles).
206
254
 
207
255
  **Errors — two kinds, both surfaced, neither shouting.** An INTERMEDIARY tool failure rides in
208
256
  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
257
+ group's header goes amber too), the reason waits in the expanded Error panel. A BREAKING error that terminates the whole run lives
211
258
  OUTSIDE `parts` (chat persists it in the message's `errors`; `useAgentRun().error` carries it) —
212
259
  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
260
+ 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
261
  action (a secondary `Button`) sits INSIDE that panel so the operator can re-fire the run; omit it
216
262
  and the panel carries the message alone.
217
263
 
218
- **In a bounded container, wrap it in `FollowScroll`.** An `AgentRun` streaming inside a dialog,
219
- drawer, or fixed-height panel grows BELOW the fold a plain scroll container doesn't follow. Wrap
220
- 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
264
+ **`FollowScroll` only when you took the job (`collapseProcess={false}`).** A run opened mid-flight
265
+ already caps itself with exactly this, so wrapping the default AGAIN nests a scroller in a
266
+ scroller: the outer never overflows, the inner holds the content, and the wheel lands on whichever
267
+ the pointer happens to be over. Reach for the wrapper when the run is a raw timeline you are
268
+ framing yourself — a dialog, drawer, or fixed-height panel where the feed grows BELOW the fold and
269
+ a plain scroll container doesn't follow. Wrap it then:
270
+ `<FollowScroll style={{ maxHeight: … }}><AgentRun … collapseProcess={false} /></FollowScroll>` — the same
271
+ inverted-list mechanism as a chat message list: the follow happens at
222
272
  LAYOUT level (each growth paints already pinned — no scroll-after-paint flash), scrolling up
223
273
  releases it, and the feed stays at the newest content when the run settles (`AgentProgress`'s
224
274
  expanded panel does this). When settling REPLACES the feed with different content (a results
@@ -233,7 +283,7 @@ for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={
233
283
  install; the imports are type-only (purity-enforced), so no `ai` runtime ever enters a bundle.
234
284
  **A run with no parts yet renders the breathing "Starting…" row** (streaming state only) — the
235
285
  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
286
+ part (upload + run creation + first token). **The law: pass
237
287
  `state="streaming"` from the moment the paid CTA fires — uploads included — and render
238
288
  `AgentRun` even with zero parts. Never hand-roll a text/skeleton placeholder in front of the
239
289
  feed**; the starting row IS the placeholder, consistent and localized.
@@ -243,9 +293,9 @@ feed**; the starting row IS the placeholder, consistent and localized.
243
293
 
244
294
  **Localization.** The feed's fixed chrome — the "Thinking" label, the "Input"/"Output"/"Error"
245
295
  panel titles, the "awaiting" annotation, the "Retry" action, the tool labels (`agentRun.tools`,
246
- keyed by raw tool name) and the "{n} steps" suffix (`agentRun.steps`) — resolves through the
296
+ keyed by raw tool name) — resolves through the
247
297
  `LoticsLocale` `agentRun` slice (translate once at the provider; a `vi` app gets them for free);
248
- `labelForCall` and `stepsLabel` remain as per-call-site OVERRIDES.
298
+ `labelForCall` remains a per-call-site OVERRIDE.
249
299
 
250
300
  **Answering an awaiting call — `ApprovalPrompt`.** The `awaiting` row inside `AgentRun` is
251
301
  READ-ONLY — it only NAMES that a call is parked on a human decision. The surface that ANSWERS it
@@ -263,7 +313,7 @@ locale slice → English.
263
313
  On a canvas/composer app reach for `AgentProgress` (`@lotics/ui/agent_progress`) — `AgentRun`
264
314
  collapsed into a floating pill (an animated `WaveAvatar` + the current step's label) that EXPANDS
265
315
  on press to the full feed in a capped scroll panel. It takes the same `parts`/`state`/`error`, and
266
- forwards `labelForCall`/`renderToolOutput`/`onRetry`/`stepsLabel` to the expanded feed, plus `label`
316
+ forwards `labelForCall`/`renderToolOutput`/`onRetry` to the expanded feed, plus `label`
267
317
  (override the collapsed text — defaults to the running step's label, else "Working…" / "Done" /
268
318
  "Stopped"; pass it to localize — note it's one static string, so it no longer tracks the running
269
319
  step) and `defaultExpanded`. Its pill matches `Composer`'s compact geometry, so the composer morphs
@@ -275,17 +325,15 @@ into it while running and reveals again when done.
275
325
  ## Review before apply — TWO atoms and a hook, composed into your screen
276
326
 
277
327
  **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.
328
+ supplied, whether or not they replace anything. Modifying an existing record takes the full
329
+ diff. Creating records FROM A DOCUMENT takes the same gate in a different shape — no diff, but
330
+ the whole record laid out and editable before the write (`examples/tpl_item_list.tsx`). Only
331
+ values a HUMAN TYPED skip it: the manual form saves direct and ends on the receipt.
283
332
 
284
333
  **There is no review CONTAINER, deliberately.** A review is not a place you go; it is something
285
334
  that happens to values already on screen. So the diff renders wherever the value renders — a
286
335
  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.
336
+ alignment and rhythm.
289
337
 
290
338
  ### The three pieces
291
339
 
@@ -294,39 +342,28 @@ shape its root draws, and every surface needing a diff somewhere else rebuilds i
294
342
  dense rows; `align`/`tabular` for money so the struck value and its replacement line up;
295
343
  `tone` when the change itself is the bad news. **The three ops are the same component:**
296
344
  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.
345
+ passes both, and **equal values collapse to one** — agreement is not a change.
346
+ **Both values stay on screen** on a real change, and **`delta`** prints the movement itself — "+250 pcs",
347
+ "−118.519 VAT".
302
348
 
303
349
  **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
350
+ system's error colour. Red-against-green is also the one pair a colour-blind reader cannot
351
+ separate. The strikethrough carries "going away" with no colour at all; weight, tone and (stacked) size
308
352
  carry the rest.
309
353
  - **`DiffMark`** (`@lotics/ui/diff_mark`) — what happened to the row holding the value, for the
310
354
  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.
355
+ accessible word, never colour alone.
313
356
 
314
357
  **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.
358
+ list and the thumbnail grid all draw the same mark. `unchanged` renders as an empty
359
+ disc-sized HOLE: it keeps the column's width and announces nothing.
321
360
 
322
361
  **Derive the kind from the same before/after the row's `DiffValue` carries — never from a
323
362
  boolean "did this differ".** A field with no prior value (`before` empty or absent) that a run
324
363
  now proposes a value for is an ADD, not a CHANGE, and a local review row that only asks
325
364
  "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
365
+ on a value that was never there to begin with. The first proposal any fresh record ever gets
366
+ has an empty `before` on every field, so every mark on that one run is wrong until the kind is derived per field
330
367
  (`before === after` → no mark; `before === ""` → `added`; otherwise → `changed`).
331
368
  - **`useChangeSet(ids, { initial })`** (`@lotics/ui/use_change_set`) — the bookkeeping:
332
369
  `status`/`accept`/`reject`/`undo`/`acceptAll`/`reset`, `accepted`/`rejected`/`pending`,
@@ -339,33 +376,28 @@ shape its root draws, and every surface needing a diff somewhere else rebuilds i
339
376
  - **A record's fields** — a `DetailTable` of rows, each a label plus a `DiffValue`, with
340
377
  Keep/Drop on the row and a collapsed state once decided. That row is ~20 lines and it belongs
341
378
  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.
379
+ read differently.
343
380
  - **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.
381
+ inside the figure cells.
345
382
  - **A conflict** — two documents disagree, so the value is the read-only OUTCOME of a pick:
346
383
  a `DiffValue` showing the placeholder until resolved, over `CardSelectItem` candidates (each
347
384
  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.
385
+ Keep until one is chosen.
350
386
  - **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
387
+ 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
388
  are NODES (a `FileBadge` plus its name). A file replacing one already filed shows both, and
354
389
  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`.
390
+ a `View` child. Worked in `examples/tpl_record.tsx`.
357
391
  - **A DOCUMENT SET** — a files section is not a field. It is a set of objects with identity,
358
392
  so what changes is MEMBERSHIP plus per-document properties, and the kinds do not share a
359
393
  treatment. **Added** and **removed** are membership: the row's existence and its mark are
360
394
  the whole change. **Superseded** is one slot whose bytes changed — the row names the new
361
395
  file and its second line names the one it displaces. **Reclassified** is the one a review
362
396
  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
397
+ belongs on the property. `FileRow` takes the mark in `leading` (so the names still form a column) and a node
365
398
  `meta` (so the second line can carry a diff).
366
399
 
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
400
+ **In a GRID** the same set reads differently again: a grid is scanned whole, so it carries membership (this is arriving, that
369
401
  is leaving — `FileThumbnailGrid diffs`, a corner mark plus a fade) at a glance and carries
370
402
  pairing not at all. Pick the surface for the change; do not shrink the change to fit a shape.
371
403
 
@@ -373,56 +405,45 @@ shape its root draws, and every surface needing a diff somewhere else rebuilds i
373
405
  a tile — the badge earns its space and the row is built around it. Where the file is one
374
406
  VALUE among several columns, render the filename as a value at the column's own text size:
375
407
  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.
408
+ size a cell allows), and sizing it back up makes it set the row's height.
378
409
 
379
410
  **The strikethrough has a discriminator.** Strike the superseded file when it shares a CELL
380
411
  with its replacement, adjacent — a table's document column, where the pairing is what the
381
412
  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`.
413
+ a word names the relation and the chip only identifies the file. Both shapes are worked in `dev/pages/ai.tsx`.
384
414
  - **A record being CREATED from a document** — no before-state, so no diff: the whole record
385
415
  is the review. Lay every field out as it will be stored, editable in place, and mark only
386
416
  the fact that IS relational (matched onto something existing, or new alongside it). One
387
417
  commit named for the outcome. Worked in `examples/tpl_item_list.tsx`.
388
418
  - **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.
419
+ change set.
390
420
 
391
421
  ### The laws that survive the container
392
422
 
393
423
  - **Nothing auto-applies.** The commit is one explicit press, named for its outcome
394
424
  ("Update record (3)", "Add 4 tasks") — never "Apply" alone, never "OK".
395
425
  - **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.
426
+ on a value they cannot touch.
398
427
  - **Gate the commit on what will actually be written**, not on the proposal count — disable at
399
428
  zero kept.
400
429
  - **A decided row's Keep/Drop must be reachable from BOTH directions when `initial: "accepted"`.**
401
430
  `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
431
+ (`initial: "accepted"`) a row starts already decided and Undo is a no-op with nothing to
432
+ reach. The collapsed-to-Undo anatomy (value + one "Undo" button, swapping to Keep/Drop only
406
433
  while `pending`) is correct ONLY under `initial: "pending"`, where every row genuinely starts
407
434
  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.
435
+ both buttons present, the one matching the row's current status disabled, the other live.
410
436
  - **The mark rides the LABEL, and the value sits in an `InlineSlot`.** Both are alignment
411
437
  before they are semantics. A mark beside the VALUE indents every row it marks by its own
412
438
  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
439
+ and they drift. The label column is a fixed width with one
414
440
  left edge, so a mark placed there aligns down the page for free. Give the slot a fixed HEIGHT
415
441
  as well as width: it is an inline-flex box inside `DetailRow`'s label `Text`, so an empty one
416
442
  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.
443
+ with 20px flat diffs; without the slot the column has two left edges and two baselines.
419
444
  - **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
445
+ under the whole cell — which is under the Keep/Drop buttons. Render it inside the value block.
446
+ - **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
447
  value, so the same component tree serves a record with one change and a record with twenty).
427
448
  Emphasis, not omission, is what makes the changes findable.
428
449
  - **The fixture must not flatter.** A review demo whose every row carries a change proves
@@ -435,8 +456,7 @@ shape its root draws, and every surface needing a diff somewhere else rebuilds i
435
456
  `Clarify` (`@lotics/ui/clarify`): when the agent is unsure, it asks a question with quick-reply
436
457
  options and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run: `question` (the
437
458
  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
459
+ answer carries a REQUIRED one-line description; a `ChoiceList`), `onAnswer(value)` resumes the run, the controlled `answer` keeps the pick
440
460
  switchable until committed. **`allowCustom`** adds an always-visible, borderless, auto-growing multiline field below the
441
461
  options — typing it IS the selection; its text becomes the value (any value not matching an option
442
462
  reads as the custom answer).
@@ -446,7 +466,7 @@ Back / Next / Cancel / Submit (Next/Submit primary), the step position an eyebro
446
466
  question — no progress bar, and no eyebrow at all for a single question. Each step is a `Clarify`; advance only once the
447
467
  current question is answered; `onSubmit` gives one `{ value, custom }` per question (aligned by index,
448
468
  `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
469
+ locale-wired (the `clarify` slice). **Placement follows
450
470
  the dialog grammar**: standalone (riding a parked run's stream, where no footer exists) the wizard
451
471
  renders its own inline action row; as a DIALOG phase, wrap the `Dialog` with `ClarifyWizardScope`
452
472
  and put `ClarifyWizardActions` in the `DialogFooter` — the wizard suppresses the inline row and
@@ -474,29 +494,25 @@ const run = useAgentRun("intake"); // @lotics/app-sdk
474
494
  after a question it is the only thing holding how the answered leg ended — your own `run()` promise
475
495
  already resolved, with `parked`, and never resolves again. Give `onLanding` the same handler you
476
496
  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.
497
+ ask. Omit it and an answered run completes with the host none the wiser — your review step never
498
+ opens, and the paid read is discarded. A host that instead reads its result off `status`/`output`
499
+ does not need it.
481
500
 
482
501
  **The parked question REPLACES the feed — that is the contract, not a style.** The run is blocked
483
502
  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
503
+ its actions sit outside it. Stacked under the transcript in an unscrollable box, a multi-question
504
+ 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
505
  also swaps `FollowScroll` for `DialogScrollArea` on the park, so the question opens at the top
488
506
  rather than wherever the feed was scrolled to.
489
507
 
490
508
  **Mount `AgentRunActions` wherever the run drives the surface — never gated on one phase.** It
491
509
  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.
510
+ it settles; the RUN decides. A host that names a single phase out here duplicates that decision.
495
511
 
496
512
  **`run` is a SHAPE, not an import** (`AgentRunLike`: `status`, `parts`, `pendingChoice`,
497
513
  `answerChoice`, `cancel`, `error`; `AgentRunQuestion` names what `pendingChoice` carries, so a template or
498
514
  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
515
+ label, which is the `ask_user_choice` wire shape). `useAgentRun()` satisfies it structurally — `@lotics/ui` never depends on
500
516
  `@lotics/app-sdk` — which also means a template or test can hand it a plain object and exercise the
501
517
  whole pane with no backend ([`tpl_item_list`](../examples/tpl_item_list.tsx) does exactly that).
502
518
 
@@ -533,28 +549,24 @@ list renders nothing.
533
549
  reader is deciding about it. The harder surface is the one they open a week later: a stored
534
550
  record whose fields were written by a person, by a chat agent, by an extraction off an upload,
535
551
  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.
552
+ which. Three rules, each of which a screen built for a single writer gets wrong for free.
538
553
 
539
554
  **An unwritten value must not render like a written one.** A row assembled by an automation
540
555
  arrives with its enums set and its prose empty, and the natural repair — composing a label out
541
556
  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
557
+ this up" and "this is what happened" then look identical. Say the value is missing, in the ink an unfilled field already uses
544
558
  (`Timeline`'s `placeholder`; the same idea wherever a component takes a `label: string` and
545
559
  therefore cannot express absence — that type is the trap, so give it a way to say so).
546
560
 
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
561
+ **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
562
  line above ("Written by AI from the recording" — the source alone is not authorship), and drop
551
563
  the machine's text into a recessed `Inset`, which says it was not written on this page. Leave
552
564
  the person's conclusion on the page's own ground; that contrast IS the signal.
553
565
 
554
566
  **Model prose arrives with its own type ladder — take it away at the door.** A model asked for
555
567
  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
568
+ document-scale markdown hands those an `h1` equal to your section heading. Render every
569
+ embedded value with
558
570
  `<Markdown variant="embedded">`.
559
571
 
560
572
  And one that is not about treatment at all: **check that the read path projects every field the
@@ -568,22 +580,18 @@ Confidence IS a callout (`@lotics/ui/confidence`) — the ONLY form it takes: th
568
580
  meter + level phrase head the block, and the REQUIRED `children` carry the basis — what was
569
581
  checked and what came of it ("all 7 references pass their checksum" / "2 of 7 fail (X, Y) —
570
582
  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,
583
+ meter is the only colored element. Pass `level` or a 0–1 `score` (≥0.8 high,
573
584
  ≥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
585
+ without its reasons cannot be acted on, so it cannot be rendered. Derive levels HONESTLY (deterministic checks: all pass → high, any
577
586
  fail → low) — never render a fabricated score.
578
587
 
579
588
  ## Findings — evidence, not writes
580
589
 
581
590
  `Finding` (`@lotics/ui/finding`) is one ranked insight from an AI check — a cross-check
582
591
  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
592
+ needs it: a severity dot, the `title`, and the `delta` right-aligned on that same line, then the
593
+ `readings` beneath ONE PER LINE, each a dim `source` plus its `value`, never joined by a
594
+ separator. `detail` is the CONSEQUENCE and is optional a second sentence restating the title in
587
595
  longer words costs a line and adds nothing.
588
596
 
589
597
  BOTH readings stay on screen and NEITHER is struck through or marked wrong: a finding reports
@@ -602,7 +610,7 @@ persisted check-status goes stale on the next edit). Stack several most-severe f
602
610
 
603
611
  The outputs accrue as a history the user can CLEAR ("New session"); the APP owns the evolving
604
612
  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
613
+ but it's a run LOG, not a conversation transcript.) The session key
606
614
  and history reads are the SDK's: `sessionId` on each run, `useAgentRuns` for the history — see
607
615
  [the SDK doc](../../app-sdk/docs/ai.md).
608
616
 
@@ -684,13 +692,11 @@ kit does:
684
692
  (white centre — pulses only when live), `done` a filled accent dot + **white check**,
685
693
  `upcoming` a faint **grey** ring, the terminal `complete` a **blackish ring + black check**,
686
694
  `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).
695
+ `AgentRun` keeps the neutral default.
689
696
  - **ChangeBand** — the removed value on the light **red** band with the `−` marker, the incoming
690
697
  value on the light **emerald** band with `+` (the GitHub-diff idiom, markers in one aligned
691
698
  column); everything else in a review stays neutral — a decided row reads a single emerald
692
699
  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
700
  - **Finding severity** — the coloured dot badge (red / amber / zinc / emerald), most severe
695
701
  first.
696
702
 
@@ -698,5 +704,5 @@ Card chrome (borders, microlabels) stays neutral — colour marks the *state*, n
698
704
  The **composer keeps its icons**: `Composer` compact is a single-row pill with an optional
699
705
  circular attach `actionsButton` + a circular send button; expanded it adds a `FileThumbnail`
700
706
  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
707
+ The "no icons" rule covers the review surfaces' sparkles and severity glyphs, not functional
702
708
  affordances.