@lotics/ui 22.2.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +9 -2
  2. package/MIGRATION.md +142 -2
  3. package/docs/ai_patterns.md +27 -25
  4. package/docs/catalog.md +119 -98
  5. package/docs/composition.md +134 -42
  6. package/docs/data_entry.md +67 -31
  7. package/docs/templates.md +86 -68
  8. package/examples/tpl_allocate.tsx +5 -5
  9. package/examples/tpl_attendance.tsx +2 -2
  10. package/examples/tpl_calendar.tsx +6 -6
  11. package/examples/tpl_dashboard.tsx +9 -9
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_item_list.tsx +64 -37
  14. package/examples/tpl_lookup.tsx +5 -5
  15. package/examples/tpl_pick.tsx +6 -6
  16. package/examples/tpl_pivot.tsx +3 -3
  17. package/examples/tpl_record.tsx +919 -611
  18. package/examples/tpl_report.tsx +2 -2
  19. package/examples/tpl_rollup.tsx +5 -5
  20. package/examples/tpl_shifts.tsx +5 -5
  21. package/examples/tpl_statements.tsx +7 -7
  22. package/examples/tpl_stock.tsx +2 -2
  23. package/examples/tpl_task_board.tsx +42 -26
  24. package/examples/tpl_tower.tsx +6 -6
  25. package/package.json +3 -2
  26. package/src/agent_run.tsx +40 -25
  27. package/src/breakdown.tsx +1 -1
  28. package/src/calendar/calendar_view.tsx +1 -1
  29. package/src/change_review.tsx +9 -8
  30. package/src/chip_group.tsx +12 -2
  31. package/src/choice_list.tsx +2 -2
  32. package/src/confidence.tsx +2 -2
  33. package/src/data_grid.tsx +1 -1
  34. package/src/detail_row.tsx +50 -58
  35. package/src/file_dropzone.tsx +1 -1
  36. package/src/file_gallery_modal.tsx +3 -3
  37. package/src/file_rows.tsx +1 -1
  38. package/src/finding.tsx +4 -4
  39. package/src/form_field.tsx +1 -1
  40. package/src/format_date.ts +2 -2
  41. package/src/heatmap.tsx +1 -1
  42. package/src/inline_button.tsx +84 -0
  43. package/src/inline_date_picker.tsx +17 -10
  44. package/src/inline_edit.tsx +298 -59
  45. package/src/inline_member_select.tsx +8 -3
  46. package/src/inline_number_input.tsx +11 -4
  47. package/src/inline_select.tsx +26 -13
  48. package/src/inline_text_input.tsx +12 -4
  49. package/src/inline_time_picker.tsx +10 -4
  50. package/src/ledger.tsx +2 -2
  51. package/src/locale.tsx +7 -3
  52. package/src/matrix.tsx +1 -1
  53. package/src/number_input.tsx +18 -7
  54. package/src/pipeline.tsx +1 -1
  55. package/src/popover.tsx +1 -1
  56. package/src/press_door.tsx +1 -1
  57. package/src/pressable_highlight.tsx +1 -1
  58. package/src/progress_bar.tsx +3 -3
  59. package/src/record_summary.tsx +2 -2
  60. package/src/result_header.tsx +2 -2
  61. package/src/sequence.tsx +170 -0
  62. package/src/share_or_download.ts +2 -2
  63. package/src/step_progress.tsx +7 -5
  64. package/src/stepper.tsx +1 -1
  65. package/src/task.tsx +6 -6
  66. package/src/text_input_field.tsx +19 -1
  67. package/src/text_utils.ts +1 -1
  68. package/src/linked_record_box.tsx +0 -157
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
1
+ import { Fragment, useEffect, useRef, useState } 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";
@@ -13,7 +13,7 @@ import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
13
13
  import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
14
14
  import { Callout, CalloutText } from "@lotics/ui/callout";
15
15
  import { Inset } from "@lotics/ui/inset";
16
- import { Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
16
+ import { DialogSectionHeadingTitle, Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
17
17
  import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
18
18
  import { Pipeline, PipelineActions, PipelineField, PipelineNote, PipelineStage } from "@lotics/ui/pipeline";
19
19
  import { TaskCaption, TaskDetail, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
@@ -42,7 +42,6 @@ import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
42
42
  import { EmptyState } from "@lotics/ui/empty_state";
43
43
  import { FormField } from "@lotics/ui/form_field";
44
44
  import { TextInputField } from "@lotics/ui/text_input_field";
45
- import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
46
45
  import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
47
46
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
48
47
  import { DangerZone } from "@lotics/ui/danger_zone";
@@ -54,9 +53,13 @@ import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
54
53
  import { Finding, FindingComparison } from "@lotics/ui/finding";
55
54
  import { FormTextInput } from "@lotics/ui/form_text_input";
56
55
  import { CheckboxInput } from "@lotics/ui/checkbox_input";
57
- import { LinkedRecordBox } from "@lotics/ui/linked_record_box";
56
+ import { Popover, PopoverContent } from "@lotics/ui/popover";
57
+ import { ChipGroup } from "@lotics/ui/chip_group";
58
+ import { Sequence, SequenceItem, SEQUENCE_INSET } from "@lotics/ui/sequence";
59
+ import { TextLink } from "@lotics/ui/text_link";
60
+ import { InlineButton } from "@lotics/ui/inline_button";
61
+ import { InlineEditView } from "@lotics/ui/inline_edit";
58
62
  import { RadioPicker } from "@lotics/ui/radio_picker";
59
- import { SearchInput } from "@lotics/ui/search_input";
60
63
  import { useSelection } from "@lotics/ui/use_selection";
61
64
  import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
62
65
  import { CardSelectItem } from "@lotics/ui/card_select_item";
@@ -71,7 +74,7 @@ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
71
74
  type Part = UIMessagePart<UIDataTypes, UITools>;
72
75
 
73
76
  // ─────────────────────────────────────────────────────────────────────────────
74
- // Template · Record — THE record surface: the surface IS the editor, every
77
+ // Template, Record — THE record surface: the surface IS the editor, every
75
78
  // field refines in place. No breadcrumb, no create CTA — back lives in the
76
79
  // panel and creation belongs to the REGISTER (the list owns "new").
77
80
  //
@@ -83,29 +86,29 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
83
86
  // patterns.
84
87
  //
85
88
  // THE SECTION PATTERNS (what each is, when to use it):
86
- // · General — the CORE FACTS: a headingless key-facts lead + named
89
+ // - General — the CORE FACTS: a headingless key-facts lead + named
87
90
  // groups + Classification (the right-input-per-field
88
91
  // showcase) + System ids. Always present, first in rail.
89
- // · Comments — the discussion thread. When people collaborate here.
90
- // · Progress — WHERE the record sits: the desks as a `Pipeline`, each
92
+ // - Comments — the discussion thread. When people collaborate here.
93
+ // - Progress — WHERE the record sits: the desks as a `Pipeline`, each
91
94
  // owning its own facts, the live one owning the act that
92
95
  // leaves it. Records that move between owners.
93
- // · Documents — the INTAKE desk: files that ARRIVE + the ONE "Use AI"
96
+ // - Documents — the INTAKE desk: files that ARRIVE + the ONE "Use AI"
94
97
  // fork (the Agents "Document desk" pattern — this template
95
98
  // is its worked example).
96
- // · Customer — the LINKED PARTY: another record referenced, never
99
+ // - Customer — the LINKED PARTY: another record referenced, never
97
100
  // edited here (the linked-record box + drawer).
98
- // · Fees — the MONEY LEDGER: cost/charge lines, both directions.
99
- // · Billing — INVOICING: charges grouped into issuable documents.
100
- // · Document set — the BATCH OUTPUT desk: the record produces per-party
101
+ // - Fees — the MONEY LEDGER: cost/charge lines, both directions.
102
+ // - Billing — INVOICING: charges grouped into issuable documents.
103
+ // - Document set — the BATCH OUTPUT desk: the record produces per-party
101
104
  // form SETS on demand (readiness, fill panels, the gate).
102
- // · Delivery receipt— the QUICK-ISSUE form: one document issued from a
105
+ // - Delivery receipt— the QUICK-ISSUE form: one document issued from a
103
106
  // handful of facts at a known moment (a handover, an
104
107
  // inspection, a visit).
105
- // · Activity — the AUDIT TRAIL. Always.
106
- // · Handoff — the BOUNDARY: the record crosses to another desk's
108
+ // - Activity — the AUDIT TRAIL. Always.
109
+ // - Handoff — the BOUNDARY: the record crosses to another desk's
107
110
  // table ONCE (creates + links the sibling; Recall undoes).
108
- // · Danger zone — destructive lifecycle. Always last.
111
+ // - Danger zone — destructive lifecycle. Always last.
109
112
  //
110
113
  // The lifecycle is the HANDOFF CHAIN, and it reads in TWO places by design:
111
114
  // `Progress` carries the position and the act that changes it; `Handoff` near
@@ -183,6 +186,41 @@ const lookupRegistry = (taxId: string) =>
183
186
  setTimeout(() => resolve(REGISTRY[taxId] ?? { contact: "Accounts desk", city: "Ho Chi Minh City" }), 700);
184
187
  });
185
188
 
189
+ // TRANSPORT's option sets — generic movement vocabulary, at the level any
190
+ // goods business shares. A template teaches the SHAPE; one industry's jargon in
191
+ // a template is a trap for every reader from another.
192
+ const DOC_TYPES = [
193
+ { value: "original", label: "Original", data: { hint: "Printed set, released against a surrendered copy" } },
194
+ { value: "electronic", label: "Electronic release", data: { hint: "No paper to lose; release is a message" } },
195
+ { value: "waybill", label: "Straight waybill", data: { hint: "Named consignee, not transferable" } },
196
+ ];
197
+ const DELIVERY_TERMS = [
198
+ { value: "exw", label: "Ex works" },
199
+ { value: "fca", label: "Free carrier" },
200
+ { value: "cpt", label: "Carriage paid to" },
201
+ { value: "dap", label: "Delivered at place" },
202
+ { value: "ddp", label: "Delivered duty paid" },
203
+ ];
204
+ const CHARGE_TERMS = [
205
+ { value: "prepaid", label: "Prepaid", data: { hint: "The sender pays the carrier" } },
206
+ { value: "collect", label: "Collect", data: { hint: "The receiver pays on arrival" } },
207
+ ];
208
+
209
+ /** Choice + its gloss, shown in the OPTION LIST rather than on the page. The
210
+ * explanation is needed while CHOOSING, not on every later read of the row. */
211
+ const choiceWithHint = (o: { label?: string; data?: { hint: string } }) => (
212
+ <View style={{ gap: 1 }}>
213
+ <Text size="sm">{o.label}</Text>
214
+ {o.data ? <Text size="xs" color="muted">{o.data.hint}</Text> : null}
215
+ </View>
216
+ );
217
+ const SCHEDULE_STATES = [
218
+ { value: "on-time", label: "On time" },
219
+ { value: "delayed", label: "Delayed" },
220
+ { value: "rerouted", label: "Rerouted" },
221
+ { value: "cancelled", label: "Sailing cancelled" },
222
+ ];
223
+
186
224
  const TERMS = [
187
225
  { value: "receipt", label: "Due on receipt" },
188
226
  { value: "15", label: "Net 15" },
@@ -255,9 +293,7 @@ const BILLING_INITIAL: Invoice[] = [
255
293
  },
256
294
  ];
257
295
 
258
- type InvoiceState = "none" | "draft" | "issued";
259
296
  const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
260
- const invoiceStatus = (inv: Invoice): InvoiceState => (inv.ref ? "issued" : invoiceTotal(inv) > 0 ? "draft" : "none");
261
297
  const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
262
298
 
263
299
  const money = (v: number | null) => (v == null ? "" : formatMoney(v));
@@ -273,102 +309,6 @@ function persist<T>(set: (v: T) => void) {
273
309
  });
274
310
  }
275
311
 
276
- /** One invoice document — a named SUBSECTION (the Billing section's groups all
277
- * ride the one SubsectionStack beat): its heading, then the charge lines as
278
- * inline rows (amount chip with the list price as ghost text + a one-tap
279
- * "Standard …" suggestion pill while unset; the payment method turns required
280
- * the moment the line carries an amount), then the ACTION alone at the bottom
281
- * right — no prose beside a CTA; a not-ready Issue simply stays disabled
282
- * (the stage/customer gates read at THEIR sections).
283
- * No per-band total — the collect band below owns the number. An ISSUED
284
- * invoice keeps its lines editable — fees change — and offers Re-issue (a new
285
- * lookup code replaces the old, confirmed in the same Dialog). */
286
- function InvoiceBand({
287
- inv, gated, onAmount, onMethod, onIssue,
288
- }: {
289
- inv: Invoice;
290
- /** The record-level gate (wrong stage / no customer / invalid tax ID),
291
- * resolved by the parent — issuing stays disabled while true. */
292
- gated: boolean;
293
- onAmount: (chKey: string, v: number) => Promise<void>;
294
- onMethod: (chKey: string, m: Method) => Promise<void>;
295
- /** Takes the invoice's KEY, never the `inv` object: the press may be held while
296
- * a charge cell finishes writing (Button waits out an inline commit), and a
297
- * captured object would still be the pre-edit snapshot. Resolve at use. */
298
- onIssue: (invKey: string) => void;
299
- }) {
300
- const total = invoiceTotal(inv);
301
- const state = invoiceStatus(inv);
302
- const missing = missingMethods(inv);
303
- const ready = total > 0 && missing.length === 0 && !gated;
304
- return (
305
- <Subsection>
306
- <SubsectionHeading>
307
- <SubsectionHeadingTitle>{inv.title}</SubsectionHeadingTitle>
308
- </SubsectionHeading>
309
- {/* two editors per cell → stack earlier than a single-editor table */}
310
- <DetailTable labelWidth={150} minValueWidth={320}>
311
- {inv.charges.map((c) => (
312
- <DetailRow key={c.key} label={c.label}>
313
- <View style={{ gap: 4 }}>
314
- {/* amount + method WRAP on narrow — a fixed 160 method next to a
315
- flex amount crushes the amount chip to nothing on phones */}
316
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 }}>
317
- <View style={{ flexGrow: 1, flexBasis: 150 }}>
318
- <InlineNumberInput
319
- value={c.amount || null}
320
- onSave={(v) => onAmount(c.key, v ?? 0)}
321
- min={0}
322
- format={money}
323
- placeholder={c.standard > 0 ? money(c.standard) : "—"}
324
- accessibilityLabel={`${c.label} amount`}
325
- />
326
- </View>
327
- <View style={{ flexGrow: 1, flexBasis: 140, maxWidth: 160 }}>
328
- <InlineSelect
329
- value={c.method === "" ? null : c.method}
330
- onSave={(m) => onMethod(c.key, m)}
331
- options={METHODS}
332
- placeholder="How paid…"
333
- disabled={c.amount <= 0}
334
- accessibilityLabel={`Payment method · ${c.label}`}
335
- />
336
- </View>
337
- </View>
338
- {/* the one-tap suggestion: fill the list price while unset */}
339
- {c.amount <= 0 && c.standard > 0 ? (
340
- <View style={{ flexDirection: "row" }}>
341
- <Button title={`Standard ${money(c.standard)}`} color="secondary" onPress={() => onAmount(c.key, c.standard)} />
342
- </View>
343
- ) : null}
344
- </View>
345
- </DetailRow>
346
- ))}
347
- </DetailTable>
348
- {/* band-LOCAL state, co-located: a charged line without a payment method
349
- is a real inconsistency → its callout lives in the band. An EMPTY
350
- band stays silent — a disabled Issue over no fees explains itself. */}
351
- {missing.length > 0 ? (
352
- <Callout tone="warning">
353
- <CalloutText>Choose a payment method for every charged line.</CalloutText>
354
- </Callout>
355
- ) : null}
356
- {/* the band's action row — the CTA alone, bottom right (the issued ref
357
- link IS the issued state); not-ready = disabled, the WHY reads in the
358
- callouts (band-local above, record-level at the section top) */}
359
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", columnGap: 8 }}>
360
- {state === "issued" ? (
361
- <>
362
- <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
363
- <Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv.key)} />
364
- </>
365
- ) : (
366
- <Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv.key)} />
367
- )}
368
- </View>
369
- </Subsection>
370
- );
371
- }
372
312
 
373
313
  /** The section rail's width — and, mirrored as an empty right gutter, what
374
314
  * keeps the reading column centred in the viewport. */
@@ -378,6 +318,13 @@ const RAIL_GAP = 48;
378
318
  /** The reserved gutter: the rail plus its gap. Mirrored empty on the right, this
379
319
  * is what centres the column. */
380
320
  const GUTTER = RAIL_W + RAIL_GAP;
321
+ /** The discussion panel's column. A record's comments are not a SECTION — they
322
+ * are about the whole record, so they do not belong at one position in a
323
+ * top-to-bottom reading order, and a coworker's note has to stay readable while
324
+ * you work anywhere on the page. It takes the right gutter, which until now was
325
+ * reserved purely to balance the rail. */
326
+ const PANEL_W = 360;
327
+ const PANEL_GUTTER = PANEL_W + RAIL_GAP;
381
328
  /** The reading column's ceiling. */
382
329
  const CONTENT_MAX = 720;
383
330
 
@@ -484,30 +431,9 @@ const SEED_COMMENTS: ThreadComment[] = [
484
431
  ];
485
432
 
486
433
  // The sibling-record counter — each handoff CREATES the next desk's record on
487
- // ITS table (OP-…/AC-…) and links it here for reference.
488
- let siblingSeq = 90;
489
-
490
434
  /** "2026-07-18" — the wire shape every date FIELD on this surface stores. */
491
435
  const iso = (d: Date): string => d.toISOString().slice(0, 10);
492
436
 
493
- /** "18 Jul, 4:05 PM" — the trail/audit timestamp format. */
494
- const stamp = (d: Date): string =>
495
- `${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`;
496
-
497
- /** A sibling record created by a handoff — lives on ITS desk's table; here we
498
- * keep the reference data the cards + drawer show. */
499
- interface SiblingRecord {
500
- id: string;
501
- code: string;
502
- /** The desk this handoff OPENED — the key, not just its label, because recall
503
- * has to undo exactly that step: return to the desk before it and clear the
504
- * custody stamp it wrote. A label can't be mapped back without a lookup. */
505
- deskKey: Desk;
506
- desk: string;
507
- assignee: string;
508
- note: string;
509
- at: string;
510
- }
511
437
 
512
438
  // ── the FEES ledger — the DETAILED money view (both directions), distinct
513
439
  // from Billing's invoice DOCUMENTS: every fee the record incurs or charges,
@@ -518,7 +444,7 @@ type FeeDirection = "charge" | "cost";
518
444
  interface Fee {
519
445
  id: string;
520
446
  name: string;
521
- /** charge = billed to the customer · cost = paid out to a vendor. */
447
+ /** charge = billed to the customer, cost = paid out to a vendor. */
522
448
  direction: FeeDirection;
523
449
  party: string;
524
450
  amount: number;
@@ -656,6 +582,262 @@ const DESTINATIONS: PickerOption<string, { country: string }>[] = [
656
582
  { value: "dubai", label: "Dubai", data: { country: "United Arab Emirates" } },
657
583
  ];
658
584
 
585
+ /**
586
+ * A REFERENCE to another record, rendered as a FIELD VALUE.
587
+ *
588
+ * It wears the inline editor's resting surface — same 40px band, same zinc-50
589
+ * chip, same radius — so a pointer at another record sits in the value column
590
+ * exactly like the editors above and below it, rather than announcing itself as
591
+ * a different species of thing. (The chip's usual promise is "editable"; here it
592
+ * reads as "pressable", which is what `InlineSelect` and `InlineDatePicker`
593
+ * already mean by it — they open a popover too.)
594
+ *
595
+ * THREE acts, placed by how OFTEN they happen and how much they cost to undo:
596
+ * press the value → the facts, in a popover (free, so it's the whole surface)
597
+ * Open, inside → the record itself (the common act, so it stays in sight)
598
+ * Remove, in the peek → detach (destructive and rare, so it sits one layer in)
599
+ *
600
+ * Remove earns its depth twice over. It is the answer to the question the PEEK
601
+ * asks — "is this the right one?" — so the check and the correction are the same
602
+ * gesture; and a detach link repeated down a column of references is noise in the
603
+ * scan path for an act most readers never perform. Depth is not a hiding place
604
+ * here: pressing a field-shaped value is the first thing anyone tries, and the
605
+ * verb is plainly visible once open.
606
+ *
607
+ * The anatomy is the one a container-with-controls REQUIRES and that hand-rolling
608
+ * reliably gets wrong: the surface is a role-less, non-focusable `Pressable` (a
609
+ * button must never contain a button), an empty `PressDoor` sibling carries the
610
+ * tab stop / accessible name / focus ring, and the interior verb lifts above the
611
+ * door on `zIndex`. Hover is tracked on the container itself so the border stays
612
+ * lit while the pointer crosses Open — react-native-web otherwise releases the
613
+ * parent's hover to the innermost pressable and the edge flickers out.
614
+ *
615
+ * This is a TEMPLATE composition, not yet a kit primitive: it earns extraction on
616
+ * its second consumer, and that extraction is about this ANATOMY, not the layout.
617
+ */
618
+ function ReferenceValue(props: {
619
+ name: string;
620
+ code?: string;
621
+ facts: { label: string; value: string }[];
622
+ /** Announced name of the peek trigger, e.g. "Harbor Freight Lines — details". */
623
+ accessibilityLabel: string;
624
+ onOpen: () => void;
625
+ openLabel: string;
626
+ /** Detach — omit where the link cannot be broken (a handoff's sibling record). */
627
+ onRemove?: () => void;
628
+ }) {
629
+ const { name, code, facts, accessibilityLabel, onOpen, openLabel, onRemove } = props;
630
+ const anchor = useRef<View>(null);
631
+ const [peekOpen, setPeekOpen] = useState(false);
632
+ return (
633
+ <Popover open={peekOpen} onOpenChange={setPeekOpen} triggerRef={anchor} side="bottom" align="start">
634
+ {/* THE KIT'S FIELD, not a lookalike. This was hand-rolled — a Pressable
635
+ wearing copies of the field's border, radius, height and hover — and it
636
+ promptly fell out of step the moment the kit's field changed: it kept a
637
+ pointer cursor no editor has, and missed the hover TINT that every other
638
+ field gained. A copied surface always drifts; the fix is to stop copying.
639
+
640
+ `InlineEditView` gives all of it: the surface and its states, the verb as
641
+ a SIBLING of the press target (a button must not contain a button), hover
642
+ tracked on the box so the border holds while the pointer crosses the
643
+ verb, and focus-within. `anchorRef` hands the popover the FIELD's box —
644
+ the press target inside is narrower, and a peek anchored there would
645
+ open misaligned with the row it describes.
646
+
647
+ OPEN, never toggle: the press target and the popover's own dismissal
648
+ would otherwise fight, and idempotent `true` makes the pair safe. The
649
+ popover closes on outside press / Escape. */}
650
+ <InlineEditView
651
+ anchorRef={anchor}
652
+ accessibilityLabel={accessibilityLabel}
653
+ onPress={() => setPeekOpen(true)}
654
+ active={peekOpen}
655
+ display={
656
+ <View style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 6 }}>
657
+ <Text size="sm" numberOfLines={1}>{name}</Text>
658
+ {code ? <Text size="sm" color="muted" numberOfLines={1}>{code}</Text> : null}
659
+ </View>
660
+ }
661
+ actions={
662
+ /* Dismiss the peek on the way out: the verb sits inside the popover's
663
+ own trigger and stops propagation, so nothing else will close it —
664
+ and a summary left hanging over the record you just opened describes
665
+ what you are now looking at. */
666
+ <InlineButton title="Open" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
667
+ }
668
+ />
669
+ {/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one. A popover
670
+ is dialog-scale, so the identity takes the ramp's dialog rung
671
+ (`DialogSectionHeadingTitle`, ####) with the code as its description
672
+ rather than a hand-picked font weight; the facts are `DetailRow`s, which
673
+ is what label-beside-value IS everywhere else on this page; and the
674
+ destructive verb is fenced off by a `Divider` instead of floating after
675
+ the last fact. Width matches `Peek`'s own content width so every peek in
676
+ an app is the same object.
677
+ `labelWidth` is the one override, and it is not arbitrary: a `DetailTable`
678
+ STACKS its columns below `labelWidth + MIN_CONTROL_WIDTH + 24`, so the
679
+ page's 150 would flip a 320 popover into stacked form grammar. 88 keeps
680
+ the summary side-by-side, which is the whole point of a glance. */}
681
+ <PopoverContent style={{ width: 320 }} disableBodyScroll>
682
+ <View style={{ gap: 12 }}>
683
+ <DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
684
+ <DetailTable labelWidth={88}>
685
+ {facts.map((f) => (
686
+ <DetailRow key={f.label} label={f.label} flat>
687
+ <InlineStatic value={f.value || "—"} />
688
+ </DetailRow>
689
+ ))}
690
+ </DetailTable>
691
+ {/* THE FOOTER — destructive LEFT, go-to RIGHT, the kit's convention.
692
+ Open repeats the verb on the surface deliberately: the popover now
693
+ covers the button that opened it, and having read the facts, "take me
694
+ there" is the next move. That is the shape `Peek` prescribes — a
695
+ summary with one action to the full record. Remove closes the peek
696
+ first, because the surface it is anchored to is about to stop
697
+ existing. */}
698
+ <Divider />
699
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
700
+ {onRemove ? (
701
+ // No icon: an ✕ beside "Remove" says it twice, and the glyph alone
702
+ // made this read as the popover's dismiss rather than a decision.
703
+ <Button title="Remove" color="danger" onPress={() => { setPeekOpen(false); onRemove(); }} />
704
+ ) : null}
705
+ <View style={{ flex: 1 }} />
706
+ <Button title="Open" color="secondary" onPress={() => { setPeekOpen(false); onOpen(); }} />
707
+ </View>
708
+ </View>
709
+ </PopoverContent>
710
+ </Popover>
711
+ );
712
+ }
713
+
714
+ /** One stop on a route. `place` + when the movement is expected there; the ROLE
715
+ * (origin, via, destination) is DERIVED from position, never stored — a stored
716
+ * role lies the moment the list is reordered. */
717
+ interface Stop {
718
+ id: string;
719
+ place: string;
720
+ eta: string | null;
721
+ }
722
+
723
+ /**
724
+ * A ROUTE — the kit's `Sequence`, because a route is an ordered list whose ORDER
725
+ * is the data.
726
+ *
727
+ * The three fields this replaces (origin, transfer point, destination) could
728
+ * not express either thing a route IS: it is ordered, and it has as many stops
729
+ * as it has. A second transfer had nowhere to go, and three identical grey boxes
730
+ * said nothing about which came first.
731
+ *
732
+ * The kit draws the rail, the reorder/remove controls and the spacing; the
733
+ * template supplies what a stop CONTAINS and what its position MEANS.
734
+ */
735
+ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[]) => void }) {
736
+ const patch = (id: string, next: Partial<Stop>) =>
737
+ onChange(stops.map((s) => (s.id === id ? { ...s, ...next } : s)));
738
+ const swap = (i: number, j: number) => {
739
+ const next = [...stops];
740
+ [next[i], next[j]] = [next[j], next[i]];
741
+ onChange(next);
742
+ };
743
+ return (
744
+ <View style={{ gap: 6 }}>
745
+ <Sequence>
746
+ {stops.map((stop, i) => {
747
+ // Derived, so a move or an insert relabels its neighbours for free.
748
+ const role = i === 0 ? "Origin" : i === stops.length - 1 ? "Destination" : "Via";
749
+ return (
750
+ <SequenceItem
751
+ key={stop.id}
752
+ role={role}
753
+ accessibilityName={stop.place || role}
754
+ onMoveUp={() => swap(i, i - 1)}
755
+ onMoveDown={() => swap(i, i + 1)}
756
+ // A route needs its two ends; only what is BETWEEN them can go.
757
+ onRemove={stops.length > 2 ? () => onChange(stops.filter((s) => s.id !== stop.id)) : undefined}
758
+ >
759
+ <View style={{ flexGrow: 1, flexBasis: 150, minWidth: 0 }}>
760
+ <InlineTextInput
761
+ value={stop.place}
762
+ onSave={(v) => patch(stop.id, { place: v })}
763
+ placeholder="Add a place…"
764
+ accessibilityLabel={`${role} place`}
765
+ />
766
+ </View>
767
+ <View style={{ width: 130 }}>
768
+ <InlineDatePicker
769
+ value={stop.eta}
770
+ onSave={(v) => patch(stop.id, { eta: v })}
771
+ onClear={() => patch(stop.id, { eta: null })}
772
+ locale="en-US"
773
+ placeholder="Expected…"
774
+ accessibilityLabel={`${role} expected date`}
775
+ />
776
+ </View>
777
+ </SequenceItem>
778
+ );
779
+ })}
780
+ </Sequence>
781
+ {/* Indented onto the rail's own column, so the affordance to grow the list
782
+ sits under the list rather than beside it. */}
783
+ <View style={{ flexDirection: "row", paddingLeft: SEQUENCE_INSET }}>
784
+ {/* A stop is added BEFORE the destination — a new leg is always in the
785
+ middle of a journey; nobody adds one past the end. */}
786
+ <TextLink
787
+ size="sm"
788
+ onPress={() =>
789
+ onChange([
790
+ ...stops.slice(0, -1),
791
+ { id: `st_${stops.length}_${stops[stops.length - 1].id}`, place: "", eta: null },
792
+ stops[stops.length - 1],
793
+ ])
794
+ }
795
+ >
796
+ Add stop
797
+ </TextLink>
798
+ </View>
799
+ </View>
800
+ );
801
+ }
802
+
803
+ /** A party ROW — a reference when one is attached, the find-or-create picker when
804
+ * not. The two states share the row so the field never moves; the picker is the
805
+ * field's EMPTY state, not a different kind of surface. */
806
+ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onRemove }: {
807
+ role: string;
808
+ rec: Customer | null;
809
+ options: PickerOption<string, Customer>[];
810
+ placeholder: string;
811
+ onPick: (opt: PickerOption<string, Customer>) => void;
812
+ onOpen: () => void;
813
+ onRemove: () => void;
814
+ }) {
815
+ return (
816
+ <DetailRow label={role}>
817
+ {rec ? (
818
+ <ReferenceValue
819
+ name={rec.name}
820
+ code={rec.code}
821
+ facts={[
822
+ { label: "Contact", value: rec.contact },
823
+ { label: "City", value: rec.city },
824
+ { label: "Tax ID", value: rec.taxId },
825
+ ]}
826
+ accessibilityLabel={`${rec.name} — details`}
827
+ openLabel={`Open ${rec.name}`}
828
+ onOpen={onOpen}
829
+ onRemove={onRemove}
830
+ />
831
+ ) : (
832
+ <Combobox options={options} onValueChange={onPick} reflectSelection={false}>
833
+ <ComboboxInput icon="search" placeholder={placeholder} accessibilityLabel={role} />
834
+ <ComboboxContent emptyText="No match" />
835
+ </Combobox>
836
+ )}
837
+ </DetailRow>
838
+ );
839
+ }
840
+
659
841
  export function TplRecord() {
660
842
  const id = useRef(1);
661
843
  const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
@@ -688,52 +870,6 @@ export function TplRecord() {
688
870
  // ── the audit trail — real actions on this surface append to it. An entry
689
871
  // DRILLS DOWN via the Timeline `details` chevron: a field change carries its
690
872
  // From → To diff, a document entry the pressable file, an issue its amount.
691
- const [activity, setActivity] = useState<TimelineItem[]>(() => [
692
- {
693
- id: "ac_4", icon: "calendar", iconColor: colors.zinc[500],
694
- label: "Due date changed — Sarah Chen",
695
- description: "Pulled in a week after the customer confirmed the window.",
696
- right: <Text size="xs" color="muted">16 Jun, 10:02 AM</Text>,
697
- details: (
698
- <DetailTable labelWidth={80}>
699
- <DetailRow label="From"><InlineStatic value="06/29/2026" tabular /></DetailRow>
700
- <DetailRow label="To"><InlineStatic value="06/22/2026" tabular /></DetailRow>
701
- </DetailTable>
702
- ),
703
- },
704
- {
705
- id: "ac_3", icon: "file-text", iconColor: colors.zinc[500],
706
- label: "booking-confirmation.pdf added — David Park",
707
- right: <Text size="xs" color="muted">16 Jun, 9:20 AM</Text>,
708
- details: (
709
- <FileRow
710
- name="booking-confirmation.pdf"
711
- mimeType="application/pdf"
712
- onPress={() => setPreview({ files: [{ id: "ac_f1", filename: "booking-confirmation.pdf", mimeType: "application/pdf", url: MOCK_PDF_URL }], index: 0 })}
713
- />
714
- ),
715
- },
716
- { id: "ac_2", icon: "building-2", iconColor: colors.zinc[500], label: "Customer attached — Harbor Freight Lines", right: <Text size="xs" color="muted">15 Jun, 2:31 PM</Text> },
717
- { id: "ac_1", icon: "plus", iconColor: colors.blue[500], label: "Record created — Sarah Chen", right: <Text size="xs" color="muted">15 Jun, 2:30 PM</Text> },
718
- ]);
719
- const logActivity = (
720
- icon: TimelineItem["icon"],
721
- label: string,
722
- extra?: { iconColor?: string; description?: string; details?: ReactNode },
723
- ) =>
724
- setActivity((prev) => [
725
- {
726
- id: `ac_${prev.length + 1}`,
727
- icon,
728
- iconColor: extra?.iconColor ?? colors.zinc[500],
729
- label,
730
- description: extra?.description,
731
- details: extra?.details,
732
- right: <Text size="xs" color="muted">{stamp(new Date())}</Text>,
733
- },
734
- ...prev,
735
- ]);
736
-
737
873
  // ── the fees ledger — register + entity drawer (create-then-refine: Add
738
874
  // creates a blank fee and opens it; every field refines in the drawer)
739
875
  const [fees, setFees] = useState<Fee[]>(FEE_SEED);
@@ -743,17 +879,16 @@ export function TplRecord() {
743
879
  const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", paid: false, note: "" };
744
880
  setFees((prev) => [...prev, f]);
745
881
  setFeeView({ kind: "edit", id: f.id });
746
- logActivity("receipt", "Fee added");
747
882
  };
748
883
  const openFee = feeView ? fees.find((f) => f.id === feeView.id) ?? null : null;
749
884
  const feeIdx = openFee ? fees.findIndex((f) => f.id === openFee.id) : -1;
750
885
  const deleteFee = (f: Fee) => {
751
886
  Alert.alert(`Remove ${f.name || "this fee"}?`, "The fee comes off the record's ledger. This can't be undone.", [
752
887
  { text: "Cancel", style: "cancel" },
753
- { text: "Remove", style: "destructive", onPress: () => { setFees((prev) => prev.filter((x) => x.id !== f.id)); setFeeView(null); logActivity("receipt", `Fee removed — ${f.name || "unnamed"}`, { description: `${formatMoney(f.amount)}${f.party ? ` — ${f.party}` : ""}` }); } },
888
+ { text: "Remove", style: "destructive", onPress: () => { setFees((prev) => prev.filter((x) => x.id !== f.id)); setFeeView(null); } },
754
889
  ]);
755
890
  };
756
- // The status column speaks in facts: Paid · Overdue · Due <date> · Unpaid.
891
+ // The status column speaks in facts: Paid, Overdue, Due <date>, Unpaid.
757
892
  const feeStatus = (f: Fee): { text: string; danger: boolean } =>
758
893
  f.paid ? { text: "Paid", danger: false }
759
894
  : feeOverdue(f) ? { text: "Overdue", danger: true }
@@ -778,6 +913,37 @@ export function TplRecord() {
778
913
  const [reference, setReference] = useState("");
779
914
  const [weight, setWeight] = useState<number | null>(1250);
780
915
 
916
+ // ── TRANSPORT — a DENSE section, faithfully dense on purpose: two groups of
917
+ // ~10 fields each, mixed editor types, two record references, a derived
918
+ // warning, and one "Show more" expansion. This is the shape a real operations
919
+ // record arrives at, and the surface every compaction idea has to be judged
920
+ // against — measure any change here, not on a four-field group.
921
+ const [shipTo, setShipTo] = useState<string | null>("cus_03");
922
+ const [notify, setNotify] = useState<string | null>(null);
923
+ const [carrier, setCarrier] = useState<string | null>("cus_05");
924
+ const [docType, setDocType] = useState("original");
925
+ const [waybill, setWaybill] = useState("WB-4471902");
926
+ const [orderRef, setOrderRef] = useState("");
927
+ const [copies, setCopies] = useState<number | null>(3);
928
+ const [incoterm, setIncoterm] = useState<string | null>("dap");
929
+ const [chargeTerms, setChargeTerms] = useState("prepaid");
930
+ const [booking, setBooking] = useState("BK-208814");
931
+ const [vehicle, setVehicle] = useState("");
932
+ const [trip, setTrip] = useState("");
933
+ // A ROUTE IS A SEQUENCE, so it is state shaped like one — not an origin field,
934
+ // a transfer field and a destination field, which has nowhere to put a SECOND
935
+ // transfer and cannot say that the stops are ordered.
936
+ const [stops, setStops] = useState<Stop[]>([
937
+ { id: "st_1", place: "Rotterdam", eta: "2026-06-18" },
938
+ { id: "st_2", place: "Antwerp", eta: "2026-06-20" },
939
+ { id: "st_3", place: "Hamburg", eta: "2026-06-24" },
940
+ ]);
941
+ const [dropPoint, setDropPoint] = useState("");
942
+ const [scheduleNote, setScheduleNote] = useState("delayed");
943
+ const shipToRec = customers.find((c) => c.id === shipTo) ?? null;
944
+ const notifyRec = customers.find((c) => c.id === notify) ?? null;
945
+ const carrierRec = customers.find((c) => c.id === carrier) ?? null;
946
+
781
947
  // ── customer section — TWO states: attached (read-only card, Remove
782
948
  // detaches) or empty (the find-or-create search). The "create" branch opens
783
949
  // a focused Dialog; non-null draft = the dialog is open.
@@ -871,10 +1037,8 @@ export function TplRecord() {
871
1037
  // (openExternal(file.url)).
872
1038
  // One sort at a time; the third toggle on a column clears it (register law).
873
1039
  const [docSort, setDocSort] = useState<SortState | null>(null);
874
- const [docSearch, setDocSearch] = useState("");
875
- const docQuery = docSearch.trim().toLowerCase();
876
1040
  const visibleFiles = sortBy(
877
- docQuery ? files.filter((f) => f.name.toLowerCase().includes(docQuery)) : files,
1041
+ files,
878
1042
  docSort,
879
1043
  (f, key) => (key === "doc" ? f.name.toLowerCase() : key === "size" ? f.sizeKB : f.addedAt),
880
1044
  );
@@ -1034,7 +1198,6 @@ export function TplRecord() {
1034
1198
  setTimeout(() => {
1035
1199
  setDocSet(chosen.map((f) => ({ id: `set-${f.id}`, name: `${f.slug}_${code}.pdf`, mimeType: "application/pdf", kind: "PDF", sizeKB: f.sizeKB, added: "just now", addedAt: 999 })));
1036
1200
  setProducing(false);
1037
- logActivity("file-stack", `Document set created — ${chosen.length} ${chosen.length === 1 ? "form" : "forms"}`, { description: chosen.map((f) => f.label).join(", ") });
1038
1201
  }, 900);
1039
1202
  };
1040
1203
  // Missing fields DEGRADE the outcome (those fields print blank, filled by
@@ -1074,7 +1237,6 @@ export function TplRecord() {
1074
1237
  setTimeout(() => {
1075
1238
  setRcptDoc({ id: "rcpt-1", name: `delivery-receipt_${code}.pdf`, mimeType: "application/pdf", kind: "PDF", sizeKB: 64, added: "just now", addedAt: 999 });
1076
1239
  setRcptProducing(false);
1077
- logActivity("file-text", "Delivery receipt created — RCPT-2026-0715");
1078
1240
  }, 900);
1079
1241
  };
1080
1242
 
@@ -1149,14 +1311,12 @@ export function TplRecord() {
1149
1311
  const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
1150
1312
  const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
1151
1313
  const allMissing = invoices.flatMap(missingMethods);
1152
- const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
1153
1314
 
1154
1315
  const issue = (inv: Invoice) => {
1155
1316
  seq.current += 1;
1156
1317
  const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
1157
1318
  setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
1158
1319
  setConfirmIssueKey(null);
1159
- logActivity("credit-card", `Invoice ${ref} issued — ${inv.title}`, { description: formatMoney(invoiceTotal(inv)) });
1160
1320
  };
1161
1321
 
1162
1322
  const printReceipt = () => {
@@ -1190,7 +1350,6 @@ export function TplRecord() {
1190
1350
  : null;
1191
1351
 
1192
1352
  // ── the handoff flow — the dialog's fields, the TRAIL of marks, and the
1193
- // SIBLING records the handoffs created (shown as LinkedRecordCards)
1194
1353
  const [handoffOpen, setHandoffOpen] = useState(false);
1195
1354
  // The desk the OPEN dialog is handing to, captured on press. Not re-derived
1196
1355
  // from `stage`: confirming advances the stage while the dialog is still fading
@@ -1198,41 +1357,16 @@ export function TplRecord() {
1198
1357
  const [handoffTo, setHandoffTo] = useState<Desk>("operations");
1199
1358
  const [handoffAssignee, setHandoffAssignee] = useState<string | null>(null);
1200
1359
  const [handoffNote, setHandoffNote] = useState("");
1201
- const [handoffs, setHandoffs] = useState<TimelineItem[]>([]);
1202
- const [siblings, setSiblings] = useState<SiblingRecord[]>([]);
1203
- const [siblingOpen, setSiblingOpen] = useState<string | null>(null);
1204
- const openSibling = siblingOpen ? siblings.find((sb) => sb.id === siblingOpen) ?? null : null;
1205
1360
  // ── the customer detail drawer (the linked record's full info)
1206
1361
  const [customerOpen, setCustomerOpen] = useState(false);
1207
- // Confirm MARKS the handoff — whom · where · the note — and, in a real app,
1208
- // CREATES the next desk's record on ITS table, linked here. The mark shows
1209
- // that linked sibling as a reference; its summary lives on the sibling.
1362
+ // Confirm MARKS the handoff — whom, where, the note.
1210
1363
  //
1211
1364
  // It commits what the DIALOG captured (`handoffTo`), never what `stage` implies
1212
1365
  // right now: the dialog is a statement about one handoff, and re-deriving the
1213
1366
  // target at confirm time would give two sources for the same fact.
1214
1367
  const confirmHandoff = () => {
1215
1368
  if (handoffAssignee == null) return;
1216
- const to = TEAM.find((m) => m.id === handoffAssignee);
1217
1369
  const at = new Date();
1218
- const deskName = stageOf(handoffTo).label;
1219
- const siblingCode = `${handoffTo === "operations" ? "OP" : "AC"}-2026-00${(siblingSeq += 1)}`;
1220
- setSiblings((prev) => [
1221
- { id: siblingCode, code: siblingCode, deskKey: handoffTo, desk: deskName, assignee: to?.name ?? "", note: handoffNote.trim(), at: stamp(at) },
1222
- ...prev,
1223
- ]);
1224
- setHandoffs((prev) => [
1225
- {
1226
- id: `h_${prev.length + 1}`,
1227
- icon: "send",
1228
- iconColor: colors.blue[500],
1229
- label: `Handed off to ${deskName} — ${to?.name ?? ""}`,
1230
- description: handoffNote.trim() || undefined,
1231
- right: <Text size="xs" color="muted">{stamp(at)}</Text>,
1232
- },
1233
- ...prev,
1234
- ]);
1235
- logActivity("send", `Handed off to ${deskName} — ${to?.name ?? ""}`, { iconColor: colors.blue[500], description: `${siblingCode} created and linked` });
1236
1370
  // The handoff STAMPS the desk it opens — the dialog already asked whom and
1237
1371
  // it is happening now, so the receiving stage's own fields must not come up
1238
1372
  // blank and re-ask. They stay editable there; this is the default, not a lock.
@@ -1252,80 +1386,49 @@ export function TplRecord() {
1252
1386
  {
1253
1387
  text: "Close record",
1254
1388
  onPress: () => {
1255
- logActivity("circle-check", "Record closed", { iconColor: colors.emerald[500] });
1256
1389
  setStage("closed");
1257
1390
  },
1258
1391
  },
1259
1392
  ]);
1260
1393
  };
1261
- // RECALL — the undo: withdraws the sibling (a real app cancels the sibling
1262
- // record, and gates recall on it being UNTOUCHED at its desk) and returns
1263
- // the desk. History is never erased — the trail keeps the original mark and
1264
- // gains a recall mark; the handoff CTA comes back, so redo is possible.
1265
- //
1266
- // It undoes exactly ONE step: back to the desk BEFORE the one this handoff
1267
- // opened, and it clears that desk's custody stamp. Rewinding to the first desk
1268
- // instead would silently drop the desks in between, and leaving the stamp
1269
- // behind would leave `stage` and the custody map disagreeing — an unreached
1270
- // desk still holding a taken-on date and an owner.
1271
- const recallHandoff = (sb: SiblingRecord) => {
1272
- const backTo = DESKS[DESKS.findIndex((d) => d.key === sb.deskKey) - 1]?.key ?? "sales";
1273
- Alert.alert(`Recall the handoff?`, `${sb.code} is withdrawn from ${sb.desk} and the record returns to ${stageOf(backTo).label}. The trail keeps both marks.`, [
1274
- { text: "Cancel", style: "cancel" },
1275
- {
1276
- text: "Recall",
1277
- style: "destructive",
1278
- onPress: () => {
1279
- setSiblings((prev) => prev.filter((x) => x.id !== sb.id));
1280
- setSiblingOpen(null);
1281
- setDeskSince((p) => ({ ...p, [sb.deskKey]: "" }));
1282
- setDeskOwner((p) => ({ ...p, [sb.deskKey]: null }));
1283
- setStage(backTo);
1284
- setHandoffs((prev) => [
1285
- { id: `h_${prev.length + 1}`, icon: "x", iconColor: colors.zinc[500], label: `Handoff recalled — ${sb.code} withdrawn`, right: <Text size="xs" color="muted">{stamp(new Date())}</Text> },
1286
- ...prev,
1287
- ]);
1288
- logActivity("x", `Handoff recalled — ${sb.code} withdrawn from ${sb.desk}`);
1289
- },
1290
- },
1291
- ]);
1292
- };
1293
-
1294
1394
  // ── the outline rail — jump to a section instead of scrolling the long page
1295
- // GENERAL leads the rail — the item's core details; everything after it is
1296
- // supplementary (discussion, work, documents, parties, money, lifecycle).
1395
+ // GENERAL leads the rail — the item's core details (the customer among them,
1396
+ // since a reference is a field); everything after it is supplementary
1397
+ // (discussion, work, documents, money, lifecycle).
1297
1398
  const SECTIONS = [
1298
1399
  { key: "general", label: "General", icon: "file-text" },
1299
- { key: "comments", label: "Comments", icon: "message-square" },
1400
+ { key: "transport", label: "Transport", icon: "map-pin" },
1300
1401
  { key: "progress", label: "Progress", icon: "git-branch" },
1301
1402
  { key: "documents", label: "Documents", icon: "folder-closed" },
1302
- { key: "customer", label: "Customer", icon: "building-2" },
1303
1403
  { key: "fees", label: "Fees", icon: "receipt" },
1304
1404
  { key: "billing", label: "Billing", icon: "credit-card" },
1305
1405
  { key: "docset", label: "Document set", icon: "file-stack" },
1306
1406
  { key: "receipt", label: "Delivery receipt", icon: "log-in" },
1307
- { key: "activity", label: "Activity", icon: "clock" },
1308
- { key: "handoff", label: "Handoff", icon: "send" },
1309
1407
  { key: "danger", label: "Danger zone", icon: "circle-alert" },
1310
1408
  ] as const;
1311
- const nav = useSectionNav(["general", "comments", "progress", "documents", "customer", "fees", "billing", "docset", "receipt", "activity", "handoff", "danger"] as const);
1409
+ const nav = useSectionNav(["general", "transport", "progress", "documents", "fees", "billing", "docset", "receipt", "danger"] as const);
1312
1410
  // Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
1313
1411
  // it opens a full-page section picker (Escape / the close control dismiss).
1314
1412
  const [sectionsOpen, setSectionsOpen] = useState(false);
1315
1413
  const [pageWidth, setPageWidth] = useState<number | null>(null);
1316
- // The rail lives in the page's LEFT GUTTER and an equal spacer balances it on
1317
- // the right, so the reading column lands dead-centre in the viewport (the docs
1318
- // layout, where the right-hand TOC is the balancer). The rail only earns its
1319
- // place once the centred column still has room beside it: 2 × rail + a
1320
- // readable column below that it collapses to the pinned bar.
1321
- const wide = pageWidth != null && pageWidth >= GUTTER * 2 + 560;
1414
+ // THREE COLUMNS: the outline rail in the left gutter, the record, and the
1415
+ // discussion panel in the right. The right gutter used to be empty — reserved
1416
+ // only to centre the column so the panel costs no width the page did not
1417
+ // already spend. Both only earn their place while a readable column survives
1418
+ // between them; below that they collapse to the pinned bar and its drawer.
1419
+ const wide = pageWidth != null && pageWidth >= GUTTER + PANEL_GUTTER + 560;
1420
+ // Narrow: the discussion is one tap off the same bar the sections are on —
1421
+ // on the RIGHT, opposite the section picker, since it is about the record as a
1422
+ // whole rather than a place in it.
1423
+ const [commentsOpen, setCommentsOpen] = useState(false);
1322
1424
  // The rail floats over its RESERVED gutter rather than sitting in the scroll
1323
1425
  // row, so the ScrollView keeps the FULL page width — wheel anywhere and the
1324
1426
  // record scrolls. (Capping the ScrollView at the column's width instead makes
1325
1427
  // the gutters dead zones.) Mirrors the flex maths the row does below: the
1326
1428
  // group is centred inside the 28px content padding.
1327
- const groupW = pageWidth == null ? 0 : Math.min(pageWidth - 28 * 2, GUTTER * 2 + CONTENT_MAX);
1429
+ const groupW = pageWidth == null ? 0 : Math.min(pageWidth - 28 * 2, GUTTER + CONTENT_MAX + PANEL_GUTTER);
1328
1430
  const railLeft = pageWidth == null ? 0 : 28 + Math.max(0, (pageWidth - 28 * 2 - groupW) / 2);
1431
+ const panelRight = railLeft;
1329
1432
 
1330
1433
  return (
1331
1434
  // Measure-then-reveal: hidden until the width is known, so the rail/bar
@@ -1345,6 +1448,16 @@ export function TplRecord() {
1345
1448
  icon="list-collapse"
1346
1449
  onPress={() => setSectionsOpen(true)}
1347
1450
  />
1451
+ <View style={{ flex: 1 }} />
1452
+ {/* WHERE it sits carries the meaning: sections on the left (where you
1453
+ ARE), the discussion on the right (what is being SAID) — the same
1454
+ two poles the wide layout puts in its two gutters. */}
1455
+ <IconButton
1456
+ icon="message-square"
1457
+ tooltip="Comments"
1458
+ accessibilityLabel={`Comments (${comments.length})`}
1459
+ onPress={() => setCommentsOpen(true)}
1460
+ />
1348
1461
  </View>
1349
1462
  )}
1350
1463
  <View style={{ flex: 1 }}>
@@ -1354,7 +1467,7 @@ export function TplRecord() {
1354
1467
  and an equal empty gutter that balances it — so the column's centre is
1355
1468
  the window's centre. Both gutters live INSIDE the scroller, so they
1356
1469
  scroll like everything else. */}
1357
- <View style={{ width: "100%", maxWidth: wide ? GUTTER * 2 + CONTENT_MAX : CONTENT_MAX, alignSelf: "center", flexDirection: "row" }}>
1470
+ <View style={{ width: "100%", maxWidth: wide ? GUTTER + CONTENT_MAX + PANEL_GUTTER : CONTENT_MAX, alignSelf: "center", flexDirection: "row" }}>
1358
1471
  {wide ? <View style={{ width: GUTTER, flexShrink: 0 }} /> : null}
1359
1472
  {booting ? (
1360
1473
  /* first paint — Skeleton MIRRORS the final layout (summary →
@@ -1388,14 +1501,14 @@ export function TplRecord() {
1388
1501
  hairline BETWEEN top-level blocks (a conditional block that renders
1389
1502
  null never leaves a stray divider) */}
1390
1503
  <SectionStack style={{ flex: 1, minWidth: 0 }}>
1391
- {/* header band — identity · attention · a light summary. No stage chip
1504
+ {/* header band — identity, attention, a light summary. No stage chip
1392
1505
  here (Progress carries the desk and the act), no breadcrumb,
1393
1506
  no create CTA: back lives in the panel, creation belongs to the
1394
1507
  REGISTER (the list owns "new"), the record page only edits. */}
1395
1508
  <View style={{ gap: 16 }}>
1396
1509
  <RecordSummary
1397
1510
  title={code}
1398
- subtitle={customer ? [customer.name, customer.city].filter(Boolean).join(" · ") : "No customer"}
1511
+ subtitle={customer ? [customer.name, customer.city].filter(Boolean).join(", ") : "No customer"}
1399
1512
  />
1400
1513
  {/* the record's ATTENTION state, on top — record-scoped, so it reads
1401
1514
  as a Callout here (field-scoped state stays on its field) */}
@@ -1419,7 +1532,7 @@ export function TplRecord() {
1419
1532
  SubsectionStack (space-only beat): a headingless LEAD group (the
1420
1533
  facts every desk reads first) then the named groups — the rail
1421
1534
  stays sections-only, grouping lives INSIDE the section. Every
1422
- group's DetailTable repeats the SAME labelWidth/trailingWidth, so
1535
+ group's DetailTable repeats the SAME labelWidth, so
1423
1536
  the grid stays aligned through the groups; read-only values are
1424
1537
  InlineStatic in the same column, aligned to the pixel. */}
1425
1538
  <View onLayout={nav.register("general")}>
@@ -1429,7 +1542,66 @@ export function TplRecord() {
1429
1542
  </SectionHeading>
1430
1543
  <SubsectionStack>
1431
1544
  <Subsection>
1432
- <DetailTable labelWidth={150} trailingWidth={88}>
1545
+ <DetailTable labelWidth={150}>
1546
+ {/* A REFERENCE TO ANOTHER RECORD IS A FIELD, NOT A SECTION.
1547
+ A section is a claim that a distinct AREA of the record lives
1548
+ here, and it buys an outline-rail entry and a heading; one
1549
+ pointer to one other row never earns that. The machinery that
1550
+ tempts you to promote it — a find-or-create search, a create
1551
+ dialog, a validity warning — is just this field's EMPTY state,
1552
+ its picker, and its annotation. So it sits among the order's
1553
+ other core facts, where a reader looks for the bill-to party.
1554
+
1555
+ The value is a `ReferenceValue` (above): the inline editor's
1556
+ own resting surface, so the reference sits in the value column
1557
+ like every editor around it rather than as a card that
1558
+ outweighs the fields it sits among. Its three verbs are three
1559
+ targets — press for the facts, the arrow for the record,
1560
+ Remove in the row's `trailing` where row-level verbs live. */}
1561
+ <DetailRow
1562
+ label="Customer"
1563
+ description="The bill-to party — its tax ID gates invoicing below."
1564
+ warning={customer && !taxIdValid ? "No valid tax ID — invoicing stays blocked until this customer carries one." : undefined}
1565
+ >
1566
+ {customer ? (
1567
+ <ReferenceValue
1568
+ name={customer.name}
1569
+ code={customer.code}
1570
+ facts={[
1571
+ { label: "Tax ID", value: customer.taxId },
1572
+ { label: "Contact", value: customer.contact },
1573
+ { label: "City", value: customer.city },
1574
+ ]}
1575
+ accessibilityLabel={`${customer.name} — details`}
1576
+ openLabel="Open customer"
1577
+ onOpen={() => setCustomerOpen(true)}
1578
+ onRemove={() => setCustomerId(null)}
1579
+ />
1580
+ ) : (
1581
+ <Combobox
1582
+ options={customerOptions}
1583
+ onValueChange={onPickCustomer}
1584
+ renderOptionContent={(o) => (
1585
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
1586
+ <View style={{ width: 28, height: 28, borderRadius: 7, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
1587
+ <Icon name="building-2" size={14} color={colors.zinc[600]} />
1588
+ </View>
1589
+ <View style={{ flex: 1, minWidth: 0 }}>
1590
+ <Text size="sm" numberOfLines={1}>{o.label}</Text>
1591
+ {o.data ? <Text size="xs" color="muted">{[o.data.city, o.data.taxId ? `Tax ID ${o.data.taxId}` : "No tax ID"].filter(Boolean).join(", ")}</Text> : null}
1592
+ </View>
1593
+ </View>
1594
+ )}
1595
+ reflectSelection={false}
1596
+ allowCustom
1597
+ customOptionPlacement="top"
1598
+ customOptionLabel={(q) => `Create new customer “${q}”`}
1599
+ >
1600
+ <ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
1601
+ <ComboboxContent emptyText="No customer matches — type a name to create one" />
1602
+ </Combobox>
1603
+ )}
1604
+ </DetailRow>
1433
1605
  {/* The SAME fact the Sales stage owns in Progress, shown where
1434
1606
  a reader looks for it first — bound to the one source, never
1435
1607
  a second copy: two member selects over their own state would
@@ -1460,12 +1632,27 @@ export function TplRecord() {
1460
1632
  <SubsectionHeading>
1461
1633
  <SubsectionHeadingTitle>Order</SubsectionHeadingTitle>
1462
1634
  </SubsectionHeading>
1463
- <DetailTable labelWidth={150} trailingWidth={88}>
1635
+ <DetailTable labelWidth={150}>
1464
1636
  {/* a persistent field DESCRIPTION — under the value, same
1465
1637
  vocabulary as FormField; state → `error` / a co-located
1466
1638
  Callout. Everything EXPLICIT — no hidden ⓘ gloss. */}
1467
- <DetailRow label="Reference" description="Printed on every document" trailing={<Button title="Copy" color="secondary" onPress={() => {}} />}>
1468
- <InlineTextInput value={reference} onSave={persist(setReference)} placeholder="Add buyer's reference…" accessibilityLabel="Reference" />
1639
+ {/* Copy lives ON the field, not in the row's trailing column: it
1640
+ acts on THIS value, so it travels with it, and a reader never
1641
+ has to pair a control at the page's right edge with the field
1642
+ it belongs to. `InlineButton` + the `actions` slot own the
1643
+ anatomy that makes a control inside a press target legal. */}
1644
+ <DetailRow label="Reference" description="Printed on every document">
1645
+ <InlineTextInput
1646
+ value={reference}
1647
+ onSave={persist(setReference)}
1648
+ placeholder="Add buyer's reference…"
1649
+ accessibilityLabel="Reference"
1650
+ // UNCONDITIONAL, disabled when there is nothing to copy — a
1651
+ // verb that appears once the field is non-empty changes the
1652
+ // field's width as you type, which is the one thing an inline
1653
+ // editor promises never to do.
1654
+ actions={<InlineButton title="Copy" accessibilityLabel="Copy reference" disabled={!reference} onPress={() => {}} />}
1655
+ />
1469
1656
  </DetailRow>
1470
1657
  <DetailRow label="Priority">
1471
1658
  <InlineSelect
@@ -1487,7 +1674,7 @@ export function TplRecord() {
1487
1674
  <SubsectionHeading>
1488
1675
  <SubsectionHeadingTitle>Fulfilment</SubsectionHeadingTitle>
1489
1676
  </SubsectionHeading>
1490
- <DetailTable labelWidth={150} trailingWidth={88}>
1677
+ <DetailTable labelWidth={150}>
1491
1678
  <DetailRow label="Location" description="The site that picks and ships the order">
1492
1679
  <InlineSelect value={warehouse} onSave={persist(setWarehouse)} options={WAREHOUSES} accessibilityLabel="Location" />
1493
1680
  </DetailRow>
@@ -1516,15 +1703,15 @@ export function TplRecord() {
1516
1703
  </Subsection>
1517
1704
  {/* CLASSIFICATION — the right-input-per-field law, worked. A field
1518
1705
  gets the control its SHAPE wants, not a default text box:
1519
- · ≤5 exclusive options the user should SEE → RadioPicker
1706
+ , ≤5 exclusive options the user should SEE → RadioPicker
1520
1707
  (a stacked column of full-width choices)
1521
- · a longer closed list → InlineSelect (rich options carry a
1708
+ , a longer closed list → InlineSelect (rich options carry a
1522
1709
  description line — it shows in the resting row too)
1523
- · a pick from a big REGISTRY → InlineSelect searchable (the
1710
+ , a pick from a big REGISTRY → InlineSelect searchable (the
1524
1711
  search lives in the popover; the bare Combobox is the FORM-
1525
1712
  surface control — find-or-create / attach flows)
1526
- · a boolean → CheckboxInput (Hazardous goods below)
1527
- · a DEPENDENT field renders only while its parent value makes
1713
+ , a boolean → CheckboxInput (Hazardous goods below)
1714
+ , a DEPENDENT field renders only while its parent value makes
1528
1715
  it real — no disabled ghost rows.
1529
1716
  Radio/checkbox/search are PERSISTENT controls (the control is
1530
1717
  the best display); prose-shaped values stay inline editors. */}
@@ -1532,19 +1719,25 @@ export function TplRecord() {
1532
1719
  <SubsectionHeading>
1533
1720
  <SubsectionHeadingTitle>Classification</SubsectionHeadingTitle>
1534
1721
  </SubsectionHeading>
1535
- <DetailTable labelWidth={150} trailingWidth={88}>
1722
+ <DetailTable labelWidth={150}>
1536
1723
  <DetailRow label="Service level">
1537
1724
  <InlineSelect
1538
1725
  value={serviceLevel}
1539
1726
  options={SERVICE_LEVELS}
1540
1727
  onSave={persist(setServiceLevel)}
1541
1728
  accessibilityLabel="Service level"
1729
+ /* The gloss belongs to CHOOSING, not to every later read.
1730
+ Without `renderSelected` a rich option renders the resting
1731
+ row too, so the field stands two lines tall among
1732
+ one-line neighbours — row-HEIGHT variation being what
1733
+ actually reads as clutter on a field grid. */
1542
1734
  renderOptionContent={(o) => (
1543
1735
  <View style={{ gap: 1, flexShrink: 1 }}>
1544
1736
  <Text size="sm">{o.label}</Text>
1545
1737
  <Text size="xs" color="muted">{SERVICE_LEVEL_DESC[o.value]}</Text>
1546
1738
  </View>
1547
1739
  )}
1740
+ renderSelected={(o) => <Text size="sm">{o.label}</Text>}
1548
1741
  />
1549
1742
  </DetailRow>
1550
1743
  <DetailRow label="Fulfilment status">
@@ -1601,6 +1794,7 @@ export function TplRecord() {
1601
1794
  {o.data ? <Text size="xs" color="muted">{o.data.country}</Text> : null}
1602
1795
  </View>
1603
1796
  )}
1797
+ renderSelected={(o) => <Text size="sm">{o.label}</Text>}
1604
1798
  />
1605
1799
  </DetailRow>
1606
1800
  {/* fields the OUTPUT FORMS read — each description names its
@@ -1625,7 +1819,7 @@ export function TplRecord() {
1625
1819
  <SubsectionHeading>
1626
1820
  <SubsectionHeadingTitle>System</SubsectionHeadingTitle>
1627
1821
  </SubsectionHeading>
1628
- <DetailTable labelWidth={150} trailingWidth={88}>
1822
+ <DetailTable labelWidth={150}>
1629
1823
  <DetailRow label="Record ID">
1630
1824
  <InlineStatic value={code} tabular />
1631
1825
  </DetailRow>
@@ -1635,55 +1829,167 @@ export function TplRecord() {
1635
1829
  </Section>
1636
1830
  </View>
1637
1831
 
1638
- {/* COMMENTS — the first SUPPLEMENTARY section (General owns the core
1639
- details): a coworker's note is the first thing to read on a record
1640
- someone else touched. CommentList (author-only
1641
- edit/delete via currentMemberId) + THE kit Composer; an app
1642
- wires `useComments()` + `useMembers()` to the same shapes. */}
1643
- <View onLayout={nav.register("comments")}>
1832
+ {/* TRANSPORT — the DENSE section, and the honest one. Two groups of ~10
1833
+ fields, three record references, mixed editor types, a derived
1834
+ warning, one "Show more".
1835
+
1836
+ It is here UNCOMPACTED on purpose. A real operations record reaches
1837
+ this density — the movement's paperwork, its parties, its route — and
1838
+ every idea for making a record readable has to be judged against a
1839
+ surface like this one, never against a four-field group where
1840
+ everything already fits. Read it as the BEFORE. */}
1841
+ <View onLayout={nav.register("transport")}>
1644
1842
  <Section>
1645
1843
  <SectionHeading>
1646
- <SectionHeadingTitle description="The record's discussion every question and decision stays with the order.">Comments</SectionHeadingTitle>
1844
+ <SectionHeadingTitle description="The consignment document, the parties printed on it, the carrier and the route.">Transport</SectionHeadingTitle>
1647
1845
  </SectionHeading>
1648
- <CommentList
1649
- comments={comments}
1650
- currentMemberId="mem_01"
1651
- resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
1652
- onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
1653
- onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
1654
- /* the file-capable edit form — the same injection the product uses */
1655
- renderEditForm={(p) => <CommentFileEditForm {...p} />}
1656
- /* attachments render as the SAME thumbnail grid the product uses —
1657
- press previews in the gallery (the desk's preview state) */
1658
- renderFiles={(files) => (
1659
- <FileGrid
1660
- files={files.map(commentFileToDisplay)}
1661
- itemSize={84}
1662
- onFilePress={(f) => setPreview({ files: [f], index: 0 })}
1663
- />
1664
- )}
1665
- /* compact timestamp the product formats RELATIVE ("2 days ago",
1666
- date-fns); a template stays dependency-free and deterministic */
1667
- formatTimestamp={(iso) => { const d = new Date(iso); return `${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`; }}
1668
- />
1669
- {/* THE kit Composer the one composing surface everywhere (chat,
1670
- agent prompts, comments): attach via actionsButton, the staged
1671
- files (removable until Send) ride the files slot. */}
1672
- <Composer
1673
- onSend={(content) => {
1674
- setComments((prev) => [...prev, { id: `cm_${(commentSeq += 1)}`, member_id: "mem_01", content, files: pendingFiles.length > 0 ? pendingFiles : undefined, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }]);
1675
- setPendingFiles([]);
1676
- }}
1677
- placeholder="Write a comment…"
1678
- sendLabel="Send"
1679
- actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
1680
- files={pendingFiles.length > 0 ? (
1681
- <FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
1682
- ) : undefined}
1683
- />
1846
+ <SubsectionStack>
1847
+ {/* The document and the two parties that PRINT on it — they live
1848
+ here, with its numbers and its type, because the document is the
1849
+ only place anyone reads them. */}
1850
+ <Subsection>
1851
+ <SubsectionHeading><SubsectionHeadingTitle>Consignment &amp; terms</SubsectionHeadingTitle></SubsectionHeading>
1852
+ <DetailTable labelWidth={150} minHeight={40}>
1853
+ <PartyRow
1854
+ role="Ship to" rec={shipToRec} options={customerOptions}
1855
+ placeholder="Find or create a consignee"
1856
+ onPick={(o) => setShipTo(o.value)} onOpen={() => {}} onRemove={() => setShipTo(null)}
1857
+ />
1858
+ <PartyRow
1859
+ role="Notify" rec={notifyRec} options={customerOptions}
1860
+ placeholder="Find or create a notify party"
1861
+ onPick={(o) => setNotify(o.value)} onOpen={() => {}} onRemove={() => setNotify(null)}
1862
+ />
1863
+ {/* A CHOICE IS ONE ROW, like every other field.
1864
+ `RadioPicker` renders three full-width bordered choices with
1865
+ their glosses ~170px, four rows' worth of height, for one
1866
+ field — and that variation in WEIGHT between neighbouring rows
1867
+ is what makes a field grid read as cluttered, far more than
1868
+ the number of rows does. Radios earn their place where the
1869
+ choice IS the task (a wizard step, a short form); in a grid of
1870
+ nineteen fields they shout over everything around them.
1871
+ The gloss is not lost — it moves into the option LIST, where
1872
+ it is needed while CHOOSING rather than on every later read.
1873
+ That split is what `renderSelected` is for: without it the
1874
+ resting row falls back to `renderOptionContent` and prints the
1875
+ gloss on every row, which is the tall row again by another
1876
+ route. */}
1877
+ <DetailRow label="Document type">
1878
+ <InlineSelect
1879
+ value={docType}
1880
+ onSave={persist(setDocType)}
1881
+ options={DOC_TYPES}
1882
+ renderOptionContent={choiceWithHint}
1883
+ renderSelected={(o) => <Text size="sm">{o.label}</Text>}
1884
+ accessibilityLabel="Document type"
1885
+ />
1886
+ </DetailRow>
1887
+ <DetailRow label="Waybill no.">
1888
+ <InlineTextInput value={waybill} onSave={persist(setWaybill)} placeholder="Add waybill number…" accessibilityLabel="Waybill number" />
1889
+ </DetailRow>
1890
+ <DetailRow label="Order no.">
1891
+ <InlineTextInput value={orderRef} onSave={persist(setOrderRef)} placeholder="Add order number…" accessibilityLabel="Order number" />
1892
+ </DetailRow>
1893
+ {/* NO DISCLOSURE. A "Show more" here hid three fields — and the
1894
+ fatal part is that it hid them whether or not they held a
1895
+ VALUE: a record with 3 original copies reported nothing about
1896
+ them until you found the link. A field grid's job is to say
1897
+ what the record IS, so an empty row (this record has no order
1898
+ number) and a filled one (it has 3 copies) both have to be on
1899
+ the page. Disclosure is for OTHER SUBJECTS, not for a record's
1900
+ own fields. */}
1901
+ <DetailRow label="Original copies" description="Losing an original means an indemnity letter; electronic release = 0">
1902
+ <InlineNumberInput value={copies} onSave={persist(setCopies)} min={0} placeholder="e.g. 3" accessibilityLabel="Original copies" />
1903
+ </DetailRow>
1904
+ <DetailRow label="Delivery terms" description="Where cost and risk pass from seller to buyer">
1905
+ <InlineSelect value={incoterm} onSave={persist(setIncoterm)} options={DELIVERY_TERMS} placeholder="Choose…" accessibilityLabel="Delivery terms" />
1906
+ </DetailRow>
1907
+ {/* SELECT PILLS — the third treatment for a choice, and the
1908
+ right one at TWO SHORT options: every choice stays visible
1909
+ and switching is ONE press, where a select costs two (open,
1910
+ pick) and hides the alternative until you do. It is still
1911
+ one row, because the pills wrap inside the control band.
1912
+ The ladder, by option count and label length:
1913
+ 2–3 short, switched often → `ChipGroup` (here)
1914
+ more, or long labels → `InlineSelect` (Document type)
1915
+ the choice IS the task → `RadioPicker`, in a form or
1916
+ wizard — never a row in a grid */}
1917
+ <DetailRow label="Charge terms">
1918
+ <ChipGroup
1919
+ accessibilityLabel="Charge terms"
1920
+ value={chargeTerms}
1921
+ onValueChange={setChargeTerms}
1922
+ options={CHARGE_TERMS.map((o) => ({ value: o.value, label: o.label }))}
1923
+ />
1924
+ </DetailRow>
1925
+ </DetailTable>
1926
+ </Subsection>
1927
+ {/* The carrier sits WITH its booking — the booking number, the
1928
+ vehicle, the trip and the route below are all its data.
1929
+
1930
+ UNIFORM ROWS, and that is a conclusion, not a default. Two
1931
+ compactions were tried here and both lost to this:
1932
+
1933
+ A read view behind an Edit button dropped its EMPTY fields, which
1934
+ is information loss — an empty row is the difference between "this
1935
+ movement has no vehicle" and "this section has no vehicle field" —
1936
+ and it bought that with a MODE, against the kit's law that the
1937
+ surface IS the editor.
1938
+
1939
+ Grouping correlated fields onto shared rows (route as one row,
1940
+ vehicle + trip as another) cut nine rows to six and read WORSE. A
1941
+ field grid is scanned down its VALUE COLUMN, not by counting rows:
1942
+ three editors of differing widths on one line break the single x
1943
+ every other row shares, so fewer rows carrying more objects each
1944
+ reads busier than more rows that all look alike. Placeholders
1945
+ standing in for the vanished labels finished it — once filled, the
1946
+ row was three boxes with no names.
1947
+
1948
+ Density on a field grid comes from ROW HEIGHT, COLUMN COUNT, and
1949
+ what is lifted into a summary ABOVE it — never from making one row
1950
+ unlike its neighbours. */}
1951
+ <Subsection>
1952
+ <SubsectionHeading><SubsectionHeadingTitle>Carrier &amp; route</SubsectionHeadingTitle></SubsectionHeading>
1953
+ {scheduleNote !== "on-time" ? (
1954
+ <Callout tone="warning">
1955
+ <CalloutText>The schedule changed ({scheduleNote}). Re-check every cut-off and any document already issued against this movement.</CalloutText>
1956
+ </Callout>
1957
+ ) : null}
1958
+ <DetailTable labelWidth={150} minHeight={40}>
1959
+ <PartyRow
1960
+ role="Carrier" rec={carrierRec} options={customerOptions}
1961
+ placeholder="Find or create a carrier"
1962
+ onPick={(o) => setCarrier(o.value)} onOpen={() => {}} onRemove={() => setCarrier(null)}
1963
+ />
1964
+ <DetailRow label="Booking no.">
1965
+ <InlineTextInput value={booking} onSave={persist(setBooking)} placeholder="Add booking number…" accessibilityLabel="Booking number" />
1966
+ </DetailRow>
1967
+ <DetailRow label="Vehicle">
1968
+ <InlineTextInput value={vehicle} onSave={persist(setVehicle)} placeholder="Add vehicle…" accessibilityLabel="Vehicle" />
1969
+ </DetailRow>
1970
+ <DetailRow label="Trip no.">
1971
+ <InlineTextInput value={trip} onSave={persist(setTrip)} placeholder="Add trip number…" accessibilityLabel="Trip number" />
1972
+ </DetailRow>
1973
+ {/* A ROUTE IS NOT FIELDS — see `RouteStops` above. It lives in
1974
+ a `DetailRow` so it keeps the section's label column and the
1975
+ reader's eye stays on one x; what sits in the VALUE is a list,
1976
+ because that is what a route is. */}
1977
+ <DetailRow label="Route">
1978
+ <RouteStops stops={stops} onChange={setStops} />
1979
+ </DetailRow>
1980
+ <DetailRow label="Delivery place" description="The final door, when it differs from the arrival point">
1981
+ <InlineTextInput value={dropPoint} onSave={persist(setDropPoint)} placeholder="Add delivery place…" accessibilityLabel="Delivery place" />
1982
+ </DetailRow>
1983
+ <DetailRow label="Schedule">
1984
+ <InlineSelect value={scheduleNote} onSave={persist(setScheduleNote)} options={SCHEDULE_STATES} accessibilityLabel="Schedule" />
1985
+ </DetailRow>
1986
+ </DetailTable>
1987
+ </Subsection>
1988
+ </SubsectionStack>
1684
1989
  </Section>
1685
1990
  </View>
1686
1991
 
1992
+
1687
1993
  {/* PROGRESS — the desk handoff, as the ordered positions it is. Sales →
1688
1994
  Operations → Accounting: one desk owns the record at a time, the live
1689
1995
  one carries the act that leaves it, and a passed desk keeps its own
@@ -1719,7 +2025,7 @@ export function TplRecord() {
1719
2025
  onSave={(v) => setDeskSince((p) => ({ ...p, [d.key]: v ?? "" }))}
1720
2026
  onClear={() => setDeskSince((p) => ({ ...p, [d.key]: "" }))}
1721
2027
  placeholder="Not recorded"
1722
- accessibilityLabel={`Taken on · ${d.label}`}
2028
+ accessibilityLabel={`Taken on for ${d.label}`}
1723
2029
  />
1724
2030
  </PipelineField>
1725
2031
  <PipelineField label="Owner" maxWidth={260}>
@@ -1728,7 +2034,7 @@ export function TplRecord() {
1728
2034
  value={owner}
1729
2035
  onSave={(v) => setDeskOwner((p) => ({ ...p, [d.key]: v }))}
1730
2036
  placeholder="Unassigned"
1731
- accessibilityLabel={`Owner · ${d.label}`}
2037
+ accessibilityLabel={`Owner for ${d.label}`}
1732
2038
  />
1733
2039
  </PipelineField>
1734
2040
  </>
@@ -1753,8 +2059,8 @@ export function TplRecord() {
1753
2059
  </Section>
1754
2060
  </View>
1755
2061
 
1756
- {/* DOCUMENTS — the document desk: the register Table (search · Create
1757
- documents · Add files) whose selection feeds the Use-AI fork below.
2062
+ {/* DOCUMENTS — the document desk: the register Table (search, Create
2063
+ documents, Add files) whose selection feeds the Use-AI fork below.
1758
2064
  No FileDropTarget of its OWN — the WHOLE-RECORD target above already
1759
2065
  captures a drop/paste here (and everywhere else on the surface); this
1760
2066
  section keeps only its explicit Add-files CTA and the affordance line. */}
@@ -1765,30 +2071,6 @@ export function TplRecord() {
1765
2071
  stay discoverable even though the drop target is the whole record. */}
1766
2072
  <SectionHeadingTitle description="Drag, paste, or click to add files">Documents</SectionHeadingTitle>
1767
2073
  </SectionHeading>
1768
- {/* toolbar — search LEFT, the CTAs RIGHT, one row (the register band). */}
1769
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1770
- <View style={{ flexGrow: 1, flexBasis: 220, minWidth: 200, maxWidth: 340 }}>
1771
- <SearchInput
1772
- placeholder="Search documents…"
1773
- value={docSearch}
1774
- onChangeText={setDocSearch}
1775
- accessibilityLabel="Search documents"
1776
- />
1777
- </View>
1778
- <View style={{ flex: 1 }} />
1779
- {/* no Create here — GENERATION lives in the Document set section
1780
- below (this desk = intake) */}
1781
- {/* PENDING until the user chooses: saving is a decision the dialog
1782
- asks for (save only / run a task), never a side effect of
1783
- picking — closing the dialog discards. */}
1784
- <Button
1785
- title="Add files"
1786
- color="secondary"
1787
- onPress={() => {
1788
- void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
1789
- }}
1790
- />
1791
- </View>
1792
2074
  <Table
1793
2075
  columns={DOC_COLUMNS}
1794
2076
  leading={24}
@@ -1830,85 +2112,24 @@ export function TplRecord() {
1830
2112
  </TableRow>
1831
2113
  ))}
1832
2114
  </Table>
1833
- </Section>
1834
- </View>
1835
-
1836
- {/* CUSTOMER TWO states, no edit mode. Attached: the READ-ONLY card
1837
- (the customer is a linked record, not this record's fields); the
1838
- correction path is Remove → attach the right one (the create
1839
- Dialog carries tax ID + the registry Fetch). Empty: the
1840
- find-or-create search; the custom row opens the create Dialog. */}
1841
- <View onLayout={nav.register("customer")}>
1842
- <Section>
1843
- <SectionHeading>
1844
- <SectionHeadingTitle description="The bill-to party — its tax ID gates invoicing below.">Customer</SectionHeadingTitle>
1845
- </SectionHeading>
1846
- {customer ? (
1847
- <View style={{ gap: 10 }}>
1848
- {/* The linked-record REFERENCE: the whole box is the keyboard door to the
1849
- customer's detail (the a11y contract lives in `LinkedRecordBox`); the verbs
1850
- ride inside — destructive Remove LEFT, go-to Open record RIGHT. */}
1851
- <LinkedRecordBox
1852
- icon="building-2"
1853
- name={customer.name}
1854
- subtitle={customer.code}
1855
- facts={[
1856
- { label: "Tax ID", value: customer.taxId || "—" },
1857
- { label: "Contact", value: customer.contact || "—" },
1858
- { label: "City", value: customer.city || "—" },
1859
- ]}
1860
- doorLabel={`${customer.name} — details`}
1861
- onOpen={() => setCustomerOpen(true)}
1862
- actions={
1863
- <>
1864
- <Button title="Remove" color="danger" onPress={() => setCustomerId(null)} />
1865
- <View style={{ flex: 1 }} />
1866
- <Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the customer's page */ }} />
1867
- </>
1868
- }
1869
- />
1870
- {/* invalid STATE is a Callout CO-LOCATED with what it describes —
1871
- it lives on the customer card, not floating at a section top */}
1872
- {!taxIdValid ? (
1873
- <Callout tone="warning">
1874
- <CalloutText>No valid tax ID — invoicing stays blocked. Replace this customer with one that carries it.</CalloutText>
1875
- </Callout>
1876
- ) : null}
1877
- </View>
1878
- ) : (
1879
- <View style={{ gap: 10 }}>
1880
- <Combobox
1881
- options={customerOptions}
1882
- onValueChange={onPickCustomer}
1883
- renderOptionContent={(o) => (
1884
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
1885
- <View style={{ width: 28, height: 28, borderRadius: 7, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
1886
- <Icon name="building-2" size={14} color={colors.zinc[600]} />
1887
- </View>
1888
- <View style={{ flex: 1, minWidth: 0 }}>
1889
- <Text size="sm" weight="medium" numberOfLines={1}>{o.label}</Text>
1890
- {o.data ? <Text size="xs" color="muted">{[o.data.city, o.data.taxId ? `Tax ID ${o.data.taxId}` : "No tax ID"].filter(Boolean).join(" · ")}</Text> : null}
1891
- </View>
1892
- </View>
1893
- )}
1894
- reflectSelection={false}
1895
- allowCustom
1896
- customOptionPlacement="top"
1897
- customOptionLabel={(q) => `Create new customer “${q}”`}
1898
- >
1899
- <ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
1900
- <ComboboxContent emptyText="No customer matches — type a name to create one" />
1901
- </Combobox>
1902
- <Text size="xs" color="muted">
1903
- No match? Pick “Create new customer …” to add one without leaving the order.
1904
- </Text>
1905
- </View>
1906
- )}
2115
+ {/* the Add, under the register it extends — see the Fees section for
2116
+ why it is not in the heading. PENDING until the user chooses:
2117
+ saving is a decision the dialog asks for (save only / run a task),
2118
+ never a side effect of picking closing the dialog discards. */}
2119
+ <View style={{ flexDirection: "row" }}>
2120
+ <Button
2121
+ title="Add files"
2122
+ color="primary"
2123
+ onPress={() => {
2124
+ void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
2125
+ }}
2126
+ />
2127
+ </View>
1907
2128
  </Section>
1908
2129
  </View>
1909
2130
 
1910
2131
  {/* FEES — the DETAILED money ledger, both directions (charge = billed
1911
- to the customer · cost = paid to a vendor), distinct from Billing's
2132
+ to the customer, cost = paid to a vendor), distinct from Billing's
1912
2133
  invoice DOCUMENTS. The drill-down law: a compact register row, and
1913
2134
  EVERY row opens its FULL detail in a right-docked entity drawer
1914
2135
  (◀ ▶ steps the rows). Add is create-then-refine — a blank fee
@@ -1917,7 +2138,6 @@ export function TplRecord() {
1917
2138
  <Section>
1918
2139
  <SectionHeading>
1919
2140
  <SectionHeadingTitle description="Every fee the order incurs or charges — its party, due date and paid state.">Fees</SectionHeadingTitle>
1920
- <Button title="Add fee" color="secondary" onPress={addFee} />
1921
2141
  </SectionHeading>
1922
2142
  <SummaryLine
1923
2143
  items={[
@@ -1944,6 +2164,21 @@ export function TplRecord() {
1944
2164
  })}
1945
2165
  </Table>
1946
2166
  )}
2167
+ {/* THE ADD SITS WHERE ITS RESULT APPEARS — under the last row, on the
2168
+ list's own left edge. In the heading it sat ABOVE the thing it
2169
+ extends and at a different altitude from it, and it competed with
2170
+ the title for the one line that names the section. Secondary and
2171
+ left, and PRIMARY — in the empty state too, which is what makes it
2172
+ consistent. A section's add is the act that section offers, so it
2173
+ carries the section's weight; a lone `secondary` button reads as
2174
+ though the real action were somewhere else. One verb keeps ONE
2175
+ weight either way: the fault to avoid is an add that is primary on
2176
+ an empty list and secondary on a full one. */}
2177
+ {fees.length > 0 ? (
2178
+ <View style={{ flexDirection: "row" }}>
2179
+ <Button title="Add fee" color="primary" onPress={addFee} />
2180
+ </View>
2181
+ ) : null}
1947
2182
  </Section>
1948
2183
  </View>
1949
2184
 
@@ -1972,68 +2207,118 @@ export function TplRecord() {
1972
2207
  </Callout>
1973
2208
  ) : null}
1974
2209
 
1975
- {/* the section's GROUPS bands · collect · deposit — divide via the
1976
- SubsectionStack beat, no hand-rolled gaps */}
2210
+ {/* EVERYTHING VISIBLE, and the chrome is what shrinks.
2211
+ Three attempts landed here. Inline BANDS were "spread" — but the
2212
+ spread was never the data: four charge lines across three invoices
2213
+ is nothing. Each band carried a `SubsectionHeading`, its own
2214
+ callout and its own action row, so the CHROME was three times the
2215
+ content. A register then hid those four lines behind three
2216
+ expansions, which trades a real fault for a worse one: you can no
2217
+ longer see what you are paying for.
2218
+ So the lines all stay, and each invoice costs ONE line of chrome —
2219
+ its name, its total, its act. No heading rung, no band callout, no
2220
+ separate action row. A problem rides the CHARGE ROW that has it
2221
+ (`warning` on the line missing its method), which is both smaller
2222
+ than a callout and more precise than one: it names the offender
2223
+ instead of counting offenders. */}
1977
2224
  <SubsectionStack>
1978
- {invoices.map((inv) => (
1979
- <InvoiceBand
1980
- key={inv.key}
1981
- inv={inv}
1982
- /* issuing gates on the RECORD's premises only (a customer with a
1983
- valid tax ID) — never silently on the stage: a filled band
1984
- whose Issue stays dead with no visible reason is a trap */
1985
- gated={customer === null || !taxIdValid}
1986
- onAmount={(chKey, v) => saveCharge(inv.key, chKey, { amount: v })}
1987
- onMethod={(chKey, m) => saveCharge(inv.key, chKey, { method: m })}
1988
- onIssue={setConfirmIssueKey}
1989
- />
1990
- ))}
2225
+ {invoices.map((inv) => {
2226
+ const missing = missingMethods(inv);
2227
+ const total = invoiceTotal(inv);
2228
+ const ready = total > 0 && missing.length === 0 && customer !== null && taxIdValid;
2229
+ return (
2230
+ <Subsection key={inv.key}>
2231
+ {/* the invoice's ONE line of chrome: what it is, what it comes
2232
+ to, and its reference once issued. No indent under it — the
2233
+ lead line's weight and its total already mark the group, and
2234
+ the `SubsectionStack` beat separates one from the next, so an
2235
+ indent would only push every charge off the page's left edge
2236
+ for nothing. */}
2237
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
2238
+ <Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>{inv.title}</Text>
2239
+ <Text size="sm" tabular color={total > 0 ? "default" : "muted"}>{total > 0 ? money(total) : "—"}</Text>
2240
+ {inv.ref ? (
2241
+ <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
2242
+ ) : null}
2243
+ </View>
2244
+ <DetailTable labelWidth={150} minValueWidth={320}>
2245
+ {inv.charges.map((c) => (
2246
+ <DetailRow
2247
+ key={c.key}
2248
+ label={c.label}
2249
+ warning={c.amount > 0 && !c.method ? "Choose how this was paid" : undefined}
2250
+ >
2251
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 }}>
2252
+ <View style={{ flexGrow: 1, flexBasis: 150 }}>
2253
+ <InlineNumberInput
2254
+ value={c.amount || null}
2255
+ onSave={(v) => saveCharge(inv.key, c.key, { amount: v ?? 0 })}
2256
+ min={0}
2257
+ format={money}
2258
+ placeholder="—"
2259
+ accessibilityLabel={`Amount for ${c.label}`}
2260
+ /* the list price, one tap, ON the field it fills */
2261
+ actions={
2262
+ c.amount <= 0 && c.standard > 0
2263
+ ? <InlineButton title={money(c.standard)} accessibilityLabel={`Use the standard ${c.label} price`} onPress={() => saveCharge(inv.key, c.key, { amount: c.standard })} />
2264
+ : undefined
2265
+ }
2266
+ />
2267
+ </View>
2268
+ <View style={{ flexGrow: 1, flexBasis: 140, maxWidth: 160 }}>
2269
+ <InlineSelect
2270
+ value={c.method === "" ? null : c.method}
2271
+ onSave={(m) => saveCharge(inv.key, c.key, { method: m })}
2272
+ options={METHODS}
2273
+ placeholder="How paid…"
2274
+ disabled={c.amount <= 0}
2275
+ accessibilityLabel={`Payment method for ${c.label}`}
2276
+ />
2277
+ </View>
2278
+ </View>
2279
+ </DetailRow>
2280
+ ))}
2281
+ {/* AFTER the lines it bills — a committing act goes where its
2282
+ effect lands, which is the end of what it commits, never
2283
+ above it. On the control column like every other CTA on
2284
+ the page (the form-action alignment law). */}
2285
+ <DetailRow label="">
2286
+ <View style={{ flexDirection: "row" }}>
2287
+ <Button
2288
+ title={inv.ref ? "Re-issue" : "Issue invoice"}
2289
+ color={inv.ref ? "secondary" : "primary"}
2290
+ disabled={!ready}
2291
+ onPress={() => setConfirmIssueKey(inv.key)}
2292
+ />
2293
+ </View>
2294
+ </DetailRow>
2295
+ </DetailTable>
2296
+ </Subsection>
2297
+ );
2298
+ })}
2299
+ </SubsectionStack>
1991
2300
 
1992
- {/* collect band a headingless Subsection: the total owns the number */}
1993
- <Subsection>
1994
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1995
- <Text weight="semibold" style={{ flex: 1 }}>Total to collect</Text>
1996
- <Text weight="semibold" size="lg" tabular>{formatMoney(grandTotal)}</Text>
1997
- </View>
1998
- {allMissing.length > 0 ? (
1999
- <Callout tone="warning">
2000
- <CalloutText>
2001
- {allMissing.length} charged {allMissing.length === 1 ? "line has" : "lines have"} no payment method — set them before printing the receipt.
2002
- </CalloutText>
2003
- </Callout>
2004
- ) : null}
2005
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
2006
- <Text size="xs" color="muted" style={{ flex: 1 }}>{issuedCount} of {invoices.length} issued</Text>
2007
- <Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
2008
- </View>
2009
- </Subsection>
2010
-
2011
- {/* deposit band — a named Subsection, never part of the total */}
2012
- <Subsection>
2013
- <SubsectionHeading>
2014
- <SubsectionHeadingTitle>Deposit</SubsectionHeadingTitle>
2015
- </SubsectionHeading>
2016
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap", minHeight: 40 }}>
2017
- <Text size="sm" color="muted" style={{ width: 150 }}>Refundable</Text>
2018
- <View style={{ flexGrow: 1, flexBasis: 160 }}>
2019
- <InlineNumberInput
2020
- value={deposit || null}
2021
- onSave={persist((v: number | null) => setDeposit(v ?? 0))}
2022
- min={0}
2023
- format={money}
2024
- placeholder="—"
2025
- accessibilityLabel="Deposit amount"
2026
- />
2027
- </View>
2028
- <Button
2029
- title="Deposit receipt"
2030
- color="secondary"
2031
- disabled={deposit <= 0}
2032
- onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])}
2301
+ {/* ONE action row for the section, on the control column. The deposit
2302
+ is a field of the record, not a band of its own — its receipt is a
2303
+ second act on the same row. */}
2304
+ <DetailTable labelWidth={150} minValueWidth={320}>
2305
+ <DetailRow label="Deposit" description="Refundable — never part of the total to collect">
2306
+ <InlineNumberInput
2307
+ value={deposit || null}
2308
+ onSave={persist((v: number | null) => setDeposit(v ?? 0))}
2309
+ min={0}
2310
+ format={money}
2311
+ placeholder="—"
2312
+ accessibilityLabel="Deposit amount"
2313
+ actions={<InlineButton title="Receipt" accessibilityLabel="Deposit receipt" disabled={deposit <= 0} onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])} />}
2033
2314
  />
2034
- </View>
2035
- </Subsection>
2036
- </SubsectionStack>
2315
+ </DetailRow>
2316
+ <DetailRow label="">
2317
+ <View style={{ flexDirection: "row" }}>
2318
+ <Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
2319
+ </View>
2320
+ </DetailRow>
2321
+ </DetailTable>
2037
2322
  </Section>
2038
2323
  </View>
2039
2324
 
@@ -2144,7 +2429,7 @@ export function TplRecord() {
2144
2429
  })}
2145
2430
  {/* the output config — values printed on every generated form */}
2146
2431
  <Subsection>
2147
- <DetailTable labelWidth={150} trailingWidth={88}>
2432
+ <DetailTable labelWidth={150}>
2148
2433
  <DetailRow label="Issuing office" description="Printed in every form's header">
2149
2434
  <InlineTextInput value={issuingOffice} onSave={persist(setIssuingOffice)} placeholder="Add the office…" accessibilityLabel="Issuing office" />
2150
2435
  </DetailRow>
@@ -2183,7 +2468,7 @@ export function TplRecord() {
2183
2468
  <FileRow
2184
2469
  key={d.id}
2185
2470
  name={d.name}
2186
- meta={`${d.kind} · ${fmtSize(d.sizeKB)}`}
2471
+ meta={`${d.kind}, ${fmtSize(d.sizeKB)}`}
2187
2472
  mimeType={d.mimeType}
2188
2473
  onPress={() => openPreview(docSet, di)}
2189
2474
  trailing={
@@ -2225,7 +2510,7 @@ export function TplRecord() {
2225
2510
  <SectionHeading>
2226
2511
  <SectionHeadingTitle description="Issued on handover — record the delivery facts, generate the receipt.">Delivery receipt</SectionHeadingTitle>
2227
2512
  </SectionHeading>
2228
- <DetailTable labelWidth={150} trailingWidth={88}>
2513
+ <DetailTable labelWidth={150}>
2229
2514
  <DetailRow label="Received">
2230
2515
  <InlineDatePicker value={rcptDate} onSave={persist(setRcptDate)} locale="en-US" accessibilityLabel="Received date" />
2231
2516
  </DetailRow>
@@ -2259,7 +2544,7 @@ export function TplRecord() {
2259
2544
  {rcptDoc ? (
2260
2545
  <FileRow
2261
2546
  name={rcptDoc.name}
2262
- meta={`${rcptDoc.kind} · ${fmtSize(rcptDoc.sizeKB)}`}
2547
+ meta={`${rcptDoc.kind}, ${fmtSize(rcptDoc.sizeKB)}`}
2263
2548
  mimeType={rcptDoc.mimeType}
2264
2549
  onPress={() => openPreview([rcptDoc], 0)}
2265
2550
  trailing={
@@ -2279,70 +2564,6 @@ export function TplRecord() {
2279
2564
  </Section>
2280
2565
  </View>
2281
2566
 
2282
- {/* ACTIVITY — the audit trail: every change on the record, who and
2283
- when. Seeded history + LIVE appends from this surface's real
2284
- actions (issue, fees, handoff). */}
2285
- <View onLayout={nav.register("activity")}>
2286
- <Section>
2287
- <SectionHeading>
2288
- <SectionHeadingTitle description="Every change on the record — who made it, and when.">Activity</SectionHeadingTitle>
2289
- </SectionHeading>
2290
- <Timeline items={activity} />
2291
- </Section>
2292
- </View>
2293
-
2294
- {/* HANDOFF — the reference mark of the boundary: handing off CREATES
2295
- the next desk's record on ITS table and links it here. The trail
2296
- marks whom · where · when + the linked sibling; the sibling owns
2297
- its own summary. */}
2298
- <View onLayout={nav.register("handoff")}>
2299
- <Section>
2300
- <SectionHeading>
2301
- <SectionHeadingTitle description="Handing off creates the next desk's record and links it here for reference.">Handoff</SectionHeadingTitle>
2302
- </SectionHeading>
2303
- {/* the records the handoffs CREATED — the SAME linked-record
2304
- composition as the Customer section (a bordered view + visible
2305
- actions; copy and customize) */}
2306
- {siblings.length > 0 ? (
2307
- <View style={{ gap: 16 }}>
2308
- {siblings.map((sb) => (
2309
- <LinkedRecordBox
2310
- key={sb.id}
2311
- icon="file-text"
2312
- name={sb.code}
2313
- subtitle={`${sb.desk} record`}
2314
- facts={[
2315
- { label: "Assignee", value: sb.assignee || "—" },
2316
- { label: "Created", value: sb.at },
2317
- { label: "Note", value: sb.note || "—" },
2318
- ]}
2319
- doorLabel={`${sb.code} — details`}
2320
- onOpen={() => setSiblingOpen(sb.id)}
2321
- actions={
2322
- <>
2323
- <View style={{ flex: 1 }} />
2324
- <Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the sibling's page */ }} />
2325
- </>
2326
- }
2327
- />
2328
- ))}
2329
- </View>
2330
- ) : null}
2331
- {handoffs.length > 0 ? <Timeline items={handoffs} /> : null}
2332
- {/* Nothing handed off YET is a real state, not an absent section — and
2333
- its empty state carries NO action: the act lives on the live stage
2334
- in Progress, so a CTA here would be the second copy this section
2335
- exists to avoid. */}
2336
- {siblings.length === 0 && handoffs.length === 0 ? (
2337
- <EmptyState icon="send" message="Not handed off yet" hint={`${stageOf(stage).label} still holds this record — hand it off from Progress above.`} />
2338
- ) : null}
2339
- {/* No CTA here. The act that leaves a desk belongs ON that desk — it
2340
- rides the live stage in the Progress pipeline. This section is the
2341
- RESULT: what the handoff created and when. A second button here
2342
- would offer the same act twice, in the place with less context. */}
2343
- </Section>
2344
- </View>
2345
-
2346
2567
  {/* the rail lists EVERY section — the danger zone included, with the
2347
2568
  SAME section anatomy (heading + description) as everything else;
2348
2569
  the fenced red card is the section's BODY, not its heading */}
@@ -2396,7 +2617,7 @@ export function TplRecord() {
2396
2617
  {/* the same inline-chip table vocabulary as the record's field grids —
2397
2618
  only the commit differs (Create & attach) */}
2398
2619
  <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 8 }}>
2399
- <DetailTable labelWidth={150} trailingWidth={88}>
2620
+ <DetailTable labelWidth={150}>
2400
2621
  <DetailRow label="Customer name">
2401
2622
  <InlineTextInput
2402
2623
  value={custDraft?.name ?? ""}
@@ -2410,19 +2631,15 @@ export function TplRecord() {
2410
2631
  </DetailRow>
2411
2632
  {/* the field-level failure rides the ROW (`DetailRow error` —
2412
2633
  FormField's alert semantics), not a loose line below the table */}
2413
- <DetailRow
2414
- label="Tax ID"
2415
- error={taxIdError}
2416
- trailing={
2417
- <Button
2418
- title="Fetch"
2419
- color="secondary"
2420
- loading={fetching}
2421
- disabled={!TAX_ID_RE.test((custDraft?.taxId ?? "").trim())}
2422
- onPress={fetchIntoDraft}
2423
- />
2424
- }
2425
- >
2634
+ <DetailRow label="Tax ID" error={taxIdError}>
2635
+ {/* Fetch is NOT a verb about this value — it reads the tax ID and
2636
+ fills the contact and city BELOW it, so it belongs to the form,
2637
+ not to the field (which is why it isn't an `InlineButton` in
2638
+ `actions`). It composes into a row inside this value cell, so
2639
+ it costs the one row that wants it instead of a column every
2640
+ row would reserve. */}
2641
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
2642
+ <View style={{ flex: 1, minWidth: 0 }}>
2426
2643
  <InlineTextInput
2427
2644
  value={custDraft?.taxId ?? ""}
2428
2645
  onSave={(v) => {
@@ -2432,6 +2649,15 @@ export function TplRecord() {
2432
2649
  placeholder="Add tax ID…"
2433
2650
  accessibilityLabel="Tax ID"
2434
2651
  />
2652
+ </View>
2653
+ <Button
2654
+ title="Fetch"
2655
+ color="secondary"
2656
+ loading={fetching}
2657
+ disabled={!TAX_ID_RE.test((custDraft?.taxId ?? "").trim())}
2658
+ onPress={fetchIntoDraft}
2659
+ />
2660
+ </View>
2435
2661
  </DetailRow>
2436
2662
  <DetailRow label="Contact">
2437
2663
  <InlineTextInput
@@ -2503,7 +2729,7 @@ export function TplRecord() {
2503
2729
  <DetailRow label="Fee">
2504
2730
  <InlineTextInput value={openFee.name} onSave={persist((v: string) => patchFee(openFee.id, { name: v }))} placeholder="Name the fee…" accessibilityLabel="Fee name" />
2505
2731
  </DetailRow>
2506
- <DetailRow label="Type" description="Charge — billed to the customer · Cost — paid to a vendor">
2732
+ <DetailRow label="Type" description="Charge — billed to the customer; Cost — paid to a vendor">
2507
2733
  <InlineSelect
2508
2734
  value={openFee.direction}
2509
2735
  options={[{ value: "charge", label: "Charge" }, { value: "cost", label: "Cost" }]}
@@ -2573,35 +2799,6 @@ export function TplRecord() {
2573
2799
  </DrawerFooter>
2574
2800
  </Drawer>
2575
2801
 
2576
- {/* the SIBLING record's drawer — the handoff-created record's reference
2577
- info + the navigate verb (a real app routes to its page). */}
2578
- <Drawer open={openSibling !== null} onOpenChange={(o) => { if (!o) setSiblingOpen(null); }} title={openSibling?.code ?? ""} width={440}>
2579
- {openSibling ? (
2580
- <View style={{ padding: 20 }}>
2581
- <DetailTable labelWidth={110}>
2582
- <DetailRow label="Desk"><InlineStatic value={openSibling.desk} /></DetailRow>
2583
- <DetailRow label="Assignee"><InlineStatic value={openSibling.assignee || "—"} /></DetailRow>
2584
- <DetailRow label="Created"><InlineStatic value={openSibling.at} tabular /></DetailRow>
2585
- <DetailRow label="From"><InlineStatic value={code} tabular /></DetailRow>
2586
- <DetailRow label="Handoff note"><InlineStatic value={openSibling.note || "—"} /></DetailRow>
2587
- </DetailTable>
2588
- </View>
2589
- ) : (
2590
- <View />
2591
- )}
2592
- <DrawerFooter>
2593
- {/* Recall = the semi-destructive undo — behind the details, confirmed.
2594
- Offered ONLY on the handoff that put the record where it is. A
2595
- superseded one cannot be undone in place: withdrawing the middle
2596
- step would leave the record behind a sibling that still exists, so
2597
- `stage` and the linked records would disagree. Walk it back one
2598
- handoff at a time. */}
2599
- {openSibling && openSibling.deskKey === stage ? <Button title="Recall handoff" color="danger-secondary" onPress={() => recallHandoff(openSibling)} /> : null}
2600
- {/* a real app navigates to the sibling record's page */}
2601
- <Button title="Open record" color="secondary" onPress={() => {}} />
2602
- </DrawerFooter>
2603
- </Drawer>
2604
-
2605
2802
  {/* the handoff dialog — who receives the record + a note; the rows name
2606
2803
  exactly WHAT is being handed off. Confirm writes the trail entry. */}
2607
2804
  <Dialog width={480} open={handoffOpen} onOpenChange={(o) => { if (!o) { setHandoffOpen(false); setHandoffAssignee(null); setHandoffNote(""); } }}>
@@ -2621,13 +2818,58 @@ export function TplRecord() {
2621
2818
  <Button title={`Hand off to ${stageOf(handoffTo).label}`} color="primary" disabled={handoffAssignee == null} onPress={confirmHandoff} />
2622
2819
  </DialogFooter>
2623
2820
  </Dialog>
2624
- {/* Balances the rail so the reading column lands dead-centre. In the docs
2625
- layout the right-hand TOC does this job; a record surface has no TOC,
2626
- so the gutter is simply reserved. */}
2627
- {wide ? <View style={{ width: GUTTER, flexShrink: 0 }} /> : null}
2821
+ {/* Reserves the discussion panel's column the panel itself floats over
2822
+ it, like the rail on the left, so the scroller keeps the full page. */}
2823
+ {wide ? <View style={{ width: PANEL_GUTTER, flexShrink: 0 }} /> : null}
2628
2824
  </View>
2629
2825
  </ScrollView>
2630
2826
 
2827
+ {/* Narrow: the same thread, in the panel shape a small screen has — a
2828
+ right-docked Drawer off the bar's own comments control. */}
2829
+ <Drawer open={commentsOpen} onOpenChange={setCommentsOpen} title="Comments" width={380}>
2830
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 20, gap: 12 }}>
2831
+ <CommentList
2832
+ comments={comments}
2833
+ currentMemberId="mem_01"
2834
+ resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
2835
+ onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
2836
+ onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
2837
+ /* the file-capable edit form — the same injection the product uses */
2838
+ renderEditForm={(p) => <CommentFileEditForm {...p} />}
2839
+ /* attachments render as the SAME thumbnail grid the product uses —
2840
+ press previews in the gallery (the desk's preview state) */
2841
+ renderFiles={(files) => (
2842
+ <FileGrid
2843
+ files={files.map(commentFileToDisplay)}
2844
+ itemSize={84}
2845
+ onFilePress={(f) => setPreview({ files: [f], index: 0 })}
2846
+ />
2847
+ )}
2848
+ /* compact timestamp — the product formats RELATIVE ("2 days ago",
2849
+ date-fns); a template stays dependency-free and deterministic */
2850
+ formatTimestamp={(iso) => { const d = new Date(iso); return `${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`; }}
2851
+ />
2852
+
2853
+ </ScrollView>
2854
+ <View style={{ padding: 20, paddingTop: 0 }}>
2855
+ {/* THE kit Composer — the one composing surface everywhere (chat,
2856
+ agent prompts, comments): attach via actionsButton, the staged
2857
+ files (removable until Send) ride the files slot. */}
2858
+ <Composer
2859
+ onSend={(content) => {
2860
+ setComments((prev) => [...prev, { id: `cm_${(commentSeq += 1)}`, member_id: "mem_01", content, files: pendingFiles.length > 0 ? pendingFiles : undefined, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }]);
2861
+ setPendingFiles([]);
2862
+ }}
2863
+ placeholder="Write a comment…"
2864
+ sendLabel="Send"
2865
+ actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
2866
+ files={pendingFiles.length > 0 ? (
2867
+ <FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
2868
+ ) : undefined}
2869
+ />
2870
+ </View>
2871
+ </Drawer>
2872
+
2631
2873
  {/* ── the document desk's overlays */}
2632
2874
  <FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
2633
2875
  <Button title="Remove" color="danger-secondary" icon="trash" onPress={removeSelected} />
@@ -2641,7 +2883,7 @@ export function TplRecord() {
2641
2883
  <ChangeReview>
2642
2884
  <Dialog open={aiOpen} onOpenChange={(o) => { if (!o) closeAi(); }} maxWidth={620}>
2643
2885
  <DialogHeader>
2644
- <DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI · ${picked.length} ${picked.length === 1 ? "file" : "files"}`}</DialogHeaderTitle>
2886
+ <DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI (${picked.length} ${picked.length === 1 ? "file" : "files"})`}</DialogHeaderTitle>
2645
2887
  </DialogHeader>
2646
2888
  {phase === "running" ? (
2647
2889
  // The streaming feed gets its OWN scroller: FollowScroll (the chat list's
@@ -2704,7 +2946,7 @@ export function TplRecord() {
2704
2946
  <View style={{ gap: 8 }}>
2705
2947
  <ChangeReviewHeader />
2706
2948
  {/* ONE section for the RECORD — ChangeFields stacks a ChangeField
2707
- per proposed value: label · the − band when replacing · the
2949
+ per proposed value: label, the − band when replacing, the
2708
2950
  editable + value. Editing IS the review; each field decides
2709
2951
  for ITSELF (Keep/Drop), and Apply commits the kept rows. */}
2710
2952
  <Change id="order">
@@ -2797,8 +3039,8 @@ export function TplRecord() {
2797
3039
  <View style={{ gap: 14 }}>
2798
3040
  <ChangeReviewHeader title="Findings" />
2799
3041
  {/* Display-only: findings inform the verdict the footer records.
2800
- The kit `Finding` owns severity word · title · detail · the
2801
- PROMINENT metric · Sources; hairlines separate them. */}
3042
+ The kit `Finding` owns severity word, title, detail, the
3043
+ PROMINENT metric, Sources; hairlines separate them. */}
2802
3044
  {FINDINGS.map((c, i) => (
2803
3045
  <Fragment key={c.id}>
2804
3046
  {i > 0 ? <Divider /> : null}
@@ -2892,6 +3134,72 @@ export function TplRecord() {
2892
3134
  />
2893
3135
  ) : null}
2894
3136
 
3137
+ {/* THE DISCUSSION PANEL — pinned in the right gutter, mirroring the rail.
3138
+ It is not a Section and has no place in the rail, because it is not a
3139
+ PART of the record: it is about all of it. A thread parked at one
3140
+ position in a top-to-bottom read is unreachable from every other, which
3141
+ is exactly when a coworker's note matters — so it stays on screen while
3142
+ you work anywhere on the page.
3143
+
3144
+ It floats over its reserved column rather than sitting in the scroll row,
3145
+ for the same reason the rail does: the ScrollView keeps the full page
3146
+ width, so the wheel scrolls the record from anywhere. Its own overflow is
3147
+ the panel's, so a long thread scrolls inside itself and never lengthens
3148
+ the record. */}
3149
+ {pageWidth == null || !wide ? null : (
3150
+ <View style={{ position: "absolute", top: 28, right: panelRight, bottom: 28, width: PANEL_W, gap: 10 }}>
3151
+ {/* The panel is a PEER of the sections beside it, not a widget, so its
3152
+ title takes the ramp's section rung rather than a hand-picked size —
3153
+ and it takes the WHOLE construct. `SectionHeadingTitle` carries
3154
+ `flex: 1` so it can push meta to a heading row's right edge; dropped
3155
+ straight into this column it grows VERTICALLY instead and starves the
3156
+ thread beneath it. The row wrapper is what makes that flex mean what
3157
+ it was written to mean. */}
3158
+ <SectionHeading>
3159
+ <SectionHeadingTitle>Comments</SectionHeadingTitle>
3160
+ </SectionHeading>
3161
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12, paddingBottom: 8 }}>
3162
+ <CommentList
3163
+ comments={comments}
3164
+ currentMemberId="mem_01"
3165
+ resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
3166
+ onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
3167
+ onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
3168
+ /* the file-capable edit form — the same injection the product uses */
3169
+ renderEditForm={(p) => <CommentFileEditForm {...p} />}
3170
+ /* attachments render as the SAME thumbnail grid the product uses —
3171
+ press previews in the gallery (the desk's preview state) */
3172
+ renderFiles={(files) => (
3173
+ <FileGrid
3174
+ files={files.map(commentFileToDisplay)}
3175
+ itemSize={84}
3176
+ onFilePress={(f) => setPreview({ files: [f], index: 0 })}
3177
+ />
3178
+ )}
3179
+ /* compact timestamp — the product formats RELATIVE ("2 days ago",
3180
+ date-fns); a template stays dependency-free and deterministic */
3181
+ formatTimestamp={(iso) => { const d = new Date(iso); return `${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`; }}
3182
+ />
3183
+
3184
+ </ScrollView>
3185
+ {/* THE kit Composer — the one composing surface everywhere (chat,
3186
+ agent prompts, comments): attach via actionsButton, the staged
3187
+ files (removable until Send) ride the files slot. */}
3188
+ <Composer
3189
+ onSend={(content) => {
3190
+ setComments((prev) => [...prev, { id: `cm_${(commentSeq += 1)}`, member_id: "mem_01", content, files: pendingFiles.length > 0 ? pendingFiles : undefined, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }]);
3191
+ setPendingFiles([]);
3192
+ }}
3193
+ placeholder="Write a comment…"
3194
+ sendLabel="Send"
3195
+ actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
3196
+ files={pendingFiles.length > 0 ? (
3197
+ <FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
3198
+ ) : undefined}
3199
+ />
3200
+ </View>
3201
+ )}
3202
+
2895
3203
  {/* The rail floats over its reserved gutter — pinned while the record
2896
3204
  scrolls, and out of the scroll row so the page stays wheel-scrollable
2897
3205
  edge to edge. */}