@lotics/ui 28.3.1 → 29.2.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/docs/templates.md CHANGED
@@ -255,17 +255,28 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
255
255
  (`AgentRun` in a `FollowScroll`) reads them → `ClarifyWizard` asks the ambiguities the
256
256
  analysis surfaced (described options, one custom-answer slot; its Cancel/Back/Next/Submit
257
257
  live in the `DialogFooter` — `ClarifyWizardScope` wraps the Dialog, `ClarifyWizardActions`
258
- rides the footer) → the IMPORT stream CREATES the records and the dialog ends on the RESULT
259
- LIST one hand-rolled result row per record (outcome mark, what landed, key figures
260
- the anatomy in ai_patterns §the one law; secondary "Done" to dismiss) the one law's
261
- creation branch
262
- (ai_patterns.md): no review gate on a create, the register IS the review, correction uses
263
- the record's ordinary verbs (no drawer on a bulk landing either). BELOW the
258
+ rides the footer) → the IMPORT stream DRAFTS the records the PREVIEW lays every one of
259
+ them out in full, editable in place ONE commit creates them the dialog ends on the
260
+ RESULT LIST one hand-rolled result row per record (outcome mark, what landed, key
261
+ figures — the anatomy in ai_patterns §the one law; secondary "Done" to dismiss).
262
+ Correction past the receipt uses the record's ordinary verbs (no drawer on a bulk landing
263
+ either). BELOW the
264
264
  dropzone, "or enter manually" → the record VARIANTS as `CardSelectItem`s, each opening the
265
265
  same THREE-field create-then-refine form (a single manual create still opens its drawer).
266
266
  The template mocks the clarify step as two app-orchestrated phases; a real app can do it in
267
267
  ONE run — every app agent carries `ask_user_choice`, so the run parks on the agent's own
268
268
  question and `useAgentRun().pendingChoice`/`answerChoice` drive the same `ClarifyWizard`.
269
+ - **The PREVIEW is the gate, and the fork is who supplied the values.** The import stream
270
+ DRAFTS; it does not write. Every drafted record then renders in full — all four fields as
271
+ they will be stored, each editable in place, a `DiffMark` per card, Keep/Drop via
272
+ `useChangeSet` (`initial: "accepted"` — the operator drops the exceptions rather than
273
+ approving six identical records) — and ONE commit named for its outcome creates them. The
274
+ manual form beside it skips all of that and saves direct: the operator typed those three
275
+ values and read them on the way in. Only the DOCUMENT path needs the gate, because a
276
+ receipt saying "Created 3 records" is the first moment a misread order reference could
277
+ have been caught, and by then it is in the data. The receipt still follows the write — it
278
+ states the outcome, ROUTES, and carries the deterministic checks (a checksum, a count
279
+ reconciliation) that a person cannot eyeball anyway.
269
280
 
270
281
  ### `tpl_pick` — the guided queue
271
282
 
@@ -676,12 +687,20 @@ menu holds only Rename/Download). Inside its dialog the run FORKS into three tas
676
687
  phase runs inside a `FollowScroll` (swapped back to `DialogScrollArea` on the phase flip,
677
688
  so review opens at the top):
678
689
 
679
- - **Extract** (read the documents, fill the record) — the open `ChangeFields` review: ONE
680
- `Change` section per record whose body stacks a `ChangeField` per proposed value (label,
681
- the band when replacing, the editable + value). **Editing IS the review**; an add has no
682
- before, a removal is the band alone, and a conflict shows the read-only outcome band over
683
- candidate rows + a type-another-value third option. Proposed line items arrive as
684
- `ChangeRecord` cards; Keep-all + one outcome-named Apply commit.
690
+ - **Extract** (read the documents, fill the record) — a `DetailTable` of proposed values, each
691
+ row a label plus a `DiffValue` and its own Keep/Drop, with `useChangeSet` holding the
692
+ verdicts. **Editing IS the review**; the four shapes are ONE component an add omits
693
+ `before`, a removal omits `after` (the struck value IS the change), an update passes both,
694
+ and a conflict is a `DiffValue` on its placeholder over `CardSelectItem` candidates plus a
695
+ type-another-value option, Keep gated until one is picked. Proposed line items arrive as a
696
+ `Table` with `DiffMark` leading each row — a set of lines reads like the ledger it is about.
697
+ The line table also works the ATTACHMENT case: a dropped invoice's proposal is WHICH LINE it
698
+ belongs to, so the Document column is a `DiffValue` whose sides are file chips, and a file
699
+ replacing one already filed shows both with the superseded one struck. Its untouched lines
700
+ are deliberate — a fixture where every row changed would prove nothing about the screen that
701
+ matters, and they are why an untouched row draws NO mark: the eye finds the filled discs
702
+ among the blanks without reading the column.
703
+ Keep-all + one outcome-named Apply commit.
685
704
  - **Cross-check** (compare the documents against the record and each other) — ranked,
686
705
  display-only `Finding`s the human acts on, closing in a recorded verdict.
687
706
  - **Edit with AI** — the `askAi` handoff: dialogue-shaped file iteration happens in the
@@ -53,6 +53,8 @@ import type { DisplayFile } from "@lotics/ui/file_thumbnail";
53
53
  import { InlineSelect } from "@lotics/ui/inline_select";
54
54
  import { InlineTextInput } from "@lotics/ui/inline_text_input";
55
55
  import { useSelection } from "@lotics/ui/use_selection";
56
+ import { DiffMark } from "@lotics/ui/diff_mark";
57
+ import { useChangeSet } from "@lotics/ui/use_change_set";
56
58
 
57
59
  // ─────────────────────────────────────────────────────────────────────────────
58
60
  // Template, Register — THE canonical register, and the ONE work-execution list
@@ -70,9 +72,17 @@ import { useSelection } from "@lotics/ui/use_selection";
70
72
 
71
73
  // The customer book the new-case form searches — find-or-create (pick an existing
72
74
  // customer or coin a new one) instead of typing a raw name that risks a duplicate.
75
+ // The customer book. The import's preview asks it whether each drafted record's
76
+ // customer already exists, so the two named in `PROPOSED_BY_CUSTOMER` that the
77
+ // wizard's own question calls known ("Blue Harbor Foods isn't in the customer
78
+ // book yet" — implying the other two are) have to actually BE here. They were
79
+ // not, and the old receipt hid it by hardcoding the one new name instead of
80
+ // asking the book; every drafted record now reads as a new customer the moment
81
+ // anything checks. The fixture is the source, so the source has to agree.
73
82
  const CUSTOMER_OPTIONS: PickerOption[] = [
74
83
  "Northwind Packaging", "Crestline Foods", "Atlas Components", "Summit Logistics",
75
84
  "Michael Torres", "Anna Whitfield", "Robert Hayes", "Karen Mitchell",
85
+ "Meridian Trading Co.", "Northgate Textiles",
76
86
  ].map((name) => ({ value: name, label: name }));
77
87
 
78
88
  // The assignable roster — an app feeds `useMembers()` here.
@@ -385,13 +395,24 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
385
395
  // ─── Enter data — the INTAKE fork ────────────────────────────────────────────
386
396
  // AI FIRST, form as fallback. The one "Enter data" CTA opens a phased dialog:
387
397
  // drop files (the hero) → a short ANALYZE stream reads them → the run asks the
388
- // genuine ambiguities the analysis surfaced → the IMPORT stream
389
- // CREATES the records and the dialog ends on the RESULT LIST one row per
390
- // record with its key figures. This is the one law's creation branch: no
391
- // review gate on a create (nothing to diff — the register IS the review);
392
- // correction uses the record's ORDINARY verbs (open it, edit or delete);
393
- // `ChangeReview` belongs to the record page, where a SINGLE existing record
394
- // is being modified.
398
+ // genuine ambiguities the analysis surfaced → the IMPORT stream DRAFTS the
399
+ // records the PREVIEW lays every drafted record out in full, every field
400
+ // editable in place one commit creates them → the RECEIPT states the outcome
401
+ // and routes.
402
+ //
403
+ // The preview is the one law's DOCUMENT-create branch, and the fork is WHO
404
+ // supplied the values, not whether a before-state exists. The manual form below
405
+ // skips it and saves direct — the operator typed those three values and read
406
+ // them on the way in. These came off a spreadsheet nobody has read, and a
407
+ // receipt saying "Created 3 records" is the first moment a misread order
408
+ // reference could have been caught, by which point it is in the data. So the
409
+ // values are shown BEFORE the write, as they will be stored, editable where
410
+ // they are wrong.
411
+ //
412
+ // It is deliberately not a diff. Nothing exists to compare against, so the
413
+ // whole record is the review; `DiffMark` earns its place only for the one fact
414
+ // that IS relational — whether each record's customer matched the book or is
415
+ // being created alongside it.
395
416
  // Below the dropzone, the MANUAL variants (each flavors the same 3-field
396
417
  // create-then-refine gate). The mock plays the clarify step as two phases; a
397
418
  // real app can run it as ONE agent run — every app agent carries
@@ -400,11 +421,16 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
400
421
  // shape: the mock below satisfies the same `AgentRunLike` the hook does.
401
422
 
402
423
  type Part = UIMessagePart<UIDataTypes, UITools>;
403
- type IntakePhase = "intake" | "analyze" | "clarify" | "running" | "done" | "form";
424
+ type IntakePhase = "intake" | "analyze" | "clarify" | "running" | "preview" | "done" | "form";
404
425
  type ManualVariant = "export" | "import";
405
426
 
406
427
  type Proposal = { id: string; khach: string; dienThoai: string; phi: number; orders: string };
407
428
 
429
+ /** The preview's editable copy of one drafted record — the run's output after
430
+ * the operator has had it. Local state, seeded ONCE when the draft stream ends;
431
+ * a real app seeds it from the run's structured output the same way. */
432
+ type Draft = Proposal & { newCustomer: boolean };
433
+
408
434
  // What the (mock) import drafts, per the wizard's grouping answer. A real app
409
435
  // takes these from the import run's structured output.
410
436
  const PROPOSED_BY_CUSTOMER: Proposal[] = [
@@ -452,8 +478,10 @@ const importScript = (grouping: "customer" | "order"): Part[] => [
452
478
  { type: "reasoning", text: grouping === "customer" ? "Group the six orders under their three customers, one record each, fees summed per customer." : "One record per order — six records, fees split from the order totals." },
453
479
  doneTool("i1", "Extract order lines"),
454
480
  doneTool("i2", "Match customers"),
455
- doneTool("i3", grouping === "customer" ? "Create 3 records" : "Create 6 records"),
456
- { type: "text", text: `Created **${grouping === "customer" ? 3 : 6} records** they are in the register.` },
481
+ doneTool("i3", grouping === "customer" ? "Draft 3 records" : "Draft 6 records"),
482
+ // DRAFTED, not created the stream's closing line must not claim a write
483
+ // that has not happened. The operator sees them next, in full, and presses.
484
+ { type: "text", text: `Drafted **${grouping === "customer" ? 3 : 6} records** — check them below before they are created.` },
457
485
  ];
458
486
 
459
487
  /** The manual gate stays the THREE-field create-then-refine form — past the
@@ -481,12 +509,23 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
481
509
  // on the created records (create + link, leave unlinked, or the user's own
482
510
  // instruction via the custom slot).
483
511
  const [customerPlan, setCustomerPlan] = useState("New customer created and linked");
512
+ // What the import DRAFTED — the preview's working copy, seeded once when the
513
+ // draft stream ends and edited in place from then on. It is not server data
514
+ // (nothing is stored yet), so it is the one thing here that legitimately
515
+ // lives in `useState`.
516
+ const [drafts, setDrafts] = useState<Draft[]>([]);
484
517
  // the RECEIPT — what the import CREATED. A real app READS BACK what the
485
518
  // save stored (values + record ids) and runs its DETERMINISTIC checks over
486
519
  // the stored values (a reference checksum, a count reconciliation). ONE
487
520
  // record renders its receipt directly; several render the attention-first
488
521
  // register whose rows press straight through to the records.
489
522
  const [landed, setLanded] = useState<{ id: string; title: string; detail: string; ref: string; refFlagged: boolean; fee: string }[]>([]);
523
+ // Keep/Drop over the drafted records. Everything is IN by default: the
524
+ // operator's job here is to catch the wrong one, not to approve six right
525
+ // ones — `initial: "pending"` would turn a review into a second round of
526
+ // data entry. Nothing is gated on `settled` for the same reason.
527
+ const kept = useChangeSet(drafts.map((d) => d.id));
528
+
490
529
  // form state (the manual gate)
491
530
  const [khach, setKhach] = useState("");
492
531
  const [dienThoai, setDienThoai] = useState("");
@@ -547,26 +586,19 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
547
586
  // Auto-advance: analysis flows into the wizard.
548
587
  setTimeout(() => setPhase("clarify"), 500);
549
588
  } else {
550
- // The import SAVES as it lands the records are created here, and the
551
- // dialog advances to the result list where each row carries Undo.
589
+ // The import DRAFTS nothing is written here. The drafts go to the
590
+ // preview, which is the gate; the write happens on one press there.
552
591
  setTimeout(() => {
553
592
  const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
554
- onCreateMany(proposals.map((p) => ({ khach: p.khach, dienThoai: p.dienThoai, phi: p.phi })));
555
- const rows = proposals.map((p) => ({
556
- id: p.id,
557
- title: grouping === "customer" ? p.khach : `${p.orders} ${p.khach}`,
558
- // The wizard's answers are load-bearing: the unknown customer's row
559
- // states how it landed (created + linked / left unassigned / the
560
- // user's own instruction).
561
- detail: [p.dienThoai, formatMoney(p.phi), p.khach === "Blue Harbor Foods" ? customerPlan : ""].filter(Boolean).join(", "),
562
- ref: p.orders,
563
- // The mock flags one record's reference (a failed checksum); a
564
- // real app computes this over the STORED value.
565
- refFlagged: p.khach === "Blue Harbor Foods",
566
- fee: String(p.phi),
567
- }));
568
- setLanded(rows);
569
- setPhase("done");
593
+ setDrafts(
594
+ proposals.map((p) => ({
595
+ ...p,
596
+ // The one relational fact in a screen full of new values, and
597
+ // the only thing here a `DiffMark` can honestly say.
598
+ newCustomer: !CUSTOMER_OPTIONS.some((o) => o.label === p.khach),
599
+ })),
600
+ );
601
+ setPhase("preview");
570
602
  }, 500);
571
603
  }
572
604
  }
@@ -584,6 +616,8 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
584
616
  const reset = () => {
585
617
  setPhase("intake");
586
618
  setDocs([]);
619
+ setDrafts([]);
620
+ kept.reset();
587
621
  setLanded([]);
588
622
  setCustomerPlan("New customer created and linked");
589
623
  setKhach("");
@@ -592,6 +626,41 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
592
626
  };
593
627
  const close = () => { onOpenChange(false); reset(); };
594
628
 
629
+ /**
630
+ * THE COMMIT. It writes what is on screen — the edited drafts, minus the
631
+ * dropped ones — and only then computes the receipt, over the values that
632
+ * were actually stored.
633
+ *
634
+ * The deterministic checks belong on THIS side of the write for the reason
635
+ * the one law gives: a person cannot eyeball-verify a checksum, so the
636
+ * preview is where human judgment applies (is this the right customer, is
637
+ * that fee plausible) and the receipt is where arithmetic does.
638
+ */
639
+ const commitDrafts = () => {
640
+ const writing = drafts.filter((d) => kept.status(d.id) !== "rejected");
641
+ if (writing.length === 0) return;
642
+ onCreateMany(writing.map((d) => ({ khach: d.khach, dienThoai: d.dienThoai, phi: d.phi })));
643
+ setLanded(
644
+ writing.map((d) => ({
645
+ id: d.id,
646
+ title: grouping === "customer" ? d.khach : `${d.orders} — ${d.khach}`,
647
+ // The wizard's answers are load-bearing: the unknown customer's row
648
+ // states how it landed (created + linked / left unassigned / the
649
+ // user's own instruction).
650
+ detail: [d.dienThoai, formatMoney(d.phi), d.newCustomer ? customerPlan : ""].filter(Boolean).join(", "),
651
+ ref: d.orders,
652
+ // The mock flags one record's reference (a failed checksum); a real app
653
+ // computes this over the STORED value.
654
+ refFlagged: d.orders.includes("PO-7313"),
655
+ fee: String(d.phi),
656
+ })),
657
+ );
658
+ setPhase("done");
659
+ };
660
+
661
+ const editDraft = (id: string, patch: Partial<Draft>) =>
662
+ setDrafts((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)));
663
+
595
664
  const createManual = () => {
596
665
  if (khach.trim() === "") return;
597
666
  onCreate(khach.trim(), dienThoai.trim(), phi ?? 0);
@@ -601,6 +670,9 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
601
670
  const title =
602
671
  phase === "form" ? (variant === "export" ? "New export case" : "New import case")
603
672
  : phase === "intake" ? "Enter data"
673
+ // Named for what the operator is about to DO, not for the machinery: they
674
+ // are checking records, and the count is the size of the job.
675
+ : phase === "preview" ? `Check ${drafts.length} drafted ${drafts.length === 1 ? "record" : "records"}`
604
676
  : phase === "done" ? "Saved"
605
677
  : "Import from files";
606
678
 
@@ -693,6 +765,89 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
693
765
  <AgentRunPane run={run} onCancel={() => setPhase("intake")} />
694
766
  ) : null}
695
767
 
768
+ {phase === "preview" ? (
769
+ // THE GATE. Every drafted record, laid out as it will be STORED — all
770
+ // four fields, not a three-line summary — and every one of them
771
+ // editable where it sits. The law's reason for editing over
772
+ // accept/reject: a wrong proposal you can fix costs a keystroke, one
773
+ // you can only reject costs the whole re-entry.
774
+ <DialogScrollArea>
775
+ <View style={{ gap: 12 }}>
776
+ <Text size="xs" color="muted">
777
+ Nothing is saved yet. Correct anything that is wrong, drop what should not be created, then create the rest.
778
+ </Text>
779
+ {drafts.map((d) => {
780
+ const dropped = kept.status(d.id) === "rejected";
781
+ const title = grouping === "customer" ? d.khach : d.orders;
782
+ return (
783
+ <View key={d.id} style={{ borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 12, overflow: "hidden" }}>
784
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingVertical: 8, backgroundColor: colors.zinc[50] }}>
785
+ {/* The mark says what will happen to this card. Emphasised
786
+ because the preview is scanned, not read: a filled
787
+ shape per card is found without reading the column. */}
788
+ <DiffMark kind={dropped ? "removed" : "added"} />
789
+ <Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>{title}</Text>
790
+ {dropped ? (
791
+ <Link size="sm" onPress={() => kept.undo(d.id)} accessibilityLabel={`Keep ${title}`}>Keep</Link>
792
+ ) : (
793
+ <Link size="sm" onPress={() => kept.reject(d.id)} accessibilityLabel={`Do not create ${title}`}>Don&rsquo;t create</Link>
794
+ )}
795
+ </View>
796
+ {/* A dropped card COLLAPSES rather than dimming in place:
797
+ its fields are not going to be written, so leaving them
798
+ legible-but-grey invites edits to a record that will not
799
+ exist. The header keeps its identity and the Keep verb. */}
800
+ {dropped ? null : (
801
+ <View style={{ paddingHorizontal: 12 }}>
802
+ <DetailTable>
803
+ <DetailRow
804
+ label="Customer"
805
+ description={d.newCustomer ? customerPlan : undefined}
806
+ >
807
+ <InlineTextInput
808
+ value={d.khach}
809
+ onSave={(v) => editDraft(d.id, { khach: v })}
810
+ accessibilityLabel={`Customer for ${title}`}
811
+ />
812
+ </DetailRow>
813
+ <DetailRow label="Orders">
814
+ <InlineTextInput
815
+ value={d.orders}
816
+ onSave={(v) => editDraft(d.id, { orders: v })}
817
+ accessibilityLabel={`Orders for ${title}`}
818
+ />
819
+ </DetailRow>
820
+ <DetailRow label="Phone">
821
+ {/* The document carried no phone for one of these.
822
+ An empty box would read as "blank" — a value
823
+ somebody chose — so the placeholder NAMES the
824
+ absence: unknown is not empty. */}
825
+ <InlineTextInput
826
+ value={d.dienThoai}
827
+ placeholder="Not on the document"
828
+ onSave={(v) => editDraft(d.id, { dienThoai: v })}
829
+ accessibilityLabel={`Phone for ${title}`}
830
+ />
831
+ </DetailRow>
832
+ <DetailRow label="Service fee">
833
+ <InlineNumberInput
834
+ value={d.phi}
835
+ min={0}
836
+ format={(v) => (v == null ? "" : formatMoney(v))}
837
+ onSave={(v) => editDraft(d.id, { phi: v ?? 0 })}
838
+ accessibilityLabel={`Service fee for ${title}`}
839
+ />
840
+ </DetailRow>
841
+ </DetailTable>
842
+ </View>
843
+ )}
844
+ </View>
845
+ );
846
+ })}
847
+ </View>
848
+ </DialogScrollArea>
849
+ ) : null}
850
+
696
851
  {phase === "done" ? (
697
852
  // The RECEIPT (ai_patterns §the one law): states the outcome and
698
853
  // routes — NOTHING here edits; the RECORD is the edit surface,
@@ -804,6 +959,24 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
804
959
  <DialogFooter>
805
960
  <AgentRunActions run={run} />
806
961
  </DialogFooter>
962
+ ) : phase === "preview" ? (
963
+ <DialogFooter>
964
+ <Button
965
+ title="Back"
966
+ color="secondary"
967
+ onPress={() => { setDrafts([]); kept.reset(); setPhase("intake"); }}
968
+ />
969
+ {/* Named for its OUTCOME and carrying the count that will actually
970
+ be written — never "Apply", never the proposal count. Disabled
971
+ at zero kept, which is the only state where the press would do
972
+ nothing. */}
973
+ <Button
974
+ title={`Create ${kept.keptCount} ${kept.keptCount === 1 ? "record" : "records"}`}
975
+ color="primary"
976
+ disabled={kept.keptCount === 0}
977
+ onPress={commitDrafts}
978
+ />
979
+ </DialogFooter>
807
980
  ) : phase === "done" ? (
808
981
  <DialogFooter>
809
982
  {/* Dismissal, not an action — the records already landed. */}