@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.
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useRef, useState } from "react";
1
+ import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors } from "@lotics/ui/colors";
@@ -48,6 +48,7 @@ import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
48
48
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
49
49
  import { DangerZone } from "@lotics/ui/danger_zone";
50
50
  import { FileRow } from "@lotics/ui/file_row";
51
+ import { InlineSlot } from "@lotics/ui/inline_slot";
51
52
  import { pickFiles } from "@lotics/ui/file_picker";
52
53
  import { FileDropTarget } from "@lotics/ui/file_drop_target";
53
54
  import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
@@ -66,7 +67,9 @@ import { AgentRun } from "@lotics/ui/agent_run";
66
67
  import { FollowScroll } from "@lotics/ui/follow_scroll";
67
68
  import { useContainerSize } from "@lotics/ui/size_boundary";
68
69
  import { type SourceRef } from "@lotics/ui/sources";
69
- import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
70
+ import { DiffValue } from "@lotics/ui/diff_value";
71
+ import { DiffMark } from "@lotics/ui/diff_mark";
72
+ import { useChangeSet, type ChangeSet } from "@lotics/ui/use_change_set";
70
73
  import { CompletionState } from "@lotics/ui/completion_state";
71
74
  import type { UIMessagePart, UIDataTypes, UITools } from "ai";
72
75
 
@@ -435,9 +438,9 @@ const CHECK_STEPS: ScriptStep[] = [
435
438
  ];
436
439
 
437
440
  // The shapes of an extract decision — an ADD, an UPDATE, a REMOVAL, a source
438
- // CONFLICT — are the SAME `ChangeField` row: an add has no `before`, an update
439
- // bands it, a removal is the band alone, and a conflict's outcome is the
440
- // read-only + band over the candidate rows.
441
+ // CONFLICT — are the SAME `DiffValue`: an add omits `before`, an update passes
442
+ // both, a removal omits `after` (the struck value IS the change), and a
443
+ // conflict sits on its placeholder over the candidate rows until one is picked.
441
444
  const CARRIER_REF_PROPOSED = "MAEU129394855";
442
445
  const VESSEL_CURRENT = "MSC AURA";
443
446
  const VESSEL_PROPOSED = "MAERSK SALINA";
@@ -448,7 +451,177 @@ const CONSIGNEE_OPTIONS = [
448
451
  { value: "Nordic Furniture AB, Jönköping DC", source: "invoice.pdf", recommended: true },
449
452
  { value: "NF Distribution ApS, Kolding", source: "packing-list.pdf" },
450
453
  ];
454
+ /**
455
+ * A field carrying its own verdict. Deliberately LOCAL to this template rather
456
+ * than a kit component: the row is layout, and the kit's own rule is that an
457
+ * extraction must encode a contract or a behaviour, never layout convenience.
458
+ * `dev/pages/ai.tsx` has its own twenty-line version that reads differently, and
459
+ * that is the system working — one atom, two shapes.
460
+ */
461
+ function ReviewField<Id extends string>({ id, label, review, kind, why, keepDisabled, children }: {
462
+ id: Id;
463
+ label: string;
464
+ review: ChangeSet<Id>;
465
+ /**
466
+ * WHAT this row does to the record, when it does anything.
467
+ *
468
+ * The mark rides the LABEL, not the value, and that is an alignment decision
469
+ * before it is a semantic one. Beside the value it indents every row it marks
470
+ * by its own width plus a gap, so an unmarked row needs a spacer of exactly
471
+ * the glyph's width to keep up and the two drift. The label column is a FIXED
472
+ * width with one left edge, so a mark placed there aligns down the page for
473
+ * free and the value column keeps the single grid `DetailRow` maintains.
474
+ */
475
+ kind?: "added" | "changed" | "removed";
476
+ why?: string;
477
+ keepDisabled?: boolean;
478
+ children: ReactNode;
479
+ }) {
480
+ const status = review.status(id);
481
+ const dropped = status === "rejected";
482
+
483
+ // ONE slot, fixed width AND height, mark or no mark. The height matters as
484
+ // much as the width: this is an inline-flex box inside `DetailRow`'s label
485
+ // Text, so an empty one baselines differently from one holding a glyph, and
486
+ // the unmarked rows sit several pixels off their own labels for no reason a
487
+ // reader could ever guess at.
488
+ const labelNode = (
489
+ <View style={{ // TOP-aligned, and the slot is sized to the label's own LINE — not
490
+ // centred on the label BLOCK. Centring is identical while the label
491
+ // fits on one line and wrong the moment it wraps: a two-line label is
492
+ // 40px, so the mark drifted to the middle of the pair (+9px, against −1
493
+ // on its single-line neighbours) and the column of marks stopped being a
494
+ // column. `DetailRow` solves this for its own label the same way; moving
495
+ // the mark up here without carrying the rule over is what reopened it.
496
+ flexDirection: "row", alignItems: "flex-start", gap: 8, opacity: dropped ? 0.5 : 1 }}>
497
+ <View style={{ width: 22, height: 20, alignItems: "center", justifyContent: "center" }}>
498
+ {kind === undefined ? null : <DiffMark kind={kind} />}
499
+ </View>
500
+ <Text size="sm" color="muted" style={{ flex: 1 }}>{label}</Text>
501
+ </View>
502
+ );
503
+
504
+ if (status !== "pending") {
505
+ const kept = status === "accepted";
506
+ return (
507
+ <DetailRow label={labelNode}>
508
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
509
+ <View style={{ flex: 1, opacity: kept ? 1 : 0.5 }}>{children}</View>
510
+ <Button title="Undo" color="muted" onPress={() => review.undo(id)} />
511
+ </View>
512
+ </DetailRow>
513
+ );
514
+ }
515
+ return (
516
+ // The reason renders INSIDE the value block, above the verdict — not via
517
+ // `DetailRow`'s `description`, which lands under the whole cell and
518
+ // therefore under the buttons. A justification printed after the two
519
+ // controls that act on it is a justification nobody reads: the reader has
520
+ // already decided by the time they reach it.
521
+ <DetailRow label={labelNode}>
522
+ <View style={{ gap: 6 }}>
523
+ {children}
524
+ {why == null ? null : <Text size="xs" color="muted">{why}</Text>}
525
+ <View style={{ flexDirection: "row", justifyContent: "flex-end", gap: 8 }}>
526
+ <Button title="Drop" color="muted" onPress={() => review.reject(id)} />
527
+ <Button title="Keep" color="secondary" disabled={keepDisabled} onPress={() => review.accept(id)} />
528
+ </View>
529
+ </View>
530
+ </DetailRow>
531
+ );
532
+ }
533
+
534
+ /** Keep / Drop for a whole proposed line, collapsing to an Undo once decided. */
535
+ function LineVerdict<Id extends string>({ id, review, label }: { id: Id; review: ChangeSet<Id>; label: string }) {
536
+ if (review.status(id) !== "pending") {
537
+ return <Button title="Undo" color="muted" onPress={() => review.undo(id)} />;
538
+ }
539
+ return (
540
+ <View style={{ flexDirection: "row", gap: 4 }}>
541
+ <IconButton icon="x" accessibilityLabel={`Drop ${label}`} onPress={() => review.reject(id)} />
542
+ <IconButton icon="check" accessibilityLabel={`Keep ${label}`} onPress={() => review.accept(id)} />
543
+ </View>
544
+ );
545
+ }
546
+
451
547
  const EXTRACT_FIELD_IDS = ["carrier_ref", "vessel", "consignee", "gross", "notify"] as const;
548
+ /**
549
+ * The proposed order lines, each its own verdict.
550
+ *
551
+ * Two of them change a VALUE and two change an ATTACHMENT, which is the case a
552
+ * review surface usually forgets: dropping a supplier invoice on a record does
553
+ * not edit a number, it decides WHICH LINE the document belongs to, and that
554
+ * decision is exactly as wrong-able as a misread figure. It gets the same
555
+ * treatment — shown before it applies, on the line it lands on, with whatever
556
+ * it displaces still visible.
557
+ */
558
+ const LINE_IDS = ["line-add", "line-edit-qty", "line-doc", "line-doc-swap"] as const;
559
+
560
+ /**
561
+ * What the run proposes for the record's FILES section — the third diff shape,
562
+ * after the field form and the line table.
563
+ *
564
+ * A files section is not a field: it is a SET, so what changes is MEMBERSHIP
565
+ * plus per-document properties. `added` and `removed` are membership and the
566
+ * row's own mark carries them. `changed` splits again — a document can be
567
+ * SUPERSEDED (new bytes in the same slot) or RECLASSIFIED (the same bytes filed
568
+ * differently), and only the second has a value to diff. Rendering a mark with
569
+ * no visible diff on a reclassify would say the file itself changed.
570
+ */
571
+ const DOC_IDS = ["doc-inv", "doc-bol", "doc-coo", "doc-dup"] as const;
572
+
573
+ type ProposedDoc = {
574
+ id: (typeof DOC_IDS)[number];
575
+ kind: "added" | "changed" | "removed";
576
+ name: string;
577
+ mimeType: string;
578
+ note?: string;
579
+ supersedes?: string;
580
+ filedAs?: { before: string; after: string };
581
+ };
582
+
583
+ const PROPOSED_DOCS: ProposedDoc[] = [
584
+ { id: "doc-inv", kind: "added", name: "invoice-8841.pdf", mimeType: "application/pdf", note: "Split from pages 1-2 of the scanned bundle" },
585
+ { id: "doc-bol", kind: "changed", name: "bill-of-lading.pdf", mimeType: "application/pdf", supersedes: "scan-0042.jpg" },
586
+ { id: "doc-coo", kind: "changed", name: "cert-origin.pdf", mimeType: "application/pdf", filedAs: { before: "Other", after: "Certificate of origin" } },
587
+ { id: "doc-dup", kind: "removed", name: "photos.zip", mimeType: "application/zip", note: "Already on the record under the same name" },
588
+ ];
589
+
590
+ /** The document already filed against a line, and the one being proposed. */
591
+ type LineDoc = { name: string; mimeType: string };
592
+ const PDF = "application/pdf";
593
+
594
+ /**
595
+ * A file rendered as a table VALUE — the filename at the size every other cell
596
+ * uses.
597
+ *
598
+ * It carried a `FileBadge` at first, which in a cell that size shrank its own
599
+ * label to five pixels — off every rung of the type scale. Making it legible
600
+ * means a 34px box setting the height of a 52px row, for a fact the extension
601
+ * already carries. The discriminator is what the file IS on the surface: the
602
+ * row's SUBJECT (a `FileRow`, a tile) earns a badge; one VALUE among columns is
603
+ * a value, rendered like the quantity beside it.
604
+ */
605
+ function DocName({ doc }: { doc: LineDoc }) {
606
+ return <Text size="sm" numberOfLines={1}>{doc.name}</Text>;
607
+ }
608
+
609
+ /**
610
+ * The UNTOUCHED lines — the reason this table exists in this shape.
611
+ *
612
+ * A fixture of two proposed lines and nothing else flatters the design: every
613
+ * row carries a change, so any mark at all is findable. A real record has a
614
+ * dozen lines and a document touches one or two of them, and the question the
615
+ * operator actually has — WHICH line did this invoice land on — is a search
616
+ * problem. These rows are here to make the review honest, and they are why an
617
+ * untouched row renders NO mark: the eye finds the filled discs among the
618
+ * blanks without reading the column.
619
+ */
620
+ const UNTOUCHED_LINES: { id: string; item: string; qty: string; doc?: LineDoc }[] = [
621
+ { id: "l-oak", item: "Kiln-dried oak panels, 18mm", qty: "640 pcs", doc: { name: "packing-list.pdf", mimeType: PDF } },
622
+ { id: "l-foam", item: "Foam corner guards", qty: "2,400 pcs", doc: { name: "packing-list.pdf", mimeType: PDF } },
623
+ { id: "l-desiccant", item: "Desiccant sachets, 50g", qty: "900 pcs" },
624
+ ];
452
625
 
453
626
  // Cross-check findings — severity reads through ONE colored dot badge (red /
454
627
  // amber / zinc), the rest stays calm text.
@@ -1121,21 +1294,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1121
1294
  const [vessel, setVessel] = useState(VESSEL_PROPOSED);
1122
1295
  const [consignee, setConsignee] = useState<string | null>(null);
1123
1296
  const [grossWeight, setGrossWeight] = useState("1,540");
1124
- // Order lines proposed from the packing list — RECORD ops (ChangeRecord).
1125
- const [lineAdd, setLineAdd] = useState<ChangeStatus>("pending");
1126
- const [lineEdit, setLineEdit] = useState<Record<string, "kept" | "dropped">>({});
1297
+ // Order lines proposed from the packing list — one verdict per line.
1127
1298
  const [newItem, setNewItem] = useState("Corner protectors, foam");
1128
1299
  const [newQty, setNewQty] = useState("400");
1129
1300
  const [editedQty, setEditedQty] = useState("1,450");
1130
- const lineEditRow = (id: string) => ({
1131
- status: lineEdit[id] ?? ("pending" as const),
1132
- onKeep: () => setLineEdit((m) => ({ ...m, [id]: "kept" as const })),
1133
- onDrop: () => setLineEdit((m) => ({ ...m, [id]: "dropped" as const })),
1134
- onUndo: () => setLineEdit((m) => { const n = { ...m }; delete n[id]; return n; }),
1135
- });
1301
+ const lines = useChangeSet(LINE_IDS, { initial: "pending" });
1136
1302
  const [consigneePick, setConsigneePick] = useState<number | "custom" | null>(null);
1137
1303
  const [customConsignee, setCustomConsignee] = useState("");
1138
- const [fieldDecisions, setFieldDecisions] = useState<Record<string, "kept" | "dropped">>({});
1304
+ const fields = useChangeSet(EXTRACT_FIELD_IDS, { initial: "pending" });
1305
+ const docs = useChangeSet(DOC_IDS, { initial: "pending" });
1139
1306
 
1140
1307
  const openAi = () => { setPicked(files.filter((f) => sel.has(f.id))); setUploadFlow(false); setAiOpen(true); };
1141
1308
  // THE standard files intake — one handler, three ways in: the Documents
@@ -1156,7 +1323,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1156
1323
  const commitUpload = () => setFiles((fs) => [...fs, ...picked.filter((p2) => !fs.some((f) => f.id === p2.id))]);
1157
1324
  const closeAi = () => {
1158
1325
  setAiOpen(false); setTask(null); setPhase("fork"); setRevealed(0); setUploadFlow(false); setBrief(""); setTaskChoice(null);
1159
- setCarrierRef(CARRIER_REF_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540"); setLineAdd("pending"); setLineEdit({}); setNewItem("Corner protectors, foam"); setNewQty("400"); setEditedQty("1,450"); setFieldDecisions({});
1326
+ setCarrierRef(CARRIER_REF_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540"); lines.reset(); setNewItem("Corner protectors, foam"); setNewQty("400"); setEditedQty("1,450"); fields.reset();
1160
1327
  };
1161
1328
  const startTask = (t: Task) => { setTask(t); setRevealed(0); setPhase("running"); };
1162
1329
  // ⋯ menu per document row. "Edit with AI" is the app→chat handoff for
@@ -1206,19 +1373,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1206
1373
  ],
1207
1374
  );
1208
1375
  };
1209
- const decideField = (id: string, d: "kept" | "dropped" | undefined) =>
1210
- setFieldDecisions((m) => {
1211
- const n = { ...m };
1212
- if (d) n[id] = d;
1213
- else delete n[id];
1214
- return n;
1215
- });
1216
- const fieldRow = (id: string) => ({
1217
- status: fieldDecisions[id] ?? ("pending" as const),
1218
- onKeep: () => decideField(id, "kept"),
1219
- onDrop: () => decideField(id, "dropped"),
1220
- onUndo: () => decideField(id, undefined),
1221
- });
1222
1376
 
1223
1377
  // The streaming timer idiom — reveal one script step at a time, then settle.
1224
1378
  const script = task === "check" ? CHECK_STEPS : EXTRACT_STEPS;
@@ -1244,21 +1398,19 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1244
1398
  runItems.push({ type: "text", text: task === "check" ? "Checked 18 fields — 2 disagree, 1 worth noting. The findings are below." : "3 fields already match; 3 need a decision, and the invoice carries 2 lines the order doesn't have yet." });
1245
1399
  }
1246
1400
 
1247
- // Apply counts what actually commits: the kept fields.
1248
- const applyCount = Object.values(fieldDecisions).filter((d) => d === "kept").length + Object.values(lineEdit).filter((d) => d === "kept").length + (lineAdd === "accepted" ? 1 : 0);
1249
- // Keep-all lives with the HOST (field decisions the registry can't see):
1250
- // keep every still-pending field; the unresolved conflict stays pending.
1401
+ // Apply counts what actually commits: kept fields plus kept lines.
1402
+ // Every set the review renders, or the button claims a smaller write than the
1403
+ // screen shows. The documents block was added after this line existed, which
1404
+ // is exactly how a commit count drifts from its surface.
1405
+ const applyCount = fields.keptCount + lines.keptCount + docs.keptCount;
1406
+ // Keep-all stays the HOST's — only this screen knows that an unresolved
1407
+ // conflict (`consignee == null`) must stay pending rather than be swept in.
1251
1408
  const keepAll = () => {
1252
- setLineAdd((st) => (st === "pending" ? "accepted" : st));
1253
- setLineEdit((m) => ({ qty: m.qty ?? "kept", ...m }));
1254
- setFieldDecisions((m) => {
1255
- const n = { ...m };
1256
1409
  for (const f of EXTRACT_FIELD_IDS) {
1257
1410
  if (f === "consignee" && consignee == null) continue;
1258
- if (n[f] == null) n[f] = "kept";
1411
+ if (fields.status(f) === "pending") fields.accept(f);
1259
1412
  }
1260
- return n;
1261
- });
1413
+ lines.acceptAll();
1262
1414
  };
1263
1415
 
1264
1416
  // ── the classification fields — each one carries the input its shape wants
@@ -3365,7 +3517,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3365
3517
  {/* Use AI — fork → running → review → done, all inside one dialog. The review
3366
3518
  provider wraps the WHOLE dialog so the `Change`s (scroll area) and the
3367
3519
  commit bar (`DialogFooter`) share one review context. */}
3368
- <ChangeReview>
3369
3520
  <Dialog open={aiOpen} onOpenChange={(o) => { if (!o) closeAi(); }} maxWidth={620}>
3370
3521
  <DialogHeader>
3371
3522
  <DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI (${picked.length} ${picked.length === 1 ? "file" : "files"})`}</DialogHeaderTitle>
@@ -3429,88 +3580,202 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3429
3580
  <View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
3430
3581
  </View>
3431
3582
  <View style={{ gap: 8 }}>
3432
- <ChangeReviewHeader />
3433
- {/* ONE section for the RECORD — ChangeFields stacks a ChangeField
3434
- per proposed value: label, the band when replacing, the
3435
- editable + value. Editing IS the review; each field decides
3436
- for ITSELF (Keep/Drop), and Apply commits the kept rows. */}
3437
- <Change id="order">
3438
- <ChangeReasoning>{EXTRACT_REASONING}</ChangeReasoning>
3439
- <ChangeFields>
3440
- {/* An ADD — the record holds nothing yet, so no `before`. */}
3441
- <ChangeField label="Carrier reference" value={carrierRef} summary={carrierRef} {...fieldRow("carrier_ref")}>
3442
- <ChangeValueInput value={carrierRef} onChangeText={setCarrierRef} accessibilityLabel="Carrier reference" />
3443
- </ChangeField>
3444
- {/* An UPDATE the current value banded above the editor. */}
3445
- <ChangeField label="Vessel" value={vessel} summary={vessel} before={VESSEL_CURRENT} {...fieldRow("vessel")}>
3446
- <ChangeValueInput value={vessel} onChangeText={setVessel} accessibilityLabel="Vessel" />
3447
- </ChangeField>
3448
- {/* A CONFLICT the sources disagree: the read-only outcome
3449
- band stays on its placeholder until the user picks a
3450
- candidate below or types a third value — never a
3451
- pre-selection; Keep is gated until resolved. */}
3452
- <ChangeField
3453
- label="Consignee"
3454
- value={consignee ?? ""}
3455
- summary={consignee ?? undefined}
3456
- before={CONSIGNEE_CURRENT}
3457
- valueReadOnly
3458
- placeholder="Pick a candidate below"
3459
- reasoning="The invoice and the packing list disagree — pick a candidate or type your own."
3460
- candidates={CONSIGNEE_OPTIONS.map((c, i2) => ({ value: c.value, source: c.source, selected: consigneePick === i2 }))}
3461
- onPickCandidate={(c) => { const i2 = CONSIGNEE_OPTIONS.findIndex((x) => x.value === c.value); setConsigneePick(i2); setConsignee(c.value); }}
3462
- customValue={customConsignee}
3463
- customSelected={consigneePick === "custom"}
3464
- onCustomSelect={() => { setConsigneePick("custom"); setConsignee(customConsignee || null); }}
3465
- onCustomValue={(v) => { setCustomConsignee(v); setConsignee(v || null); }}
3466
- keepDisabled={consignee == null}
3467
- {...fieldRow("consignee")}
3468
- />
3469
- {/* An ADD-only field new information the documents carry:
3470
- the + band alone, same grammar as every diff. */}
3471
- <ChangeField label="Gross weight" value={grossWeight} summary={grossWeight} {...fieldRow("gross")}>
3472
- <ChangeValueInput value={grossWeight} onChangeText={setGrossWeight} unit="kg" accessibilityLabel="Gross weight" />
3473
- </ChangeField>
3474
- {/* A REMOVAL — the − band alone (empty value, no editor): the
3475
- documents show this value no longer applies. */}
3476
- <ChangeField
3477
- label="Notify party"
3478
- before={NOTIFY_CURRENT}
3479
- reasoning="No notify party appears on any document the consignee is notified directly."
3480
- {...fieldRow("notify")}
3481
- />
3482
- </ChangeFields>
3483
- </Change>
3484
- {/* RECORD ops — the packing list also proposes ORDER LINES:
3485
- a ChangeRecord card per item (add = one card decision;
3486
- edit = its changed fields decide themselves). A divider +
3487
- breathing room set the sub-section off from the fields. */}
3583
+ {/* The heading, its counter and every row below are ordinary
3584
+ components reading ONE `useChangeSet`. `DiffValue` carries each
3585
+ field's mini-diff; the row around it belongs to this template,
3586
+ so a screen that needs a different shape writes a different
3587
+ row instead of bending a container. */}
3588
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
3589
+ <Text size="md" weight="semibold" style={{ flex: 1 }}>Suggested edits</Text>
3590
+ <Text size="xs" color="muted">{`${fields.keptCount} of ${fields.total} kept`}</Text>
3591
+ </View>
3592
+ <Text size="sm" color="muted">{EXTRACT_REASONING}</Text>
3593
+ <DetailTable labelWidth={140}>
3594
+ {/* An ADD — the record holds nothing yet, so no `before`. */}
3595
+ <ReviewField id="carrier_ref" label="Carrier reference" review={fields} kind="added">
3596
+ <InlineTextInput value={carrierRef} onSave={setCarrierRef} accessibilityLabel="Carrier reference" />
3597
+ </ReviewField>
3598
+ {/* An UPDATE — both values stay visible. */}
3599
+ <ReviewField id="vessel" label="Vessel" review={fields} kind="changed">
3600
+ <InlineSlot><DiffValue layout="inline" before={VESSEL_CURRENT} after={vessel} /></InlineSlot>
3601
+ </ReviewField>
3602
+ {/* A CONFLICT the sources disagree, so the value is the
3603
+ read-only outcome of a pick and Keep is gated until one is
3604
+ made. Never a pre-selection. */}
3605
+ <ReviewField id="consignee" label="Consignee" review={fields} kind="changed" keepDisabled={consignee == null}
3606
+ why="The invoice and the packing list disagree — pick a candidate or type your own.">
3607
+ <View style={{ gap: 6 }}>
3608
+ <InlineSlot><DiffValue layout="inline" before={CONSIGNEE_CURRENT} after={consignee ?? undefined} placeholder="Pick a candidate below" /></InlineSlot>
3609
+ {CONSIGNEE_OPTIONS.map((c, ci) => (
3610
+ <CardSelectItem key={c.value} selected={consigneePick === ci} accessibilityLabel={c.value}
3611
+ onPress={() => { setConsigneePick(ci); setConsignee(c.value); }}>
3612
+ <View style={{ gap: 2 }}>
3613
+ <Text size="sm">{c.value}</Text>
3614
+ <Text size="xs" color="muted">{c.source}</Text>
3615
+ </View>
3616
+ </CardSelectItem>
3617
+ ))}
3618
+ <CardSelectItem selected={consigneePick === "custom"} accessibilityLabel="Type another value"
3619
+ onPress={() => { setConsigneePick("custom"); setConsignee(customConsignee || null); }}>
3620
+ <Text size="sm">{customConsignee === "" ? "Type another value" : customConsignee}</Text>
3621
+ </CardSelectItem>
3622
+ {consigneePick === "custom" ? (
3623
+ <InlineTextInput value={customConsignee} onSave={(v) => { setCustomConsignee(v); setConsignee(v || null); }} placeholder="Consignee…" accessibilityLabel="Custom consignee" />
3624
+ ) : null}
3625
+ </View>
3626
+ </ReviewField>
3627
+ <ReviewField id="gross" label="Gross weight" review={fields}>
3628
+ <InlineTextInput value={grossWeight} onSave={setGrossWeight} accessibilityLabel="Gross weight" />
3629
+ </ReviewField>
3630
+ {/* A REMOVAL `after` omitted, so the struck value IS the change. */}
3631
+ <ReviewField id="notify" label="Notify party" review={fields} kind="removed"
3632
+ why="No notify party appears on any document — the consignee is notified directly.">
3633
+ <InlineSlot><DiffValue layout="inline" before={NOTIFY_CURRENT} placeholder="removed" /></InlineSlot>
3634
+ </ReviewField>
3635
+ </DetailTable>
3636
+
3637
+ {/* RECORD ops the packing list also proposes ORDER LINES. They
3638
+ read as a TABLE because that is what they are; the old family
3639
+ could only render them as a stack of cards. */}
3488
3640
  <View style={{ paddingTop: 10 }}>
3489
3641
  <Divider />
3490
3642
  </View>
3491
- <ChangeReviewHeader title="Order lines" />
3492
- <ChangeRecord
3493
- id="line-add"
3494
- tone="add"
3495
- title="New line"
3496
- status={lineAdd}
3497
- onAccept={() => setLineAdd("accepted")}
3498
- onReject={() => setLineAdd("rejected")}
3499
- onUndo={() => setLineAdd("pending")}
3500
- summary={`${newItem} (${newQty} pcs)`}
3501
- >
3502
- <ChangeField label="Item" value={newItem} summary={newItem}>
3503
- <ChangeValueInput value={newItem} onChangeText={setNewItem} accessibilityLabel="Item" />
3504
- </ChangeField>
3505
- <ChangeField label="Quantity" value={newQty} summary={`${newQty} pcs`}>
3506
- <ChangeValueInput value={newQty} onChangeText={setNewQty} unit="pcs" accessibilityLabel="Quantity" />
3507
- </ChangeField>
3508
- </ChangeRecord>
3509
- <ChangeRecord id="line-edit" tone="edit" title="Flat-pack cartons — existing line" summary={`Flat-pack cartons — ${editedQty} pcs`}>
3510
- <ChangeField label="Quantity" before="1,200 pcs" value={editedQty} summary={`${editedQty} pcs`} {...lineEditRow("qty")}>
3511
- <ChangeValueInput value={editedQty} onChangeText={setEditedQty} unit="pcs" accessibilityLabel="Line quantity" />
3512
- </ChangeField>
3513
- </ChangeRecord>
3643
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
3644
+ <Text size="md" weight="semibold" style={{ flex: 1 }}>Order lines</Text>
3645
+ <Text size="xs" color="muted">{`${lines.keptCount} of ${lines.total} kept`}</Text>
3646
+ </View>
3647
+ {/* The WHOLE line set, changed and untouched together. Showing
3648
+ only the two proposals would answer "what did it find" while
3649
+ leaving the operator's real question — where does this sit in
3650
+ the order — to a second screen. The `Document` column is a
3651
+ first-class diff: a dropped invoice's proposal IS which line
3652
+ it attaches to. */}
3653
+ <Table columns={[
3654
+ { key: "mark", label: "", width: 30, priority: 4 },
3655
+ { key: "item", label: "Item", flex: 1 },
3656
+ { key: "qty", label: "Quantity", width: 116, align: "right", priority: 1 },
3657
+ { key: "doc", label: "Document", width: 168, priority: 2 },
3658
+ { key: "act", label: "", width: 92, priority: 3 },
3659
+ ]}>
3660
+ <TableRow>
3661
+ <TableCell><DiffMark kind="unchanged" /></TableCell>
3662
+ <TableCell><Text size="sm" numberOfLines={2}>{UNTOUCHED_LINES[0].item}</Text></TableCell>
3663
+ <TableCell><Text size="sm" tabular style={{ textAlign: "right" }}>{UNTOUCHED_LINES[0].qty}</Text></TableCell>
3664
+ <TableCell><DocName doc={UNTOUCHED_LINES[0].doc!} /></TableCell>
3665
+ <TableCell><View /></TableCell>
3666
+ </TableRow>
3667
+ <TableRow>
3668
+ <TableCell><DiffMark kind={lines.status("line-edit-qty") === "rejected" ? "unchanged" : "changed"} /></TableCell>
3669
+ <TableCell><Text size="sm" numberOfLines={2}>Flat-pack cartons</Text></TableCell>
3670
+ <TableCell><DiffValue align="right" tabular before="1,200 pcs" after={`${editedQty} pcs`} delta="+250 pcs" /></TableCell>
3671
+ <TableCell><DocName doc={{ name: "packing-list.pdf", mimeType: PDF }} /></TableCell>
3672
+ <TableCell><LineVerdict id="line-edit-qty" review={lines} label="Flat-pack cartons" /></TableCell>
3673
+ </TableRow>
3674
+ <TableRow>
3675
+ {/* THE ATTACHMENT. The line's values do not move at all — the
3676
+ whole proposal is that this invoice belongs HERE, so the
3677
+ document cell is the only thing that reads as a change. */}
3678
+ <TableCell><DiffMark kind={lines.status("line-doc") === "rejected" ? "unchanged" : "changed"} /></TableCell>
3679
+ <TableCell><Text size="sm" numberOfLines={2}>Steel banding coils</Text></TableCell>
3680
+ <TableCell><Text size="sm" tabular style={{ textAlign: "right" }}>80 pcs</Text></TableCell>
3681
+ <TableCell>
3682
+ {lines.status("line-doc") === "rejected"
3683
+ ? <Text size="xs" color="muted">None</Text>
3684
+ : <DiffValue after={<DocName doc={{ name: "invoice-8841.pdf", mimeType: PDF }} />} />}
3685
+ </TableCell>
3686
+ <TableCell><LineVerdict id="line-doc" review={lines} label="the invoice on Steel banding coils" /></TableCell>
3687
+ </TableRow>
3688
+ <TableRow>
3689
+ <TableCell><DiffMark kind="unchanged" /></TableCell>
3690
+ <TableCell><Text size="sm" numberOfLines={2}>{UNTOUCHED_LINES[1].item}</Text></TableCell>
3691
+ <TableCell><Text size="sm" tabular style={{ textAlign: "right" }}>{UNTOUCHED_LINES[1].qty}</Text></TableCell>
3692
+ <TableCell><DocName doc={UNTOUCHED_LINES[1].doc!} /></TableCell>
3693
+ <TableCell><View /></TableCell>
3694
+ </TableRow>
3695
+ <TableRow>
3696
+ {/* A document REPLACING one already filed. `DiffValue` strikes
3697
+ a node with a drawn rule, because `line-through` on a Text
3698
+ does not cross a View child — without it the superseded
3699
+ scan would sit at full strength beside its replacement. */}
3700
+ <TableCell><DiffMark kind={lines.status("line-doc-swap") === "rejected" ? "unchanged" : "changed"} /></TableCell>
3701
+ <TableCell><Text size="sm" numberOfLines={2}>Pallet collars</Text></TableCell>
3702
+ <TableCell><Text size="sm" tabular style={{ textAlign: "right" }}>320 pcs</Text></TableCell>
3703
+ <TableCell>
3704
+ {lines.status("line-doc-swap") === "rejected"
3705
+ ? <DocName doc={{ name: "scan-0042.jpg", mimeType: "image/jpeg" }} />
3706
+ : (
3707
+ <DiffValue
3708
+ before={<DocName doc={{ name: "scan-0042.jpg", mimeType: "image/jpeg" }} />}
3709
+ after={<DocName doc={{ name: "invoice-8841-p2.pdf", mimeType: PDF }} />}
3710
+ />
3711
+ )}
3712
+ </TableCell>
3713
+ <TableCell><LineVerdict id="line-doc-swap" review={lines} label="the replacement scan on Pallet collars" /></TableCell>
3714
+ </TableRow>
3715
+ <TableRow>
3716
+ <TableCell><DiffMark kind={lines.status("line-add") === "rejected" ? "unchanged" : "added"} /></TableCell>
3717
+ <TableCell><InlineTextInput value={newItem} onSave={setNewItem} accessibilityLabel="Item" /></TableCell>
3718
+ <TableCell><InlineTextInput value={newQty} onSave={setNewQty} accessibilityLabel="Quantity" /></TableCell>
3719
+ <TableCell><Text size="xs" color="muted">None</Text></TableCell>
3720
+ <TableCell><LineVerdict id="line-add" review={lines} label={newItem} /></TableCell>
3721
+ </TableRow>
3722
+ <TableRow>
3723
+ <TableCell><DiffMark kind="unchanged" /></TableCell>
3724
+ <TableCell><Text size="sm" numberOfLines={2}>{UNTOUCHED_LINES[2].item}</Text></TableCell>
3725
+ <TableCell><Text size="sm" tabular style={{ textAlign: "right" }}>{UNTOUCHED_LINES[2].qty}</Text></TableCell>
3726
+ <TableCell><Text size="xs" color="muted">None</Text></TableCell>
3727
+ <TableCell><View /></TableCell>
3728
+ </TableRow>
3729
+ </Table>
3730
+
3731
+ {/* THE FILES SECTION — the record's document set, reviewed in the
3732
+ shape the record renders it in. `FileRow` takes the mark in
3733
+ `leading` so the names still form a column, and a node `meta`
3734
+ so a reclassify can carry its diff on the PROPERTY that moved
3735
+ rather than implying the bytes changed. */}
3736
+ <View style={{ paddingTop: 10 }}>
3737
+ <Divider />
3738
+ </View>
3739
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
3740
+ <Text size="md" weight="semibold" style={{ flex: 1 }}>Documents</Text>
3741
+ <Text size="xs" color="muted">{`${docs.keptCount} of ${docs.total} kept`}</Text>
3742
+ </View>
3743
+ <View style={{ gap: 2 }}>
3744
+ {PROPOSED_DOCS.map((d) => {
3745
+ const status = docs.status(d.id);
3746
+ const dropped = status === "rejected";
3747
+ const meta =
3748
+ dropped ? d.note
3749
+ : d.supersedes ? `Replaces ${d.supersedes}`
3750
+ : d.filedAs ? (
3751
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
3752
+ <Text size="xs" color="muted">Filed as</Text>
3753
+ <DiffValue layout="inline" size="xs" before={d.filedAs.before} after={d.filedAs.after} />
3754
+ </View>
3755
+ ) : d.note;
3756
+ return (
3757
+ <View key={d.id} style={{ opacity: dropped || d.kind === "removed" ? 0.55 : 1 }}>
3758
+ <FileRow
3759
+ size="md"
3760
+ name={d.name}
3761
+ mimeType={d.mimeType}
3762
+ meta={meta}
3763
+ leading={<DiffMark kind={dropped ? "unchanged" : d.kind} />}
3764
+ trailing={
3765
+ status === "pending" ? (
3766
+ <View style={{ flexDirection: "row", gap: 4 }}>
3767
+ <IconButton icon="x" accessibilityLabel={`Drop ${d.name}`} onPress={() => docs.reject(d.id)} />
3768
+ <IconButton icon="check" accessibilityLabel={`Keep ${d.name}`} onPress={() => docs.accept(d.id)} />
3769
+ </View>
3770
+ ) : (
3771
+ <Button title="Undo" color="muted" onPress={() => docs.undo(d.id)} />
3772
+ )
3773
+ }
3774
+ />
3775
+ </View>
3776
+ );
3777
+ })}
3778
+ </View>
3514
3779
  </View>
3515
3780
  </View>
3516
3781
  ) : null}
@@ -3522,7 +3787,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3522
3787
  <View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
3523
3788
  </View>
3524
3789
  <View style={{ gap: 14 }}>
3525
- <ChangeReviewHeader title="Findings" />
3790
+ <Text size="md" weight="semibold">Findings</Text>
3526
3791
  {/* Display-only: findings inform the verdict the footer records.
3527
3792
  The kit `Finding` owns severity word, title, detail, the
3528
3793
  PROMINENT metric, Sources; hairlines separate them. */}
@@ -3582,7 +3847,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3582
3847
  {/* N = the kept fields; the minKept swap disables Apply exactly at
3583
3848
  N = 0 (the registry can't see field-level decisions, so the host
3584
3849
  gates — and hands Keep-all its own handler). */}
3585
- <ChangeReviewActions onAcceptAll={keepAll} onDiscard={closeAi} discardLabel="Cancel" onApply={() => setPhase("done")} applyLabel={`Update record (${applyCount})`} minKept={0} applyDisabled={applyCount === 0} />
3850
+ <Button title="Keep all" color="muted" onPress={keepAll} />
3851
+ <View style={{ flex: 1 }} />
3852
+ <Button title="Cancel" color="secondary" onPress={closeAi} />
3853
+ <Button title={`Update record (${applyCount})`} color="primary" disabled={applyCount === 0} onPress={() => setPhase("done")} />
3586
3854
  </DialogFooter>
3587
3855
  ) : phase === "review" && task === "check" ? (
3588
3856
  <DialogFooter>
@@ -3597,7 +3865,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3597
3865
  </DialogFooter>
3598
3866
  ) : null}
3599
3867
  </Dialog>
3600
- </ChangeReview>
3601
3868
 
3602
3869
  {/* Rename — small focused dialog; Save disabled while empty. */}
3603
3870
  <Dialog open={renameTarget != null} onOpenChange={(o) => { if (!o) setRenameTarget(null); }} maxWidth={420}>