@lotics/ui 15.2.1 → 16.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -15,8 +15,8 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` for a custom-code app's `vite.config.ts`). Read before building any screen; reuse first. |
18
- | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates. |
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 (modify → review-before-apply; create → save-direct + result list), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
18
+ | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. 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 (modify → review-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). |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
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
 
package/MIGRATION.md CHANGED
@@ -4,6 +4,19 @@ 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
+ ## v16 from 15.x
8
+
9
+ **`Confidence` IS now a callout with a REQUIRED basis** — `children` (what was checked and
10
+ what came of it) is mandatory, the meter + level phrase head the block, and the box stays
11
+ white with a hairline border at every level (the meter is the only colored element). The
12
+ standalone three-tick meter no longer exists: a bare `<Confidence level="high" />` fails to type-check. Migrate by
13
+ adding the basis (`<Confidence level="low">2 of 7 references fail their checksum (X, Y) —
14
+ verify against the source.</Confidence>`) — or, where a compact meter decorated a ranked row
15
+ or a proposal card, remove it: rank, badges, and severity already convey standing there.
16
+
17
+ Additively in v16: `ResultHeader` (`@lotics/ui/result_header`) — the save-direct receipt's
18
+ outcome strip; see ai_patterns §the one law for the receipt composition.
19
+
7
20
  ## v15 from 14.x
8
21
 
9
22
  `AgentRun`/`AgentProgress`'s **`labelForTool`** prop was replaced by **`labelForCall`** — the
@@ -18,21 +18,31 @@ the human stays in charge follows the stakes — split by whether a before-state
18
18
  - **Modifying an existing record** — a diff exists, and a wrong value can clobber a right one →
19
19
  review BEFORE apply: the `ChangeReview` family, Keep/Drop per change, nothing auto-applies.
20
20
  - **Creating new records** (intake, import, drafting rows) — there is no before-state, so there
21
- is nothing to diff → **apply directly, then show the result**. The run ends on a per-item
22
- result list — the receipt, not a gate. A result row is a plain composition (no component —
23
- hand-roll it): the outcome mark (`success` = `circle-check` green-600 · `error` =
24
- `circle-alert` red-600 · needs-attention = `circle-alert` amber-600 · skipped = `minus`
25
- zinc-400, size 18) · outcome-first title (sm semibold) · the key figures / the reason (xs,
26
- danger ink on error) · one-line provenance (xs muted) · a right-slot `Button` (an
27
- open-record jump, a retry); rows stack with `gap: 12`. A batch-level failure lands on
28
- EVERY row as its reason never ALSO paint it as a banner above the list (a top `Callout`
29
- is only for information the rows don't carry). The closing footer button is
30
- dismissal (secondary "Done"), never primary. Correction uses
31
- the record's ORDINARY verbs open it, edit or delete (make the delete workflow CASCADE the
32
- record's children, so deletion is always safe) never a special undo affordance duplicating
33
- the delete verb in a second place. A review gate on a create degenerates into proofreading a
34
- wall of cards the user blind-confirms; the register shows the same data better. Fewer steps,
35
- same control.
21
+ is nothing to diff → **apply directly, then end on the RECEIPT**
22
+ (`@lotics/ui/result_header`) — the receipt states the outcome and ROUTES; it is never a
23
+ record editor. Run DETERMINISTIC checks over what the save STORED (a checksum, a count
24
+ reconciliation binary math, never a fabricated score). ONE record: `ResultHeader` (tone ·
25
+ outcome title) the RECEIPT LINES a `DetailTable` of spread `DetailRow`s (who · how
26
+ much · how many · the schedule) **`Confidence`**: the level WITH its basis as
27
+ one block, always (the happy path says what was checked; the flagged path NAMES the exact
28
+ failing valuesthe user knows what to verify BEFORE opening). Confidence never floats
29
+ alone as a bare meter on a result surface. NOTHING in the receipt edits: the RECORD is the edit surface (its
30
+ flagged cells carry the same amber marks), one press away — correction uses the record's
31
+ ordinary verbs. SEVERAL records: a compact attention-first RESULT REGISTER (`ListItem` per
32
+ record tone mark · outcome title · key figures · the needs-checking count) whose rows
33
+ press STRAIGHT THROUGH to the record no intermediate screen. Never tabs (they cap out),
34
+ never stacked collapsed cards, never a field wall duplicating the record page. A
35
+ flagged value names its exact verdict in amber (a reason line under the value, or a check
36
+ column cell) and is corrected IN PLACE — `InlineTextInput.onSave` commits immediately
37
+ through the ordinary update workflow (the record is saved; nothing stages, no apply button
38
+ exists). A batch-level failure lands on EVERY record as its reason — never ALSO a
39
+ banner above the list (a top `Callout` is only for information the records don't carry).
40
+ The closing footer button is dismissal (secondary "Done"), never primary. Correction beyond
41
+ the receipt uses the record's ORDINARY verbs — open it, edit or delete (make the delete
42
+ workflow CASCADE the record's children, so deletion is always safe) — never a special undo
43
+ affordance duplicating the delete verb. A review GATE on a create degenerates into
44
+ proofreading a wall of cards the user blind-confirms — and a human cannot eyeball-verify a
45
+ reference number anyway; deterministic post-save checks catch what proofreading cannot.
36
46
 
37
47
  Either way the deterministic app applies the write. Compose the surfaces as a loop, and reach
38
48
  for the right one by job.
@@ -44,7 +54,8 @@ Three shapes:
44
54
  1. **Field writes** (extract, match, rank, classify → records change) → **in-app agent**
45
55
  (`useAgentRun`), then split by the one law: modifying an existing record → **`ChangeReview`**
46
56
  (Keep/Drop per change, app-workflow writes, bounded app authority); creating new records →
47
- **save direct + a result list** (correction = the record's ordinary verbs). Either way one button, never a prompt box.
57
+ **save direct + the receipt** (outcome, verdicts, and a route to the record).
58
+ Either way one button, never a prompt box.
48
59
  2. **Evidence for an in-app decision** (cross-check, audit, tie-out → nothing is written; the
49
60
  operator acts on what was found) → **in-app agent** + **`Finding`**. Still bounded and
50
61
  prompt-free (an optional instructions brief at most): the brief is fixed, the output is
@@ -371,12 +382,17 @@ list renders nothing.
371
382
 
372
383
  ## Confidence — `Confidence`
373
384
 
374
- `Confidence` (`@lotics/ui/confidence`): how sure the AI is a 3-tick meter + the full level
375
- phrase ("High confidence" / "Medium confidence" / "Low confidence"), emerald / amber / zinc by
376
- level (low is *unsure*, not an error). Pass `level` (`"high" | "medium" | "low"`) or a 0–1
377
- `score` (≥ 0.8 high · 0.5 medium · else low; neither given defaults to medium). Phrases resolve
378
- prop `labels` the `confidence` locale slice English one phrase per level, so it translates
379
- cleanly. The human weights an AI proposal by it; pair with `ChangeReview`.
385
+ Confidence IS a callout (`@lotics/ui/confidence`) the ONLY form it takes: the three-tick
386
+ meter + level phrase head the block, and the REQUIRED `children` carry the basis what was
387
+ checked and what came of it ("all 7 references pass their checksum" / "2 of 7 fail (X, Y) —
388
+ verify against the source"). The box stays white with a hairline border at EVERY level the
389
+ meter is the only colored element (the level already lives in the meter and the phrase; a
390
+ tinted fill would say it a third time). Pass `level` or a 0–1 `score` (≥0.8 high ·
391
+ ≥0.5 medium · else low; `levelFromScore` exported). There is NO standalone meter: a level
392
+ without its reasons cannot be acted on, so it cannot be rendered — and where a compact meter
393
+ used to decorate a ranked row or a proposal card, remove it (rank, badges, and severity
394
+ already convey standing). Derive levels HONESTLY (deterministic checks: all pass → high, any
395
+ fail → low) — never render a fabricated score.
380
396
 
381
397
  ## Findings — evidence, not writes
382
398
 
@@ -449,10 +465,9 @@ ranked set, don't cram it into chat prose:
449
465
  register's New CTA leads with AI: a `FileDropzone` hero → a short ANALYZE run reads the files →
450
466
  `ClarifyWizard` asks the ambiguities the analysis surfaced (its Cancel/Back/Next/Submit riding
451
467
  the `DialogFooter` via `ClarifyWizardScope` + `ClarifyWizardActions`) → the IMPORT run
452
- **creates the records and the dialog ends on the RESULT LIST** — one result row per record
453
- (what landed, the key figures the hand-rolled anatomy in the one law above) — per the one
454
- law's creation branch: no review gate, the register IS the review, correction is the
455
- record's ordinary verbs. The manual VARIANTS sit below the dropzone
468
+ **creates the records and the dialog ends on the RECEIPT** — one record: summary + named
469
+ verdicts + the open-record action; several: an attention-first register pressing through
470
+ to the records (the one law's creation branch). The manual VARIANTS sit below the dropzone
456
471
  as `CardSelectItem`s, each opening the classic create-then-refine form. The template mocks the
457
472
  clarify step as TWO app-orchestrated phases (analyze-run → wizard → import-run); a real app
458
473
  can do it in ONE run — every app agent carries `ask_user_choice`, so the run PARKS on the
package/docs/catalog.md CHANGED
@@ -611,7 +611,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
611
611
  `controls="buttons"`; KEYBOARD focus on the closed view opens edit mode with the input
612
612
  focused (type → Tab → type — see the data-entry keyboard contract), pointer focus never
613
613
  does; **`variant: "form" | "cell"`** is THE axis that separates a form field from a data-grid
614
- cell (see below).
614
+ cell (see below). Every commit registers in **`pending_commits`**, which
615
+ `Button`/`IconButton` wait on via **`use_gated_press`** so an action pressed in the same
616
+ gesture as the blur cannot read the record before the edit lands (data_entry.md § Inline
617
+ edit) — automatic, nothing to pass.
615
618
  - **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units),
616
619
  **`inline_select`**, **`inline_member_select`**, **`inline_date_picker`**
617
620
  (`format="datetime"`, `optionalTime`; keyboard focus opens the TYPED segmented `DateField`
@@ -1065,7 +1068,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1065
1068
  `AgentProgress`'s expanded panel uses it. Inherits the inverted pattern's quirks (scrollbar
1066
1069
  thumb runs opposite; keyboard paging inverted) — same as chat. A full-page chat list is
1067
1070
  already inverted and doesn't need it.
1068
- - **`confidence`** — `Confidence` + `ConfidenceLevel` + `levelFromScore`: calibrated
1071
+ - **`confidence`** — `Confidence` + `ConfidenceLevel` + `levelFromScore`: confidence IS a
1072
+ callout — the meter + level phrase head the block, the REQUIRED `children` carry the basis
1073
+ (what was checked, what failed); the box stays white with a hairline border at every
1074
+ level — the meter is the only colored element. There is NO standalone meter — a level without its reasons cannot
1075
+ be rendered; where a compact meter used to decorate a ranked row or proposal card, rank /
1076
+ badges / severity carry the standing instead. Also: calibrated
1069
1077
  high/med/low; localized via the provider.
1070
1078
  - **`change_review`** — the COMPOUND review family — frame: `ChangeReview` provider/stack ·
1071
1079
  `ChangeReviewHeader` (auto kept-counter over decidable entries) · `ChangeReviewActions`
@@ -1101,6 +1109,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1101
1109
  - **`finding`** — `Finding` + `FindingComparison` + `FindingSeverity`/`FindingLabels`: one
1102
1110
  ranked AI-check insight — severity word · title · detail · `Sources` · children slot; the
1103
1111
  expected-vs-actual body with the emphasized delta; the `finding` locale slice.
1112
+ - **`result_header`** — `ResultHeader` (+ `ResultTone`): the save-direct RECEIPT's outcome
1113
+ strip, on the page grid — tone mark (`ok`/`attention`/`error`/`skipped`) inline on the
1114
+ title row · outcome-first title · right-slot `action` (the open-record jump / a retry) ·
1115
+ ONE line under (the honest `Confidence`, or the failure reason). The receipt composes:
1116
+ header → the receipt lines (a `DetailTable` of spread `DetailRow`s — label left, value at
1117
+ the right edge) → a `Confidence` callout naming the exact values that failed their
1118
+ deterministic checks (or stating what passed). NOTHING in a
1119
+ receipt edits — the RECORD is the edit surface, one press away. SEVERAL records → an
1120
+ attention-first register of `ListItem` rows (tone mark · title · figures · needs-checking
1121
+ count) pressing straight through to the records. See ai_patterns §the one law.
1104
1122
  - **`comments_thread`** — `CommentList` + the
1105
1123
  `ThreadComment`/`ThreadMember`/`ThreadFile` types: the record comments thread.
1106
1124
  Composing is THE kit `Composer` (never a bespoke comment box): attach via its
@@ -49,6 +49,31 @@ chip, hover reveals the input-family border (no extra grey wash, no pencil icon
49
49
  layout), click OR keyboard focus swaps the input in **at the same height** (zero reflow, the whole
50
50
  point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓ / ✕).
51
51
 
52
+ **Commit-on-blur would race the press that caused it — the kit closes that race for you.**
53
+ Pressing an action while an inline field is focused starts TWO things in ONE gesture: the
54
+ field's write (the blur, on mousedown) and the button's handler (on mouseup). A handler that
55
+ RE-READS the record — to print a document, mint an invoice, copy the row into a new one — would
56
+ overtake the still-in-flight write and act on the PRE-EDIT value, silently: the new value on
57
+ screen, the old one in the output. So **`useInlineEdit` registers every commit and
58
+ `Button`/`IconButton` hold their press until the commits already in flight have settled**
59
+ (`pending_commits.ts`), showing their busy spinner while they wait. Nothing to wire up: with no
60
+ commit pending the press dispatches synchronously exactly as before, and a FAILED commit
61
+ releases the gate too (the field surfaces its own error). A commit started after the press was
62
+ taken is deliberately not waited for — the gate holds a press for the edit that caused it, never
63
+ for later work.
64
+
65
+ **What the gate cannot see — an action must take an ID, not a captured object.** Holding the
66
+ press makes the STORE current; it cannot refresh a value the handler already closed over.
67
+ `onPress={() => onIssue(invoice)}` captures the invoice as it was at press time, so the
68
+ confirm dialog quotes — and the mint bills — the pre-edit total no matter how long the gate
69
+ waits. Pass the KEY and resolve the object where it is used
70
+ (`onPress={() => onIssue(invoice.key)}`, then `invoices.find(…)` at render): worked in
71
+ `tpl_record`'s Billing band, whose charge cells are inline editors and whose Issue is
72
+ irreversible. The same rule kills the variant with no race at all — a handler reading a server
73
+ row the screen copied into `useState` (`app-sdk` `docs/mutations.md` § A read must not overtake
74
+ an in-flight write) — as does a write started by something other than an inline commit (a
75
+ dialog save, a debounced autosave).
76
+
52
77
  ### Keyboard entry — type → Tab → type
53
78
 
54
79
  Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
package/docs/templates.md CHANGED
@@ -329,7 +329,10 @@ billing, and quick-capture templates. Top → bottom:
329
329
  scope, once — a band-local problem (charged line missing its method) in the band, a broken
330
330
  record premise (no customer / invalid tax ID) at the section top; self-evident empties
331
331
  stay silent, and NEVER prose beside a CTA. Issuing gates on record premises only — never
332
- silently on stage.
332
+ silently on stage. It is also the worked example of **an irreversible action taking an ID,
333
+ not a captured object**: `onIssue(inv.key)` with the invoice resolved where the confirm
334
+ renders, so the quoted total is the one that will be billed even when the press was held
335
+ for a charge cell's write (data_entry.md § Inline edit).
333
336
  - **Document set — the OUTPUT desk, the last WORK section** (the composition rules' output
334
337
  law worked on the record surface: the top is intake, the bottom produces on demand).
335
338
  Forms group PER PARTY on the `SubsectionStack` beat — each party a `SubsectionHeading` +
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import type { UIMessagePart, UIDataTypes, UITools } from "ai";
4
4
  import { Text } from "@lotics/ui/text";
5
- import { colors, type ColorName } from "@lotics/ui/colors";
5
+ import { colors, solid, type ColorName } from "@lotics/ui/colors";
6
6
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
7
7
  import { Alert } from "@lotics/ui/alert";
8
8
  import { Badge } from "@lotics/ui/badge";
@@ -55,6 +55,8 @@ import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea
55
55
  import { AgentRun } from "@lotics/ui/agent_run";
56
56
  import { FollowScroll } from "@lotics/ui/follow_scroll";
57
57
  import { ClarifyWizard, ClarifyWizardActions, ClarifyWizardScope, type ClarifyWizardAnswer, type ClarifyWizardQuestion } from "@lotics/ui/clarify_wizard";
58
+ import { ResultHeader } from "@lotics/ui/result_header";
59
+ import { Confidence } from "@lotics/ui/confidence";
58
60
  import { CardSelectItem } from "@lotics/ui/card_select_item";
59
61
  import { FileDropzone } from "@lotics/ui/file_dropzone";
60
62
  import { FileDropTarget } from "@lotics/ui/file_drop_target";
@@ -1141,8 +1143,12 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1141
1143
  // on the created records (create + link, leave unlinked, or the user's own
1142
1144
  // instruction via the custom slot).
1143
1145
  const [customerPlan, setCustomerPlan] = useState("New customer created and linked");
1144
- // the result list — what the import CREATED, one entry per record
1145
- const [landed, setLanded] = useState<{ title: string; detail: string }[]>([]);
1146
+ // the RECEIPT — what the import CREATED. A real app READS BACK what the
1147
+ // save stored (values + record ids) and runs its DETERMINISTIC checks over
1148
+ // the stored values (a reference checksum, a count reconciliation). ONE
1149
+ // record renders its receipt directly; several render the attention-first
1150
+ // register whose rows press straight through to the records.
1151
+ const [landed, setLanded] = useState<{ id: string; title: string; detail: string; ref: string; refFlagged: boolean; fee: string }[]>([]);
1146
1152
  // form state (the manual gate)
1147
1153
  const [khach, setKhach] = useState("");
1148
1154
  const [dienThoai, setDienThoai] = useState("");
@@ -1182,13 +1188,20 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1182
1188
  setTimeout(() => {
1183
1189
  const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
1184
1190
  onCreateMany(proposals.map((p) => ({ khach: p.khach, dienThoai: p.dienThoai, phi: p.phi })));
1185
- setLanded(proposals.map((p) => ({
1191
+ const rows = proposals.map((p) => ({
1192
+ id: p.id,
1186
1193
  title: grouping === "customer" ? p.khach : `${p.orders} — ${p.khach}`,
1187
1194
  // The wizard's answers are load-bearing: the unknown customer's row
1188
1195
  // states how it landed (created + linked / left unassigned / the
1189
1196
  // user's own instruction).
1190
- detail: [p.orders, p.dienThoai, formatMoney(p.phi), p.khach === "Blue Harbor Foods" ? customerPlan : ""].filter(Boolean).join(" · "),
1191
- })));
1197
+ detail: [p.dienThoai, formatMoney(p.phi), p.khach === "Blue Harbor Foods" ? customerPlan : ""].filter(Boolean).join(" · "),
1198
+ ref: p.orders,
1199
+ // The mock flags one record's reference (a failed checksum); a
1200
+ // real app computes this over the STORED value.
1201
+ refFlagged: p.khach === "Blue Harbor Foods",
1202
+ fee: String(p.phi),
1203
+ }));
1204
+ setLanded(rows);
1192
1205
  setPhase("done");
1193
1206
  }, 500);
1194
1207
  }
@@ -1338,28 +1351,61 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1338
1351
  ) : null}
1339
1352
 
1340
1353
  {phase === "done" ? (
1341
- // The RESULT LIST one row per created record: mark · what landed ·
1342
- // the key figures. The receipt, not a gate: correcting a record uses
1343
- // its ORDINARY verbs (open it in the register, edit or delete — the
1344
- // app's cascade delete keeps that safe), never a special undo here.
1345
- // The outcome-mark vocabulary (ai_patterns §the one law): success =
1346
- // circle-check green-600 · error = circle-alert red-600 · attention =
1347
- // circle-alert amber-600 · skipped = minus zinc-400. A real app adds
1348
- // an open-record jump / retry Button on the row's right.
1354
+ // The RECEIPT (ai_patterns §the one law): states the outcome and
1355
+ // routes NOTHING here edits; the RECORD is the edit surface,
1356
+ // one press away (correction uses the record's ordinary verbs).
1357
+ // ONE record: header + the big-four summary + amber verdicts naming
1358
+ // the exact failing values. SEVERAL: an attention-first register
1359
+ // whose rows press straight through to the record.
1349
1360
  <DialogScrollArea>
1350
- <View style={{ gap: 12 }}>
1351
- {landed.map((l, i) => (
1352
- <View key={i} style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
1353
- <View style={{ paddingTop: 2 }}>
1354
- <Icon name="circle-check" size={18} color={colors.green[600]} />
1355
- </View>
1356
- <View style={{ flex: 1, gap: 2 }}>
1357
- <Text size="sm" weight="semibold">{`Created ${l.title}`}</Text>
1358
- <Text size="xs" color="muted" numberOfLines={1}>{l.detail}</Text>
1359
- </View>
1361
+ {(() => {
1362
+ // The receipt lines — DetailRow spread rows (label left, value
1363
+ // right), the kit's paper-receipt look; a warning Callout names
1364
+ // the failing values. Read-only: the record behind the dialog
1365
+ // is the edit surface.
1366
+ const receipt = (l: (typeof landed)[number]) => (
1367
+ <View style={{ gap: 16 }}>
1368
+ <ResultHeader
1369
+ tone={l.refFlagged ? "attention" : "ok"}
1370
+ title={`Created ${l.title}`}
1371
+ />
1372
+ <DetailTable>
1373
+ <DetailRow label="Customer"><Text size="sm" weight="medium">{l.title}</Text></DetailRow>
1374
+ <DetailRow label="Orders"><Text size="sm" weight="medium">{l.ref}</Text></DetailRow>
1375
+ <DetailRow label="Service fee"><Text size="sm" weight="medium" tabular>{formatMoney(Number(l.fee) || 0)}</Text></DetailRow>
1376
+ </DetailTable>
1377
+ {l.refFlagged ? (
1378
+ <Confidence level="low">
1379
+ Order reference PO-7313 fails its checksum — verify against the source file; the flagged value is marked on the record.
1380
+ </Confidence>
1381
+ ) : (
1382
+ <Confidence level="high">
1383
+ All automated checks passed: the order references validate and the totals reconcile.
1384
+ </Confidence>
1385
+ )}
1360
1386
  </View>
1361
- ))}
1362
- </View>
1387
+ );
1388
+ if (landed.length === 1) return receipt(landed[0]);
1389
+ return (
1390
+ <View style={{ gap: 2 }}>
1391
+ {[...landed].sort((a, b) => Number(b.refFlagged) - Number(a.refFlagged)).map((l) => (
1392
+ <ListItem
1393
+ key={l.id}
1394
+ left={<Icon name={l.refFlagged ? "circle-alert" : "circle-check"} size={18} color={l.refFlagged ? solid("amber") : solid("green")} />}
1395
+ title={`Created ${l.title}`}
1396
+ description={l.detail}
1397
+ right={
1398
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1399
+ {l.refFlagged ? <Text size="xs" weight="medium" style={{ color: solid("amber") }}>1 to check</Text> : null}
1400
+ <Icon name="chevron-right" size={16} color={colors.zinc[400]} />
1401
+ </View>
1402
+ }
1403
+ onPress={() => { close(); }}
1404
+ />
1405
+ ))}
1406
+ </View>
1407
+ );
1408
+ })()}
1363
1409
  </DialogScrollArea>
1364
1410
  ) : null}
1365
1411
 
@@ -229,8 +229,6 @@ export function TplLookup() {
229
229
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
230
230
  <Text size="sm" weight="semibold" tabular numberOfLines={1}>{c.code}</Text>
231
231
  {c.recommended ? <Badge color="blue" label="Recommended" /> : null}
232
- <View style={{ flex: 1 }} />
233
- <Confidence level={c.confidence} />
234
232
  </View>
235
233
  <Text size="xs" color="muted" numberOfLines={1}>{c.label}</Text>
236
234
  </PressableHighlight>
@@ -256,12 +254,19 @@ export function TplLookup() {
256
254
  <View style={{ gap: 4 }}>
257
255
  {/* verdict header — open, no card box: the panel IS the answer */}
258
256
  <View style={{ paddingVertical: 6, gap: 10 }}>
259
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
260
- <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Suggested HS code</Text>
261
- <Confidence level={active.confidence} />
262
- </View>
257
+ <Text size="xs" color="muted" weight="medium">Suggested HS code</Text>
263
258
  <Text size="xxxl" weight="semibold" tabular>{active.code}</Text>
264
259
  <Text size="sm" color="muted">{active.heading}</Text>
260
+ {/* Confidence IS a callout — the level with its basis, never a
261
+ floating meter. Ranked rows carry no meter at all: the rank
262
+ and the Recommended badge already convey standing. */}
263
+ <Confidence level={active.confidence}>
264
+ {active.confidence === "high"
265
+ ? "The description matches the heading notes and both classification criteria."
266
+ : active.confidence === "medium"
267
+ ? "The material matches, but the heading notes leave the use-case ambiguous — check the criteria below."
268
+ : "Only a partial match against the heading notes — treat as a starting point and verify the criteria."}
269
+ </Confidence>
265
270
  </View>
266
271
 
267
272
  <Divider />
@@ -345,7 +345,10 @@ function InvoiceBand({
345
345
  gated: boolean;
346
346
  onAmount: (chKey: string, v: number) => Promise<void>;
347
347
  onMethod: (chKey: string, m: Method) => Promise<void>;
348
- onIssue: (inv: Invoice) => void;
348
+ /** Takes the invoice's KEY, never the `inv` object: the press may be held while
349
+ * a charge cell finishes writing (Button waits out an inline commit), and a
350
+ * captured object would still be the pre-edit snapshot. Resolve at use. */
351
+ onIssue: (invKey: string) => void;
349
352
  }) {
350
353
  const total = invoiceTotal(inv);
351
354
  const state = invoiceStatus(inv);
@@ -410,10 +413,10 @@ function InvoiceBand({
410
413
  {state === "issued" ? (
411
414
  <>
412
415
  <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
413
- <Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv)} />
416
+ <Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv.key)} />
414
417
  </>
415
418
  ) : (
416
- <Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv)} />
419
+ <Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv.key)} />
417
420
  )}
418
421
  </View>
419
422
  </Subsection>
@@ -832,7 +835,9 @@ export function TplRecord() {
832
835
  // ── billing — the bill-to tax ID comes off the CUSTOMER
833
836
  const [invoices, setInvoices] = useState<Invoice[]>(BILLING_INITIAL);
834
837
  const [deposit, setDeposit] = useState(0);
835
- const [confirmIssue, setConfirmIssue] = useState<Invoice | null>(null);
838
+ // The key, not the invoice: what the dialog shows and issues is derived below, so a
839
+ // charge that landed between the press and the confirm is reflected, never a stale total.
840
+ const [confirmIssueKey, setConfirmIssueKey] = useState<string | null>(null);
836
841
  const seq = useRef(414);
837
842
  const taxId = customer?.taxId ?? "";
838
843
  const taxIdValid = TAX_ID_RE.test(taxId);
@@ -1238,6 +1243,9 @@ export function TplRecord() {
1238
1243
  }, 350);
1239
1244
  });
1240
1245
 
1246
+ // Resolved from the CURRENT invoices every render — the confirm dialog quotes the
1247
+ // total that will actually be billed, including a charge that landed after the press.
1248
+ const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
1241
1249
  const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
1242
1250
  const allMissing = invoices.flatMap(missingMethods);
1243
1251
  const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
@@ -1246,7 +1254,7 @@ export function TplRecord() {
1246
1254
  seq.current += 1;
1247
1255
  const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
1248
1256
  setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
1249
- setConfirmIssue(null);
1257
+ setConfirmIssueKey(null);
1250
1258
  logActivity("credit-card", `Invoice ${ref} issued — ${inv.title}`, { description: formatMoney(invoiceTotal(inv)) });
1251
1259
  };
1252
1260
 
@@ -2124,7 +2132,7 @@ export function TplRecord() {
2124
2132
  gated={customer === null || !taxIdValid}
2125
2133
  onAmount={(chKey, v) => saveCharge(inv.key, chKey, { amount: v })}
2126
2134
  onMethod={(chKey, m) => saveCharge(inv.key, chKey, { method: m })}
2127
- onIssue={setConfirmIssue}
2135
+ onIssue={setConfirmIssueKey}
2128
2136
  />
2129
2137
  ))}
2130
2138
 
@@ -2585,7 +2593,7 @@ export function TplRecord() {
2585
2593
  </Dialog>
2586
2594
 
2587
2595
  {/* issuing an e-invoice is irreversible — confirm in a Dialog (stage gate) */}
2588
- <Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssue(null); }}>
2596
+ <Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssueKey(null); }}>
2589
2597
  <DialogHeader>
2590
2598
  <DialogHeaderTitle>{confirmIssue?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
2591
2599
  </DialogHeader>
@@ -2601,7 +2609,7 @@ export function TplRecord() {
2601
2609
  </Callout>
2602
2610
  </View>
2603
2611
  <DialogFooter>
2604
- <Button title="Cancel" color="secondary" onPress={() => setConfirmIssue(null)} />
2612
+ <Button title="Cancel" color="secondary" onPress={() => setConfirmIssueKey(null)} />
2605
2613
  <Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
2606
2614
  </DialogFooter>
2607
2615
  </Dialog>
@@ -31,7 +31,7 @@ import { AgentRun } from "@lotics/ui/agent_run";
31
31
  import { Change, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeSummary, type ChangeStatus } from "@lotics/ui/change_review";
32
32
  import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
33
33
  import { CompletionState } from "@lotics/ui/completion_state";
34
- import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
34
+ import { type ConfidenceLevel } from "@lotics/ui/confidence";
35
35
  import { toISODate } from "@lotics/ui/format_date";
36
36
  import type { UIMessagePart, UIDataTypes, UITools } from "ai";
37
37
 
@@ -442,7 +442,6 @@ function TaskProposal({ proposal, onEdit }: { proposal: Proposal; onEdit: (id: s
442
442
  <View style={{ flex: 1 }}>
443
443
  <InlineTextInput variant="cell" value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
444
444
  </View>
445
- <View style={{ paddingTop: 10 }}><Confidence level={proposal.confidence} /></View>
446
445
  </View>
447
446
  <View style={{ width: 220 }}>
448
447
  <InlineDatePicker variant="cell" value={proposal.due} optionalTime onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "15.2.1",
3
+ "version": "16.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -102,6 +102,7 @@
102
102
  "./finding": "./src/finding.tsx",
103
103
  "./clarify": "./src/clarify.tsx",
104
104
  "./clarify_wizard": "./src/clarify_wizard.tsx",
105
+ "./result_header": "./src/result_header.tsx",
105
106
  "./choice_list": "./src/choice_list.tsx",
106
107
  "./sources": "./src/sources.tsx",
107
108
  "./icon": "./src/icon.tsx",
@@ -123,6 +124,7 @@
123
124
  "./menu_button": "./src/menu_button.tsx",
124
125
  "./menu_list_item": "./src/menu_list_item.tsx",
125
126
  "./pressable_highlight": "./src/pressable_highlight.tsx",
127
+ "./pending_commits": "./src/pending_commits.ts",
126
128
  "./pressable_row": "./src/pressable_row.tsx",
127
129
  "./press_door": "./src/press_door.tsx",
128
130
  "./floating_action_bar": "./src/floating_action_bar.tsx",
@@ -150,6 +152,7 @@
150
152
  "./use_screen_size": "./src/use_screen_size.ts",
151
153
  "./use_auto_grow_height": "./src/use_auto_grow_height.ts",
152
154
  "./use_focus_ring": "./src/use_focus_ring.ts",
155
+ "./use_gated_press": "./src/use_gated_press.ts",
153
156
  "./interaction_modality": "./src/interaction_modality.ts",
154
157
  "./inline_focus": "./src/inline_focus.ts",
155
158
  "./use_hover": "./src/use_hover.ts",
package/src/button.tsx CHANGED
@@ -12,11 +12,12 @@ import { Text } from "./text";
12
12
  import { colors } from "./colors";
13
13
  import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
14
14
  import { Icon, IconName } from "./icon";
15
- import { Ref, useCallback } from "react";
15
+ import { Ref } from "react";
16
16
  import { TextColor } from "./text_utils";
17
17
  import { useTooltip, UseTooltipOptions } from "./tooltip";
18
18
  import { useFocusRing, composeHandler } from "./use_focus_ring";
19
19
  import { getButtonIconColor } from "./button_colors";
20
+ import { useGatedPress } from "./use_gated_press";
20
21
  export type ButtonColor = "primary" | "secondary" | "danger" | "muted" | "danger-secondary";
21
22
  export type ButtonIconPosition = "left" | "right";
22
23
 
@@ -60,20 +61,18 @@ export function Button(props: ButtonProps) {
60
61
  accessibilityLabel,
61
62
  } = props;
62
63
 
63
- const disabledOrLoading = disabled || loading;
64
+ // The press waits out an inline commit the same gesture started (pending_commits.ts)
65
+ // and reports busy while it does — so `busy`, not the `loading` prop alone, drives the
66
+ // spinner, aria-busy, and the double-press block.
67
+ const { handlePress, waiting } = useGatedPress<NativeSyntheticEvent<any>>(onPress);
68
+ const busy = loading || waiting;
69
+ const disabledOrLoading = disabled || busy;
64
70
  const tooltipProps = useTooltip(tooltip);
65
71
  const { focusVisible, focusProps } = useFocusRing();
66
72
 
67
- const handlePress = useCallback(
68
- (event: NativeSyntheticEvent<any>) => {
69
- onPress?.(event);
70
- },
71
- [onPress],
72
- );
73
-
74
73
  const children = (
75
74
  <>
76
- {loading ? (
75
+ {busy ? (
77
76
  <ActivityIndicator color={getButtonIconColor(color)} />
78
77
  ) : (
79
78
  <>
@@ -107,7 +106,7 @@ export function Button(props: ButtonProps) {
107
106
  (typeof tooltip === "string" ? tooltip : tooltip?.text) ||
108
107
  undefined
109
108
  }
110
- aria-disabled={disabledOrLoading || undefined} aria-busy={loading || undefined}
109
+ aria-disabled={disabledOrLoading || undefined} aria-busy={busy || undefined}
111
110
  disabled={disabledOrLoading}
112
111
  style={({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
113
112
  const isPrimary = color === "primary" && !disabled;
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from "react";
1
2
  import { StyleSheet, View } from "react-native";
2
3
  import { colors, solid, type ColorName } from "./colors";
3
4
  import { Text } from "./text";
@@ -6,59 +7,74 @@ import { useLoticsLocale } from "./locale";
6
7
  export type ConfidenceLevel = "high" | "medium" | "low";
7
8
 
8
9
  export interface ConfidenceLabels {
9
- /** The full level phrase — shown beside the meter and read out. Default "High confidence" etc.,
10
- * so the meter reads as what it is, not a bare "High". One phrase per level (translates cleanly). */
10
+ /** The full level phrase — heads the callout and is read out. Default "High confidence" etc.,
11
+ * so the block reads as what it is, not a bare "High". One phrase per level (translates cleanly). */
11
12
  high: string;
12
13
  medium: string;
13
14
  low: string;
14
15
  }
15
16
 
16
- export interface ConfidenceProps {
17
- /** Pass a level directly, or a 0–1 `score` (≥0.8 high · ≥0.5 medium · else low). */
18
- level?: ConfidenceLevel;
19
- score?: number;
20
- /** Translated level phrases. Defaults to English ("High confidence" …). */
21
- labels?: Partial<ConfidenceLabels>;
22
- }
23
-
24
- const FILLED: Record<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
25
- // high emerald, medium amber, low zinc (unsure — not an error). One family per
26
- // level; the fill count AND the colour both carry it.
27
- const COLOR: Record<ConfidenceLevel, ColorName> = { high: "emerald", medium: "amber", low: "zinc" };
28
-
17
+ /** A 0–1 score folded onto the three levels: >=0.8 high · >=0.5 medium · else low. */
29
18
  export function levelFromScore(score: number): ConfidenceLevel {
30
19
  if (score >= 0.8) return "high";
31
20
  if (score >= 0.5) return "medium";
32
21
  return "low";
33
22
  }
34
23
 
24
+ const FILLED: Record<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
25
+ const COLOR: Record<ConfidenceLevel, ColorName> = { high: "green", medium: "amber", low: "red" };
26
+
27
+ export interface ConfidenceProps {
28
+ /** Pass a level directly, or a 0–1 `score` (>=0.8 high · >=0.5 medium · else low). */
29
+ level?: ConfidenceLevel;
30
+ score?: number;
31
+ labels?: Partial<ConfidenceLabels>;
32
+ /** The BASIS — what was checked and what came of it ("all 7 references pass
33
+ * their checksum" / "2 of 7 fail (X, Y) — verify against the source").
34
+ * REQUIRED: a confidence with no explanation is decoration. This component
35
+ * IS a callout; there is no standalone meter. */
36
+ children: ReactNode;
37
+ }
38
+
35
39
  /**
36
- * How sure the AI is a calibrated high / medium / low read as a 3-tick meter
37
- * (emerald / amber / zinc) beside the level word. The fill count and the colour
38
- * both carry the level. The human weights an AI proposal or estimate by it. Pair
39
- * with `ChangeReview`.
40
+ * Confidence WITH its basis, as a callout-shaped block the ONLY form
41
+ * confidence takes: the meter + level phrase head it, the explanation of what
42
+ * was checked (and what failed) fills it. The box stays WHITE with a hairline
43
+ * border at every level — the meter is the only colored element (the level
44
+ * already lives there and in the phrase; a tinted fill would say it a third
45
+ * time). Never a bare floating meter: a level without its reasons cannot be
46
+ * acted on, so it cannot be rendered.
40
47
  */
41
48
  export function Confidence(props: ConfidenceProps) {
42
- const level = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
49
+ const level: ConfidenceLevel = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
43
50
  const l = { ...useLoticsLocale().confidence, ...props.labels };
44
51
  const filled = FILLED[level];
45
52
  const fill = solid(COLOR[level]);
46
53
  return (
47
- <View style={styles.row} accessibilityLabel={l[level]}>
48
- <View style={styles.meter}>
49
- {[0, 1, 2].map((i) => (
50
- <View key={i} style={[styles.tick, { backgroundColor: i < filled ? fill : colors.zinc[200] }]} />
51
- ))}
54
+ <View style={styles.container}>
55
+ <View style={styles.head} accessibilityLabel={l[level]}>
56
+ <View style={styles.meter}>
57
+ {[0, 1, 2].map((i) => (
58
+ <View key={i} style={[styles.tick, { backgroundColor: i < filled ? fill : colors.zinc[200] }]} />
59
+ ))}
60
+ </View>
61
+ <Text size="sm" weight="medium">{l[level]}</Text>
52
62
  </View>
53
- <Text size="xs" weight="medium" style={{ color: fill }}>
54
- {l[level]}
55
- </Text>
63
+ <Text size="sm">{props.children}</Text>
56
64
  </View>
57
65
  );
58
66
  }
59
67
 
60
68
  const styles = StyleSheet.create({
61
- row: { flexDirection: "row", alignItems: "center", gap: 6 },
62
- meter: { flexDirection: "row", alignItems: "center", gap: 2 },
69
+ container: {
70
+ padding: 12,
71
+ borderRadius: 10,
72
+ borderWidth: 1,
73
+ borderColor: colors.border,
74
+ backgroundColor: colors.background,
75
+ gap: 4,
76
+ },
77
+ head: { flexDirection: "row", alignItems: "center", gap: 8 },
78
+ meter: { flexDirection: "row", gap: 2, alignItems: "flex-end" },
63
79
  tick: { width: 3, height: 10, borderRadius: 1 },
64
80
  });
@@ -4,8 +4,9 @@ import { colors } from "./colors";
4
4
  import { PressableHighlight } from "./pressable_highlight";
5
5
  import { type ButtonColor } from "./button";
6
6
  import { getButtonIconColor } from "./button_colors";
7
- import { Ref, useCallback } from "react";
7
+ import { Ref } from "react";
8
8
  import { TooltipSide } from "./tooltip";
9
+ import { useGatedPress } from "./use_gated_press";
9
10
 
10
11
  interface IconButtonBase {
11
12
  ref?: Ref<View>;
@@ -61,14 +62,12 @@ export function IconButton(props: IconButtonProps) {
61
62
  style,
62
63
  } = props;
63
64
 
64
- const handlePress = useCallback(
65
- (event: GestureResponderEvent) => {
66
- onPress?.(event);
67
- },
68
- [onPress],
69
- );
65
+ // The press waits out an inline commit the same gesture started (pending_commits.ts)
66
+ // and reports busy while it does — `busy` drives the spinner + the double-press block.
67
+ const { handlePress, waiting } = useGatedPress<GestureResponderEvent>(onPress);
68
+ const busy = loading || waiting;
70
69
 
71
- const disabledOrLoading = disabled || loading;
70
+ const disabledOrLoading = disabled || busy;
72
71
  const tint =
73
72
  iconColor ??
74
73
  (color === "none" ? colors.zinc[700] : color === "white" ? colors.white : getButtonIconColor(color));
@@ -88,7 +87,7 @@ export function IconButton(props: IconButtonProps) {
88
87
  // without shifting surrounding layouts.
89
88
  hitSlop={size === "lg" ? 0 : size === "sm" ? 8 : 6}
90
89
  >
91
- {loading ? (
90
+ {busy ? (
92
91
  <ActivityIndicator size="small" color={tint} />
93
92
  ) : (
94
93
  <Icon size={size === "sm" ? 14 : size === "lg" ? 20 : 18} name={icon} color={tint} />
@@ -10,6 +10,7 @@ import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./
10
10
  import { fontFamilyRegular, getInputTextStyle, getTextColor, type TextColor } from "./text_utils";
11
11
  import { getInteractionModality } from "./interaction_modality";
12
12
  import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
13
+ import { trackCommit } from "./pending_commits";
13
14
 
14
15
  /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
15
16
  * The view box matches it — same height, padding, and a 1px transparent border
@@ -69,7 +70,11 @@ export function useInlineEdit<T>(opts: {
69
70
  setSaving(true);
70
71
  setError(null);
71
72
  try {
72
- await onSave(candidate);
73
+ // Registered so an action pressed in the SAME gesture (the press that caused
74
+ // this blur) waits for the write instead of reading the pre-edit record —
75
+ // see pending_commits.ts. The IIFE starts `onSave` synchronously, so the
76
+ // write still leaves on mousedown, and turns a sync throw into a rejection.
77
+ await trackCommit((async () => onSave(candidate))());
73
78
  setEditing(false);
74
79
  } catch (e) {
75
80
  // Stay in edit mode so the entry isn't lost — show the error, re-arm.
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { pendingCommits, trackCommit } from "./pending_commits";
3
+
4
+ /** A field write that lands in `stored` after `ms`, like a save round trip. */
5
+ const commit = (stored: { value: string }, next: string, ms: number) =>
6
+ new Promise<void>((resolve) =>
7
+ setTimeout(() => {
8
+ stored.value = next;
9
+ resolve();
10
+ }, ms),
11
+ );
12
+
13
+ /** Let the registry's own settle callback run before asserting on it. */
14
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
15
+
16
+ describe("pendingCommits", () => {
17
+ it("is null with nothing in flight — a press keeps its synchronous path", () => {
18
+ expect(pendingCommits()).toBeNull();
19
+ });
20
+
21
+ it("holds until a commit still in flight has landed", async () => {
22
+ const stored = { value: "BKG CŨ: " };
23
+ void trackCommit(commit(stored, "BKG CŨ: SGNA52926200", 20));
24
+ const gate = pendingCommits();
25
+ expect(gate).not.toBeNull();
26
+ await gate;
27
+ expect(stored.value).toBe("BKG CŨ: SGNA52926200");
28
+ });
29
+
30
+ it("holds for EVERY commit in flight, not just the first", async () => {
31
+ const landed: string[] = [];
32
+ const land = (name: string, ms: number) =>
33
+ new Promise<void>((resolve) =>
34
+ setTimeout(() => {
35
+ landed.push(name);
36
+ resolve();
37
+ }, ms),
38
+ );
39
+ void trackCommit(land("slow", 30));
40
+ void trackCommit(land("fast", 5));
41
+ await pendingCommits();
42
+ expect([...landed].sort()).toEqual(["fast", "slow"]);
43
+ });
44
+
45
+ it("releases again once the commits have landed", async () => {
46
+ await trackCommit(Promise.resolve());
47
+ await tick();
48
+ expect(pendingCommits()).toBeNull();
49
+ });
50
+
51
+ it("is released by a FAILED commit too — a stuck gate would wedge every action", async () => {
52
+ const failed = trackCommit(Promise.reject(new Error("Save failed")));
53
+ // The caller owns the error: the inline editor keeps the edit and shows it.
54
+ await expect(failed).rejects.toThrow("Save failed");
55
+ await expect(pendingCommits() ?? Promise.resolve()).resolves.toBeUndefined();
56
+ await tick();
57
+ expect(pendingCommits()).toBeNull();
58
+ });
59
+
60
+ it("ignores a commit started AFTER the gate was taken — it holds the press for the edit that caused it, not later work", async () => {
61
+ const stored = { value: "before" };
62
+ void trackCommit(commit(stored, "first", 5));
63
+ const gate = pendingCommits();
64
+ void trackCommit(commit(stored, "later", 40));
65
+ await gate;
66
+ expect(stored.value).toBe("first");
67
+ });
68
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Inline commits that are still writing, app-wide.
3
+ *
4
+ * An inline editor commits on BLUR, so pressing an action while a field is focused
5
+ * starts TWO things in ONE gesture: the field's write (the blur, on mousedown) and
6
+ * the action's handler (on mouseup). A handler that re-reads the record — to print a
7
+ * document, mint an invoice, copy the row — otherwise overtakes the still-in-flight
8
+ * write and acts on the PRE-EDIT value. It fails silently: the screen shows the new
9
+ * value and the output carries the old one.
10
+ *
11
+ * `useInlineEdit` registers every commit here and `Button`/`IconButton` hold their
12
+ * press for the ones already in flight, so the press lands AFTER the edit that caused
13
+ * it. There is nothing for an app to wire up, and nothing it can forget.
14
+ */
15
+
16
+ const inFlight = new Set<Promise<void>>();
17
+
18
+ /**
19
+ * Register a commit. Returns it UNCHANGED — the caller keeps its own result and error
20
+ * handling; the gate only needs to know when the write SETTLED, so a failed commit
21
+ * releases it just like a successful one (the field surfaces its own error).
22
+ */
23
+ export function trackCommit<T>(commit: Promise<T>): Promise<T> {
24
+ const settled = commit.then(
25
+ () => undefined,
26
+ () => undefined,
27
+ );
28
+ inFlight.add(settled);
29
+ void settled.then(() => {
30
+ inFlight.delete(settled);
31
+ });
32
+ return commit;
33
+ }
34
+
35
+ /**
36
+ * The commits in flight RIGHT NOW, or `null` when there are none — a caller takes its
37
+ * synchronous path on `null`, so a press with nothing pending behaves exactly as it
38
+ * always did. Commits started after this call are deliberately NOT awaited: the gate
39
+ * holds a press for the edit that caused it, never for later work.
40
+ */
41
+ export function pendingCommits(): Promise<void> | null {
42
+ if (inFlight.size === 0) return null;
43
+ const snapshot = Array.from(inFlight);
44
+ return Promise.all(snapshot).then(() => undefined);
45
+ }
@@ -0,0 +1,79 @@
1
+ import type { ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors, solid } from "./colors";
4
+ import { Icon, type IconName } from "./icon";
5
+ import { Text, type HeadingLevel } from "./text";
6
+
7
+ /**
8
+ * Result header — the strip that opens the POST-SAVE RECEIPT. `ChangeReview` gates a proposal
9
+ * BEFORE it applies; this header opens the receipt for what a save-direct run
10
+ * ALREADY CREATED. The receipt states the outcome and routes — it never edits
11
+ * (correction happens through the record's ordinary verbs, on the record):
12
+ *
13
+ * - ONE record → `ResultHeader` (tone mark inline on the title row · outcome
14
+ * title · failure reason line) + a handful of spread `DetailRow` receipt
15
+ * lines (the identifying figures, not the field wall) + a `Confidence`
16
+ * callout naming exactly which values failed their deterministic checks.
17
+ * - SEVERAL records → an attention-first `ListItem` register whose rows press
18
+ * straight through to the records. Never tabs (they cap out), never stacked
19
+ * collapsed cards, never a field wall duplicating the record page.
20
+ *
21
+ * A flagged value carries a DETERMINISTIC verdict the host computed (a failed
22
+ * checksum, a count that disagrees) — never a fabricated score.
23
+ */
24
+
25
+ export type ResultTone = "ok" | "attention" | "error" | "skipped";
26
+
27
+ const TONE: Record<ResultTone, { icon: IconName; color: string }> = {
28
+ ok: { icon: "circle-check", color: solid("green") },
29
+ attention: { icon: "circle-alert", color: solid("amber") },
30
+ error: { icon: "circle-alert", color: solid("red") },
31
+ skipped: { icon: "minus", color: colors.zinc[400] },
32
+ };
33
+
34
+ export interface ResultHeaderProps {
35
+ /** `attention` when any value is flagged (or the item needs the user
36
+ * elsewhere); `error` when the save failed; `skipped` for an item
37
+ * deliberately not processed. The host computes it — it knows its flags. */
38
+ tone: ResultTone;
39
+ /** Outcome-first: "Created <ref>", "Updated <ref>", "Could not save". */
40
+ title: string;
41
+ /** The failure reason (error/skip rows), one line under the title. The
42
+ * record's FIGURES never live here (they belong in the receipt lines), and
43
+ * neither does confidence — that is `Confidence`'s job (a callout, WITH its
44
+ * basis), further down the receipt. */
45
+ detail?: string;
46
+ /** Right-slot action (an open-record jump, a retry) — a `Button`. */
47
+ action?: ReactNode;
48
+ /** Heading rank for the outcome title (screen readers jump by heading).
49
+ * Defaults to 2 — the receipt heads its surface, like `SectionHeadingTitle`. */
50
+ level?: HeadingLevel;
51
+ }
52
+
53
+ /** The record's outcome strip — the tone mark rides INLINE on the title row
54
+ * (everything sits on the page grid; no hanging gutter), with ONE meaningful
55
+ * line under it (the honest confidence, or the failure reason) and the action
56
+ * pinned right. Sections of the review (`DetailTable` fields, `Table` rows)
57
+ * follow at the same left edge. */
58
+ export function ResultHeader(props: ResultHeaderProps) {
59
+ const { tone, title, detail, action, level = 2 } = props;
60
+ const t = TONE[tone];
61
+ return (
62
+ <View style={styles.header}>
63
+ <View style={styles.headerTitleRow}>
64
+ <Icon name={t.icon} size={20} color={t.color} />
65
+ <Text level={level} size="md" weight="semibold" style={styles.headerTitle}>{title}</Text>
66
+ {action}
67
+ </View>
68
+ {detail ? (
69
+ <Text size="sm" color={tone === "error" ? "danger" : "muted"}>{detail}</Text>
70
+ ) : null}
71
+ </View>
72
+ );
73
+ }
74
+
75
+ const styles = StyleSheet.create({
76
+ header: { gap: 6 },
77
+ headerTitleRow: { flexDirection: "row", alignItems: "center", gap: 10 },
78
+ headerTitle: { flex: 1, minWidth: 0 },
79
+ });
@@ -0,0 +1,41 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { pendingCommits } from "./pending_commits";
3
+
4
+ /**
5
+ * Makes an action's press land AFTER any inline commit the same gesture started —
6
+ * see {@link pendingCommits} for the race it closes.
7
+ *
8
+ * With nothing in flight the press dispatches SYNCHRONOUSLY, exactly as before, so the
9
+ * gate is invisible outside the racing case. While waiting the action reports busy, so
10
+ * the gesture reads as "working" rather than "ignored" and cannot double-fire.
11
+ */
12
+ export function useGatedPress<E>(onPress?: (event: E) => void) {
13
+ const [waiting, setWaiting] = useState(false);
14
+ // Re-armed on mount: a StrictMode remount must not leave the ref stuck at false.
15
+ const mounted = useRef(true);
16
+ useEffect(() => {
17
+ mounted.current = true;
18
+ return () => {
19
+ mounted.current = false;
20
+ };
21
+ }, []);
22
+
23
+ const handlePress = useCallback(
24
+ (event: E) => {
25
+ const pending = pendingCommits();
26
+ if (!pending) {
27
+ onPress?.(event);
28
+ return;
29
+ }
30
+ setWaiting(true);
31
+ void pending.then(() => {
32
+ if (!mounted.current) return;
33
+ setWaiting(false);
34
+ onPress?.(event);
35
+ });
36
+ },
37
+ [onPress],
38
+ );
39
+
40
+ return { handlePress, waiting };
41
+ }