@lotics/ui 28.3.1 → 29.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -16,7 +16,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
- | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split (modifyreview-before-apply; create → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer) |
19
+ | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machinea gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows, master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
21
21
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
22
22
 
@@ -89,6 +89,17 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
89
89
  back — which is what makes scanning twenty records unaffordable. `TableRow`'s `detail` +
90
90
  `expanded` reveal the detail beneath the row; keep the drawer for a heavy form.
91
91
  → [catalog.md](./docs/catalog.md).
92
+ - **Documents get a LIST; images get a GRID.** The discriminator is what identifies the file to
93
+ the person looking for it. A PDF, a Word doc and a spreadsheet are identified by their NAME —
94
+ their thumbnails are a grey page with grey lines on it, identical to every other grey page —
95
+ so a grid of them is a wall of the same tile and the reader has to read a truncated caption
96
+ anyway. Use `FileRow` / `FileRows`, where the full name is the row and a second line can
97
+ carry a date, a size, a status, a diff. Photos, scans and drawings are identified by their
98
+ CONTENT — the filename is `IMG_4471.jpg` and tells nobody anything — so use
99
+ `FileThumbnailGrid`, where the picture is the identity and many fit on one screen. A mixed
100
+ set follows its majority; an app whose attachments are overwhelmingly paperwork should not
101
+ pay a grid's wasted space for the occasional photo.
102
+ → [catalog.md](./docs/catalog.md).
92
103
  - **A reference to another record is a FIELD** — it wears the inline editor's own resting
93
104
  surface and PEEKS its facts on press; EVERY verb lives in the peek's footer as a `Button`,
94
105
  none on the field — LEFT the link (Change and Clear, both standard), RIGHT the record (Edit's
package/MIGRATION.md CHANGED
@@ -4,6 +4,32 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 29.0.0 — review surfaces compose from atoms
8
+
9
+ `ChangeReview` and its family are DELETED, replaced by three layout-free pieces. The
10
+ `change_review` export and the `changeReview` locale slice are gone. Apps pinned to 28.x are
11
+ unaffected until they upgrade.
12
+
13
+ **Why.** `ChangeReview` was a REQUIRED context root — `useReviewCtx` threw outside it — so a
14
+ change could not be dropped into a screen that already existed; you adopted the whole container
15
+ or nothing. And the family owned LAYOUT, not just semantics (a divided stack, a ruled field
16
+ container, a label-plus-bands block), so a change could only ever *look like* a stack of
17
+ labelled field diffs. Every surface needing a diff somewhere else — inside a ledger row, beside
18
+ a total, in a cell — rebuilt it by hand, and several apps did.
19
+
20
+ **What to reach for instead**
21
+
22
+ | Was | Now |
23
+ |---|---|
24
+ | `ChangeField` `before`/`value` bands | **`DiffValue`** — anywhere a value renders |
25
+ | `ChangeRecord` op wash, add/remove framing | **`DiffMark`** on whatever row you already draw |
26
+ | `ChangeReview` provider + `Change` status + `ChangeReviewHeader` counter | **`useChangeSet(ids)`** — the same bookkeeping, no container |
27
+ | `ChangeReviewActions` | your own `Button`s, reading `review.keptCount` / `review.settled` |
28
+ | `ChangeFields`, `ChangeLabel`, `ChangeSummary`, `ChangeReasoning`, `ChangeBand`, `ChangeValueInput` | ordinary `DetailTable` / `Text` / inline inputs — they were layout, not contract |
29
+
30
+ The opinionated stack is not gone, it is demoted: build it in your own screen from the atoms.
31
+ Every app needed a slightly different shape, which is why one container could not serve them.
32
+
7
33
  ## 28.2.0 — `SubsectionHeadingTitle` takes a `description`
8
34
 
9
35
  Additive; nothing to change. The heading ramp's three rungs are meant to carry the SAME slots
@@ -13,12 +13,23 @@ file). Every component here is in the [catalog](./catalog.md) inventory.
13
13
 
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
- the human stays in charge follows the stakes split by whether a before-state exists:
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.
19
+ 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.
17
22
 
18
23
  - **Modifying an existing record** — a diff exists, and a wrong value can clobber a right one →
19
- review BEFORE apply: the `ChangeReview` family, Keep/Drop per change, nothing auto-applies.
20
- - **Creating new records** (intake, import, drafting rows) — there is no before-state, so there
21
- is nothing to diff **apply directly, then end on the RECEIPT**
24
+ review BEFORE apply: `DiffValue` + `DiffMark` + `useChangeSet`, composed into the screen; nothing auto-applies.
25
+ - **Creating new records from a DOCUMENT** (intake, import, drafting rows) — no before-state,
26
+ so nothing to diff, but the values are still proposed rather than typed → **a full EDITABLE
27
+ preview of every record before the write**, not a summary of it. Lay the records out as they
28
+ 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.
31
+ - **Creating new records a human TYPED** (the manual create form) — they read every value on
32
+ the way in → **apply directly, then end on the RECEIPT**
22
33
  (`@lotics/ui/result_header`) — the receipt states the outcome and ROUTES; it is never a
23
34
  record editor. Run DETERMINISTIC checks over what the save STORED (a checksum, a count
24
35
  reconciliation — binary math, never a fabricated score). ONE record: `ResultHeader` (tone,
@@ -52,7 +63,7 @@ for the right one by job.
52
63
  Three shapes:
53
64
 
54
65
  1. **Field writes** (extract, match, rank, classify → records change) → **in-app agent**
55
- (`useAgentRun`), then split by the one law: modifying an existing record → **`ChangeReview`**
66
+ (`useAgentRun`), then split by the one law: modifying an existing record → **a review surface you compose**
56
67
  (Keep/Drop per change, app-workflow writes, bounded app authority); creating new records →
57
68
  **save direct + the receipt** (outcome, verdicts, and a route to the record).
58
69
  Either way one button, never a prompt box.
@@ -241,107 +252,143 @@ into it while running and reveals again when done.
241
252
  **Limitation:** the pill's expand/collapse accessibility labels ("Show the agent's steps" /
242
253
  "Hide the agent's steps") are currently hardcoded English.
243
254
 
244
- ## Review before apply — ONE surface, the compound `ChangeReview` family
245
-
246
- **When to reach for it — the one law's boundary.** `ChangeReview` is for MODIFYING an existing
247
- record: a diff exists (the `−` band has something real to show) and a wrong extraction can
248
- overwrite a right value — the record page's document desk is the home ground. Do NOT put it in
249
- front of a creation flow (intake, import, bulk drafting): there is no before-state to diff, so
250
- the "review" is a proofread the user blind-confirms save direct and end on a result list
251
- instead (see [the one law](#the-one-law) and `tpl_item_list`'s intake).
252
-
253
- Every "the agent proposes the human accepts / edits / dismisses nothing auto-applies" surface
254
- composes from twelve pieces in three groups (all in `@lotics/ui/change_review`; worked example:
255
- [`tpl_record`](../examples/tpl_record.tsx)'s Use-AI extract flow).
256
-
257
- **Frame**
258
-
259
- - `ChangeReview` context provider + stack; hairline dividers between consecutive `Change`
260
- sections (and `ChangeFields` rules between fields). Changes are OPEN sections — spacing and
261
- rules, never card boxes.
262
- - `ChangeReviewHeader` the SECTION heading (md semibold; default title localizes to "Suggested
263
- edits"). Keep it lean: never repeat the record the dialog is already about. The counter does
264
- NOT live here.
265
- - `ChangeReviewActions` the commit bar; pin it in the `DialogFooter`/`DrawerFooter` (or inline
266
- under the list). **Keep all** sits bottom-left and presses every pending decision's own
267
- `onAccept` pass `onAcceptAll` when decisions live in host state the registry can't see
268
- (field-level Keep/Drop, unresolved conflicts); it then replaces the derived handler entirely.
269
- The "N of M kept" counter reads HERE beside it, never in the header (the counter renders only
270
- when at least one registered entry carries `onAccept`; Keep-all renders when one does OR when
271
- you pass `onAcceptAll`; `showAcceptAll={false}` hides Keep-all). Apply
272
- disables below `minKept` kept (default 1 when any registered entry carries `onAccept`, else 0)
273
- or via `applyDisabled` (host-level gate, ORed in); `applyLoading` marks the async write.
274
- `onDiscard` puts the Discard button beside Apply. Apply is the only outcome-named button
275
- (`applyLabel`).
276
-
277
- **Sections**
278
-
279
- - `Change` one proposed change: `id` (registers in the review context the counter, Keep-all
280
- and the apply gate derive from registered ids), controlled `status`
281
- (`"pending" | "accepted" | "rejected"`), `onAccept`/`onReject`/`onUndo`, heading + free body +
282
- optional verbs + collapse. Omit both callbacks for a **display-only** entry (a finding inside a
283
- review). Decided collapses to the `ChangeSummary` child (falls back to the body) behind a
284
- status mark + Undo. `acceptDisabled` gates Keep on a precondition.
285
- - `ChangeLabel` the change's SUBJECT heading; a stack of changes skims by subject.
286
- - `ChangeSummary` the collapsed row's one-line content after a decision.
287
- - `ChangeReasoning` the agent's quiet hairline-ruled aside (muted text behind a left rule)
288
- only when the change is not self-explanatory.
289
-
290
- **The grammar**
291
-
292
- - **`ChangeFields` + `ChangeField`** THE field unit. `ChangeFields` is the OPEN form (the
293
- section IS the record an extract dialog's one order: no card chrome), stacking `ChangeField`s
294
- with hairline rules. A diff-form `ChangeField` renders, top to bottom: the label (sm medium,
295
- **full colour** — it names the subject of a decision, never `DetailRow`'s muted ink), the
296
- field's `reasoning` directly under the label, the `−` band when replacing (`before`), the
297
- value — `ChangeValueInput` when `onChangeText` is given (the green `+` band; pressing it edits
298
- IN THE BAND — a borderless input with identical type metrics, so nothing shifts), the read-only
299
- `+` band when `valueReadOnly` or no `onChangeText` (muted `placeholder` while empty), or any
300
- input via `children` compose `<ChangeValueInput unit="pcs" …>` there yourself for a unit
301
- suffix (`unit` fixes the suffix outside the editable core — type the number, never the unit;
302
- the built-in default doesn't take it), conflict `candidates` as full-width decision rows
303
- (picked via `onPickCandidate`) + the localized "Type another value" third option
304
- (`customValue`/`customSelected`/`onCustomValue`/`onCustomSelect`; the outcome band is read-only
305
- via `valueReadOnly` the decision comes from picking, never from editing the band; gate with
306
- `keepDisabled`), per-field **Keep/Drop** bottom-right
307
- (`status: "pending" | "kept" | "dropped"`, `onKeep`/`onDrop`/`onUndo`). A pure REMOVAL is
308
- `before` with no value — the `−` band alone. A decided field collapses to the compact one-row
309
- card (mark, label `summary`, Undo). A field with no diff and no decision is the plain
310
- inline label, value row this form borrows `DetailRow`'s *metrics* (130px label column /
311
- 40px row)press-to-edit when `onChangeText` is given.
312
-
313
- **Limitation:** `ChangeFieldCandidate.source` and `.description` are accepted by the type but
314
- the built-in candidate row currently renders only the `value` (plus `selected`). Fold a short
315
- qualifier into the value string, or compose your own rows; provenance belongs in `Sources` at
316
- the review's bottom anyway.
317
-
318
- - **`ChangeRecord`** THE item card for SETS of records (order lines): `id` (registers in the
319
- review context like a `Change`), `tone` `"add" | "edit" | "remove"`, the tinted header band
320
- with the localized op word (Add / Edit / Delete the `changeReview` locale slice) + `title`,
321
- body = its `ChangeField`s, required `summary` for the collapsed row. **The verb level follows
322
- the decision level**: add/remove carry ONE card Keep/Drop (fields none editable parts, one
323
- write); edit carries NO card verbs (only its changed fields, each deciding for itself
324
- dropping a field narrows the update diff).
325
- - **`ChangeBand`** — the raw diff band (aligned `+`/`−` marker column, light emerald/red, dark
326
- text) every piece builds from; reach for it directly for custom strokes (a removed record's
327
- one-line summary). String content is UNCLAMPED by default (`numberOfLines` to clamp) — a
328
- review must show the whole value being decided.
329
- - **`ChangeValueInput`** the proposed value as a proper diff at rest (the green `+` line) that
330
- becomes a real input on press; rest and edit share one height, so nothing shifts.
331
-
332
- **The scaling boundary (known, deliberate)**: `ChangeFields` + `ChangeRecord` cards cover one
333
- record through ~10; a BULK review (30+ imported rows a bank statement, a spreadsheet import)
334
- needs a dense form that does not exist yet design it against the first real migration, not
335
- speculatively.
336
-
337
- **The laws**: ONE verb pair everywhere — Keep/Drop, localized through the `changeReview` locale
338
- slice (en: Keep / Drop / Keep all / Apply / Discard / Undo); never rename per shape (Apply is the
339
- only outcome-named button; `acceptLabel`/`rejectLabel` exist but are not a license to invent
340
- verbs). Candidate rows carry the VALUE only; provenance = `Sources` at the VERY bottom of the
341
- review (after every change; or omit it lean beats decorated). The HOST owns every decision in
342
- plain `useState` (controlled `status` everywhere); the family owns the mechanics (collapse +
343
- Undo, the counter, Keep-all, apply gating). Never apply a field with no value gate unresolved
344
- conflicts (`keepDisabled`, `applyDisabled`). Editing IS the review.
255
+ ## Review before apply — TWO atoms and a hook, composed into your screen
256
+
257
+ **When to reach for it — the one law's boundary.** A review gates values the MACHINE
258
+ supplied, whether or not they replace anything. Modifying an existing record is the obvious
259
+ case and takes the full diff. Creating records FROM A DOCUMENT takes the same gate in a
260
+ different shape no diff, because nothing is being replaced, but the whole record laid out
261
+ and editable before the write (`examples/tpl_item_list.tsx`). Only values a HUMAN TYPED skip
262
+ it: they read them on the way in, so the manual form saves direct and ends on the receipt.
263
+
264
+ **There is no review CONTAINER, deliberately.** A review is not a place you go; it is something
265
+ that happens to values already on screen. So the diff renders wherever the value renders — a
266
+ table cell, a `DetailRow`, a total, a chip, a sentence — and inherits that container's width,
267
+ alignment and rhythm. A compound family with a provider at its root can only produce the one
268
+ shape its root draws, and every surface needing a diff somewhere else rebuilds it by hand.
269
+
270
+ ### The three pieces
271
+
272
+ - **`DiffValue`** (`@lotics/ui/diff_value`) — a value that changed. `before` struck, `after`
273
+ emphasised. `layout="stacked"` (default) for a column of figures, `"inline"` for prose and
274
+ dense rows; `align`/`tabular` for money so the struck value and its replacement line up;
275
+ `tone` when the change itself is the bad news. **The three ops are the same component:**
276
+ an ADD omits `before`, a REMOVAL omits `after` (the struck value IS the change), an UPDATE
277
+ passes both, and **equal values collapse to one** agreement is not a change, and an invoice
278
+ confirming the amount that was claimed is the GOOD case, not a diff of a value against
279
+ itself. **Both values stay on screen** on a real change (the old one is the only evidence the
280
+ correction is the right SIZE), and **`delta`** prints the movement itself "+250 pcs",
281
+ "−118.519 VAT" so nobody subtracts in their head.
282
+
283
+ **The struck value is muted, never red.** An old value is not an ERROR, and red is this
284
+ system's error colour every routine update would read as a problem, and a table of eight
285
+ changed rows would carry sixteen coloured figures. Red-against-green is also the one pair a
286
+ colour-blind reader cannot separate, and it is the distinction a review turns on. The
287
+ strikethrough carries "going away" with no colour at all; weight, tone and (stacked) size
288
+ carry the rest.
289
+ - **`DiffMark`** (`@lotics/ui/diff_mark`) — what happened to the row holding the value, for the
290
+ cases no single value carries: `added` / `changed` / `removed` / `unchanged`. Glyph plus an
291
+ accessible word, never colour alone. `unchanged` draws a quiet dot, because most rows in a
292
+ real review are untouched.
293
+
294
+ **ONE treatment, every surface: a filled 22px disc.** The form, the line table, the files
295
+ list and the thumbnail grid all draw the same mark, because a reader parses shape before
296
+ meaning and a form marked with bare glyphs beside a table marked with filled shapes reads as
297
+ two systems saying the same thing. `unchanged` renders as an empty disc-sized HOLE: an
298
+ operator opening a job of twelve fee lines to see where a dropped invoice landed should find
299
+ the one filled shape in a column of blanks without reading anything or separating two hues.
300
+ The hole keeps the column's width and announces nothing.
301
+ - **`useChangeSet(ids, { initial })`** (`@lotics/ui/use_change_set`) — the bookkeeping:
302
+ `status`/`accept`/`reject`/`undo`/`acceptAll`/`reset`, `accepted`/`rejected`/`pending`,
303
+ `keptCount`/`total`/`settled`. `initial` defaults to **`accepted`** so an operator drops
304
+ exceptions instead of approving eight identical lines; pass `pending` when each change
305
+ genuinely deserves its own verdict, and gate the commit on `settled`.
306
+
307
+ ### The shapes, and what each one is
308
+
309
+ - **A record's fields** — a `DetailTable` of rows, each a label plus a `DiffValue`, with
310
+ Keep/Drop on the row and a collapsed state once decided. That row is ~20 lines and it belongs
311
+ to your screen: `examples/tpl_record.tsx` and `dev/pages/ai.tsx` each write their own and they
312
+ read differently, which is the system working.
313
+ - **A record SET** a `Table`, one row per proposed record, `DiffMark` leading and `DiffValue`
314
+ inside the figure cells. A set of ledger lines reads like the ledger it is about.
315
+ - **A conflict** — two documents disagree, so the value is the read-only OUTCOME of a pick:
316
+ a `DiffValue` showing the placeholder until resolved, over `CardSelectItem` candidates (each
317
+ naming its source document) and a type-another-value option. **Never pre-select**, and gate
318
+ Keep until one is chosen a pre-selected candidate is a decision the system made and
319
+ attributed to the person.
320
+ - **An ATTACHMENT** a dropped document's proposal is often not a value at all: it is WHICH
321
+ LINE the file belongs to. That decision is as wrong-able as a misread figure and gets the
322
+ same treatmentthe file rendered on the row it lands on, inside a `DiffValue` whose sides
323
+ are NODES (a `FileBadge` plus its name). A file replacing one already filed shows both, and
324
+ the superseded one is struck by a DRAWN rule: `line-through` set on a `Text` does not cross
325
+ a `View` child, so without it the old scan renders at full strength beside its replacement
326
+ with nothing saying which is going away. Worked in `examples/tpl_record.tsx`.
327
+ - **A DOCUMENT SET** — a files section is not a field. It is a set of objects with identity,
328
+ so what changes is MEMBERSHIP plus per-document properties, and the kinds do not share a
329
+ treatment. **Added** and **removed** are membership: the row's existence and its mark are
330
+ the whole change. **Superseded** is one slot whose bytes changed the row names the new
331
+ file and its second line names the one it displaces. **Reclassified** is the one a review
332
+ surface gets wrong: the bytes are untouched and only the FILING moved, so the `DiffValue`
333
+ belongs on the property a mark with no visible diff reads as though the file itself
334
+ changed. `FileRow` takes the mark in `leading` (so the names still form a column) and a node
335
+ `meta` (so the second line can carry a diff).
336
+
337
+ **In a GRID** the same set reads differently again, and that is a property of the shape
338
+ rather than a gap: a grid is scanned whole, so it carries membership (this is arriving, that
339
+ is leaving `FileThumbnailGrid diffs`, a corner mark plus a fade) at a glance and carries
340
+ pairing not at all. Pick the surface for the change; do not shrink the change to fit a shape.
341
+
342
+ **A file badge has a discriminator too.** Where the file is the row's SUBJECT — a `FileRow`,
343
+ a tile the badge earns its space and the row is built around it. Where the file is one
344
+ VALUE among several columns, render the filename as a value at the column's own text size:
345
+ a badge shrunk to fit a table cell takes its label off the type scale entirely (5px, at the
346
+ size a cell allows), and sizing it back up makes it set the row's height for a fact the
347
+ extension already carries.
348
+
349
+ **The strikethrough has a discriminator.** Strike the superseded file when it shares a CELL
350
+ with its replacement, adjacent a table's document column, where the pairing is what the
351
+ cell means. Do NOT strike it on a files-section line, where the two sit on different rows:
352
+ there the strike says "going away" without saying what replaces it, so a word names the
353
+ relation and the chip only identifies the file. Both shapes are worked in `dev/pages/ai.tsx`.
354
+ - **A record being CREATED from a document** no before-state, so no diff: the whole record
355
+ is the review. Lay every field out as it will be stored, editable in place, and mark only
356
+ the fact that IS relational (matched onto something existing, or new alongside it). One
357
+ commit named for the outcome. Worked in `examples/tpl_item_list.tsx`.
358
+ - **Display-only** — a finding that informs but has nothing to decide simply is not in the
359
+ change set. There is no "display-only" mode to remember.
360
+
361
+ ### The laws that survive the container
362
+
363
+ - **Nothing auto-applies.** The commit is one explicit press, named for its outcome
364
+ ("Update record (3)", "Add 4 tasks") — never "Apply" alone, never "OK".
365
+ - **Editing IS the review.** A value the operator can correct in place beats accept-or-reject
366
+ on a value they cannot touch: a wrong proposal you can fix costs a keystroke, one you can
367
+ only reject costs the whole re-entry.
368
+ - **Gate the commit on what will actually be written**, not on the proposal count — disable at
369
+ zero kept.
370
+ - **The mark rides the LABEL, and the value sits in an `InlineSlot`.** Both are alignment
371
+ before they are semantics. A mark beside the VALUE indents every row it marks by its own
372
+ width plus a gap, so an unmarked row needs a spacer of exactly the glyph's width to keep up —
373
+ and they drift (17 against 13 shipped that way). The label column is a fixed width with one
374
+ left edge, so a mark placed there aligns down the page for free. Give the slot a fixed HEIGHT
375
+ as well as width: it is an inline-flex box inside `DetailRow`'s label `Text`, so an empty one
376
+ baselines differently from one holding a glyph. On the value side, a form mixes 40px editors
377
+ with 20px flat diffs; without the slot the column has two left edges and two baselines, and
378
+ the label agrees with one kind of row and not the other.
379
+ - **The reason goes above the verdict.** A `why` passed as `DetailRow`'s `description` renders
380
+ under the whole cell — which is under the Keep/Drop buttons. A justification printed after
381
+ the controls that act on it is one nobody reads. Render it inside the value block.
382
+ - **Show the WHOLE thing, not just the parts that moved.** The operator's question is rarely
383
+ "what did it find" — it is "is this right, and where does it sit". A surface showing only
384
+ the changed rows answers the first and leaves the second to a second screen. Render the
385
+ record entire and let `DiffValue` collapse what agrees (equal values render as one plain
386
+ value, so the same component tree serves a record with one change and a record with twenty).
387
+ Emphasis, not omission, is what makes the changes findable.
388
+ - **The fixture must not flatter.** A review demo whose every row carries a change proves
389
+ nothing about the screen that matters — the one where a document touched two lines of
390
+ fourteen. Build the sparse case and check the marks are still findable in it.
391
+ - **Provenance belongs at the bottom of the section** (`Sources`), never crowded into a heading.
345
392
 
346
393
  ## Ask back — `Clarify` / `ClarifyWizard`
347
394
 
@@ -449,7 +496,7 @@ sources. Severity → colour: `critical` red, `warning` amber, `info` zinc ("Not
449
496
  emerald ("On track"); words come from the `finding` locale slice. **Display-only**: a finding
450
497
  informs the action the human takes in the app; it decides nothing itself — no phantom "record
451
498
  verdict" write (a persisted check-status goes stale on the next edit). Stack several most-severe
452
- first — inside a `ChangeReview` wrap each in a display-only `Change` (the family's dividers
499
+ first — render each as its own section separated by a `Divider` (a finding that only informs
453
500
  apply). `FindingComparison` is the expected-vs-actual body: each disagreeing side a labeled row
454
501
  (source, value), a hairline, then the DELTA emphasized — compose it as the finding's children
455
502
  for any one-value-disagrees insight (quantities, totals, dates).
@@ -496,9 +543,9 @@ ranked set, don't cram it into chat prose:
496
543
  document-driven records) — the record's files block feeds ONE "Use AI" entry that FORKS into
497
544
  the two document tasks, each a specialized run with a task-pure result: **Extract** (files
498
545
  read → fields already matching fold into one quiet line → every add / update / conflict a
499
- `ChangeField` (the `−` band, the editable value, candidate rows), the record's current value
546
+ a `DiffValue` per field (the struck current value, the proposed one, candidate rows), the record's current value
500
547
  a first-class choice — plus proposed new lines as record-body `Change`s → one outcome-named
501
- `ChangeReviewActions` commit) and **Cross-check** (documents compared against the record and
548
+ an explicit outcome-named commit) and **Cross-check** (documents compared against the record and
502
549
  each other → ranked `Finding`s — severity, title, the prominent metric, sources — separated
503
550
  by hairlines; the findings ARE the outcome the human acts on). The fork carries an OPTIONAL
504
551
  instructions field — the user steers what the agent checks or extracts, so `Finding` serves ANY
@@ -518,10 +565,10 @@ ranked set, don't cram it into chat prose:
518
565
  can do it in ONE run — every app agent carries `ask_user_choice`, so the run PARKS on the
519
566
  agent's own question and `useAgentRun().pendingChoice`/`answerChoice` drive the same
520
567
  `ClarifyWizard` (see the SDK's [ai doc](../../app-sdk/docs/ai.md)).
521
- - **Triage** — an inbox the agent classified + routed is a `ChangeReview` of `Change`s (body: the
568
+ - **Triage** — an inbox the agent classified + routed is a `useChangeSet` over rows (body: the
522
569
  item + the agent's call; the standard Keep/Drop verbs; Keep-all covers the high-confidence
523
570
  sweep). Leads, tickets, documents, emails.
524
- - **Compare / ranked pick** — one `ChangeField` whose `candidates` carry the ranked options; the
571
+ - **Compare / ranked pick** — one `DiffValue` over `CardSelectItem` rows carrying the ranked options; the
525
572
  human picks one. Quotes, carriers, suppliers, plans. (Fold the score/reason into each
526
573
  candidate's value string — see the candidate-row limitation above.)
527
574
 
package/docs/catalog.md CHANGED
@@ -301,13 +301,13 @@ upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow` (allocatio
301
301
  `Composer` (the adaptive command/chat composer — a compact pill when empty that expands for
302
302
  long text + attachments; the surface that triggers agent work), `AgentRun` (the live
303
303
  streaming work feed) + `AgentProgress` (its compact, floating, expandable form — a
304
- composer's "working" state) + `Confidence`; **`ChangeReview` — THE one review-before-apply
305
- surface, a COMPOUND family** (frame: `ChangeReview`, `ChangeReviewHeader`,
306
- `ChangeReviewActions`; sections: `Change`, `ChangeLabel`, `ChangeSummary`,
307
- `ChangeReasoning`; the grammar: `ChangeFields` + `ChangeField`, `ChangeRecord`,
308
- `ChangeBand` + `ChangeValueInput`): adds, updates, removals, conflicts, whole records,
309
- display-only findings are all compositionssee the AI-patterns doc indexed in
310
- [AGENTS.md](../AGENTS.md) for the laws; `Clarify` (the agent asks back — selectable
304
+ composer's "working" state) + `Confidence`; **review-before-apply composes from two atoms
305
+ plus a hook** — `DiffValue` (a value that changed: before struck beside/above after; drops
306
+ into a table cell, a `DetailRow`, a total, a sentence), `DiffMark` (what happened to the row:
307
+ added/changed/removed/unchanged) and `useChangeSet` (accept/reject/undo/counter, zero
308
+ layout). An add is `after` alone, a removal is `before` alone, an update is both, a conflict
309
+ is a `DiffValue` over `CardSelectItem` candidates and the ROW around any of them belongs to
310
+ your screen. See the AI-patterns doc indexed in [AGENTS.md](../AGENTS.md) for the laws; `Clarify` (the agent asks back — selectable
311
311
  `ChoiceList` options), `Sources` (provenance chips for AI output — at review scale,
312
312
  `label={null}` slots the chips at a section's bottom), `Finding` (one ranked insight from an
313
313
  AI check — localized severity word, title, detail, `Sources` chips, a `children` slot;
@@ -1328,7 +1328,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1328
1328
 
1329
1329
  - **`mime`** — pure MIME predicates: `isImageMimeType` / `isVideoMimeType` /
1330
1330
  `isAudioMimeType` / `isPdfMimeType` / `isExcelMimeType` / `isCsvMimeType` /
1331
- `isDocxMimeType` / `isPreviewableMimeType`.
1331
+ `isDocxMimeType` / `isPreviewableMimeType`. Plus
1332
+ **`resolveMimeType(declaredType, filename)`** — what to TREAT a file as. An
1333
+ honestly-declared type is returned untouched; an *uninformative* one (`""`,
1334
+ `application/octet-stream` and friends — what an OS clipboard or drag hands
1335
+ over) is resolved from the filename extension instead, falling back to the
1336
+ declaration when the extension is unknown too. It returns the same answer the
1337
+ server stores, so a surface that filters on it agrees with what gets persisted.
1332
1338
  - **`download`** — `downloadFileFromUrl(url, filename, { credentials? })`: fetch+blob+anchor
1333
1339
  download that works inside sandboxed iframes (where `window.open` is silently dropped);
1334
1340
  `credentials` defaults to `same-origin` — pass `"include"` only for auth-gated same-site
@@ -1397,7 +1403,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1397
1403
  the single in-flight upload tile `FileGrid` renders; reach for it only when hand-rolling a
1398
1404
  non-grid upload layout — it is i18n-free by design, so a bare call site owes it `labels`
1399
1405
  (`FileGrid` resolves them from `LoticsLocale.fileUpload` on your behalf).
1400
- - **`file_thumbnail`** — `FileThumbnail` + `DisplayFile` + `THUMBNAIL_SIZE` /
1406
+ - **`file_thumbnail`** — **`diff`** puts a `DiffKind` corner mark on a tile (top-LEFT, the one
1407
+ corner remove/selection/uploading do not claim) and fades a `removed` one. The mark rides a
1408
+ white RING: a tile's ground is an arbitrary photo, and a pale tint over a pale scan is a
1409
+ smudge. A `removed` tile fades its BODY only — the mark stays at full strength, since a faded
1410
+ mark is the smudge the ring exists to prevent. Ignored below 32px: a 22px disc on a compact
1411
+ tile is the tile, and a compact strip lives inside a row that carries the change instead. A grid says MEMBERSHIP well (arriving, leaving) and PAIRING not at
1412
+ all — nowhere on a 96px tile to name what a replacement supersedes — so a set where things
1413
+ are being REPLACED wants `FileRow`. Also: `FileThumbnail` + `DisplayFile` + `THUMBNAIL_SIZE` /
1401
1414
  `COMPACT_THUMBNAIL_SIZE` + `getMediaIcon`: the completed tile — the right surface per
1402
1415
  MIME: image thumbnail, a doc tile with the `FileBadge` centered + a single-line filename
1403
1416
  , media card; `isTemplate` overlays a TMPL marker. The tile's accessible name is the
@@ -1405,14 +1418,26 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1405
1418
  pieces are exported for a hand-rolled layout: `DocumentBadge` (a bare pressable badge —
1406
1419
  its `size` is the square SLOT side, like every other tile here, and the badge is fitted to
1407
1420
  it), `DocumentCard`, `MediaCard`, `RemoveButton`.
1408
- - **`file_thumbnail_grid`** — `FileThumbnailGrid` (+ the generic `ThumbnailGrid`):
1421
+ - **`file_thumbnail_grid`** — **`diffs`** (a `ReadonlyMap<string, DiffKind>`, keyed like
1422
+ `selectedIds`) marks each tile, so a grid under review is the SAME grid with one more map
1423
+ passed in rather than a different component the operator has never seen.
1424
+ `FileThumbnailGrid` (+ the generic `ThumbnailGrid`):
1409
1425
  display-only square-tile grid; `selectedIds` + `onFilePress` pair with
1410
1426
  `useSelectionMode()` for gated select-mode CRUD.
1411
1427
  - **`file_row`** — `FileRow`: a horizontal file/document LINE — `FileBadge` or a
1412
- `placeholder` + the FULL name + a meta line + a composable `trailing` slot; `onPress`
1413
- makes the whole row a pressable door, `trailing` stays an independently-pressable sibling;
1414
- for attachment lists / message files / document checklists where a square tile truncates
1415
- the name.
1428
+ `placeholder` + the FULL name + a meta line + composable `leading` / `trailing` slots;
1429
+ `onPress` makes the whole row a pressable door, `trailing` stays an independently-pressable
1430
+ sibling; for attachment lists / message files / document checklists where a square tile
1431
+ truncates the name. **`leading`** sits BEFORE the badge — a `DiffMark` for a document set
1432
+ under review, a select checkbox, a status dot; outside the badge rather than drawn on it, so
1433
+ it lines up down the list instead of moving with the badge's `sm`/`md` size. **`meta` takes a
1434
+ NODE** as well as a string, because a second line can itself be carrying a change (a file
1435
+ being RECLASSIFIED is the same bytes filed differently — a `DiffValue` on the property); a
1436
+ node renders outside the muted single-line `Text`, which would clip it and fight its colour.
1437
+ Both `sm` variants share ONE beat (48px min + 6px vertical padding): the static row used to
1438
+ have neither, so it came out 37px against the pressable's 49 — whether a row was a door
1439
+ decided how tall it was — and 37 sat under every other row beat in the kit (`DetailRow` 40,
1440
+ `TableRow` 52, this component's own `md` 56), which is what "squeezed" literally was.
1416
1441
  - **`file_rows`** — `FileRows`: batteries-included file list — row press → built-in gallery
1417
1442
  + a ⋯ Download/Open-external/Remove menu; composes `FileRow` + `ActionMenu` +
1418
1443
  `FileGalleryModal`; localized via the `gallery` locale slice.
@@ -1514,17 +1539,67 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1514
1539
  be rendered; where a compact meter used to decorate a ranked row or proposal card, rank /
1515
1540
  badges / severity carry the standing instead. Also: calibrated
1516
1541
  high/med/low; localized via the provider.
1517
- - **`change_review`** — the COMPOUND review family frame: `ChangeReview` provider/stack,
1518
- `ChangeReviewHeader` (auto kept-counter over decidable entries), `ChangeReviewActions`
1519
- (the commit bar in the DialogFooter/DrawerFooter: Keep-all bottom-left (`onAcceptAll` for
1520
- host-held field state) + Apply gating); sections: `Change` (host-owned status, labeled
1521
- verbs, collapses to its `ChangeSummary` + Undo; no callbacks = display-only),
1522
- `ChangeLabel`, `ChangeSummary`, `ChangeReasoning` (the quiet why); grammar:
1523
- `ChangeFields` (the open record form) + `ChangeField` (THE field: band, value,
1524
- candidates + type-another-value, reasoning, per-field Keep/Drop, collapse),
1525
- `ChangeRecord` (THE item card: registers like a Change; tone wash + localized op word;
1526
- verb level follows the decision level), `ChangeBand` (the raw ± band),
1527
- `ChangeValueInput` (the diff-at-rest editor); the `changeReview` locale slice.
1542
+ - **`diff_value`** — **`DiffValue`, THE atom of a review surface**: a value that changed,
1543
+ rendering `before` struck above/before `after`. Owns no container, no context, no decision —
1544
+ so it drops into a table cell, a `DetailRow`, a total, a chip or a sentence and inherits that
1545
+ container's width and alignment. Either side may be a NODE — a `MemberChip` for a reassignment,
1546
+ a chip for a link being repointed — and a node `before` is struck by a DRAWN rule rather than
1547
+ by text decoration, because `line-through` on a `Text` does not cross a `View` child (the old
1548
+ value would otherwise render at full strength beside its replacement with nothing saying it is
1549
+ the one going away — type-checked, rendered, and wrong). Reach for a node when the value
1550
+ genuinely is not text: an avatar carries identity a string cannot, and a review that swapped
1551
+ the record's own chip for a name would show the operator a surface they have never seen at the
1552
+ one moment they are asked to check something. A node belongs where it fits at FULL size — a
1553
+ form row, a card. Shrinking a composite into a table cell takes its own type off the scale
1554
+ (a file badge sized to a cell rendered its label at 5px), so a file in a CELL is a filename at
1555
+ the column's own size, not a miniature badge. Worked in `dev/pages/ai.tsx` § every shape.
1556
+ **`before === after` collapses to ONE plain value** —
1557
+ agreement is not a change, and a document confirming what the record already says is the
1558
+ GOOD case, not a diff of a value against itself. **`delta`** (a host-formatted string, the
1559
+ same contract as `FindingComparison.delta`) prints HOW FAR it moved, because
1560
+ `1.600.000 → 1.481.481` otherwise makes the reader subtract to find out whether a
1561
+ correction is trivial or alarming. `layout` stacked (a column of figures) | inline (prose, dense
1562
+ rows); `align`/`tabular` for money; `tone` for whether the change is good news. Omit `before`
1563
+ for an addition, `after` for a removal. BOTH values stay on screen: the old one is the only
1564
+ evidence the reader has that the correction is the right SIZE.
1565
+ **`placeholder` is YOUR string — translate it.** The component renders no word of its own
1566
+ (its only fallbacks are `—` and empty), so nothing here reaches the locale packs. That is
1567
+ deliberate rather than an omission: `DiffMark` beside it DOES ship localized words, but the
1568
+ two are different sentences even when English collides them — the mark says what happened to
1569
+ the ROW ("Removed" / "Xóa"), the placeholder says what is in the FIELD now, which reads "đã
1570
+ xóa" or "bỏ trống", never the bare verb. The same prop also carries "Pick a candidate below"
1571
+ and "Not on the document". Pull it from your own catalog.
1572
+ - **`diff_mark`** — **`DiffMark`**: what happened to the row holding the value —
1573
+ `added`/`changed`/`removed`/`unchanged`, for the cases no single value carries. Glyph + an
1574
+ accessible word (never colour alone — "added" vs "removed" by hue is invisible to a reader who
1575
+ cannot separate them, and that is the distinction a review turns on). **ONE treatment
1576
+ everywhere: a filled 22px DISC**, 14px glyph, 700 ink on a 100 ground. It briefly had two — a
1577
+ bare glyph for dense surfaces, a filled shape for sparse ones — and a form marked with bare
1578
+ glyphs beside a table marked with filled shapes read as two systems saying the same thing; a
1579
+ reader parses shape before meaning, so an unexplained difference reads as an accident however
1580
+ principled the reason. The disc also gives the glyph its own ground, which is what lets one
1581
+ mark sit on a white row, a tinted cell, or a photograph. **`unchanged` renders as an empty
1582
+ disc-sized HOLE** — silence is the honest rendering of "nothing happened", it keeps the
1583
+ column's width so nothing shifts left, and it announces nothing (eleven "Unchanged"es is the
1584
+ same noise to a screen reader that a dot is to an eye). `showLabel` puts the word OUTSIDE the
1585
+ disc; text crammed inside stops it being a circle, and the shape is what makes it findable
1586
+ without reading. Leads a table row, sits in a card corner, precedes a field label.
1587
+ - **`inline_slot`** — **`InlineSlot`**: any node placed on the INLINE-CONTROL grid — the box
1588
+ an `Inline*` editor draws (40px band, 8px horizontal padding inside a 1px transparent border),
1589
+ minus the editor. A record's value column mixes editors with things that are not editors, and
1590
+ anything rendered raw beside them starts at the cell edge, 20px tall, at the top — so the
1591
+ column quietly acquires TWO left edges and two baselines, and `DetailRow`'s label (which pads
1592
+ down to meet a control) agrees with one kind of row and not the other. `InlineStatic` is the
1593
+ STRING case and composes it; reach for the slot when the value is a node, which a review
1594
+ surface always is. NOT for a table cell — a `Table` sets its own row rhythm.
1595
+ - **`use_change_set`** — **`useChangeSet(ids, { initial })`**: the accept/reject/undo
1596
+ bookkeeping as a HOOK, zero layout — `status`/`accept`/`reject`/`undo`/`acceptAll`/`reset`,
1597
+ `accepted`/`rejected`/`pending`, `keptCount`/`total`/`settled`. Decisions are stored as
1598
+ OVERRIDES against a default, never seeded from `ids` into state (seeding needs an effect, and
1599
+ `ids` is normally a memo over query rows — a new array every render, which is the
1600
+ `Maximum update depth exceeded` loop). `initial` defaults to `accepted` so the operator drops
1601
+ exceptions instead of approving eight identical lines; pass `pending` when each change deserves
1602
+ its own verdict and gate the commit on `settled`.
1528
1603
  - **`clarify`** — `Clarify` + `ClarifyOption`: the agent asks back — a borderless block
1529
1604
  (the question text + a `ChoiceList`, no card wrapper; an optional muted `eyebrow` sits tight above
1530
1605
  the question — e.g. a wizard's "1 / 3"). `ClarifyOption` requires