@lotics/ui 27.17.1 → 28.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.
@@ -1,5 +1,5 @@
1
1
  import { Fragment, useEffect, useRef, useState } from "react";
2
- import { Pressable, ScrollView, View } from "react-native";
2
+ import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors } from "@lotics/ui/colors";
5
5
  import { Button } from "@lotics/ui/button";
@@ -12,14 +12,10 @@ import type { PickerOption } from "@lotics/ui/picker";
12
12
  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
- import { Inset } from "@lotics/ui/inset";
16
15
  import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
17
16
  import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
18
- import { Pipeline, PipelineActions, PipelineField, PipelineNote, PipelineStage } from "@lotics/ui/pipeline";
17
+ import { Checklist, ChecklistActions, ChecklistGroup, ChecklistItem, ChecklistNote } from "@lotics/ui/checklist";
19
18
  import { DateStamp } from "@lotics/ui/date_stamp";
20
- import { TextLink } from "@lotics/ui/text_link";
21
- import { MemberChip } from "@lotics/ui/member_chip";
22
- import { TaskCaption, TaskDetail, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
23
19
  import { MenuButton } from "@lotics/ui/menu_button";
24
20
  import { Popover, PopoverContent } from "@lotics/ui/popover";
25
21
  import { useSectionNav } from "@lotics/ui/use_section_nav";
@@ -45,7 +41,6 @@ import { InlineSelect } from "@lotics/ui/inline_select";
45
41
  import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
46
42
  import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
47
43
  import { MemberSelect, type MemberSelectMember } from "@lotics/ui/member_select";
48
- import { Drawer } from "@lotics/ui/drawer";
49
44
  import { EmptyState } from "@lotics/ui/empty_state";
50
45
  import { FormField } from "@lotics/ui/form_field";
51
46
  import { TextInputField } from "@lotics/ui/text_input_field";
@@ -169,19 +164,32 @@ const DESKS: { key: Desk; label: string }[] = [
169
164
  * a reader wants to see, and one that gives every step an owner field asks four
170
165
  * questions nobody has answers to.
171
166
  *
172
- * `desk` marks the steps that carry facts and the handoff act. The rest are a
173
- * tick and a date.
167
+ * So the ladder has TWO LEVELS, not one flat run of eight. Flat, a desk and a
168
+ * milestone sat at the same altitude while being different kinds of thing — the
169
+ * ladder claimed "Operations" and "Collected" were peers, and the reader had to
170
+ * know which names a PLACE and which names an EVENT. Grouping the milestones
171
+ * under the desk that produces them says it in the shape instead: the desk is
172
+ * where the record sits, the milestones are what happens while it is there.
173
+ *
174
+ * `RUNGS` is the flat ordered spine — the ONLY things that carry a stamp, so
175
+ * "how far along" has exactly one source. A PHASE has no stamp of its own: its
176
+ * ring SUMMARISES its rungs (full when all are ticked, half while some are), and
177
+ * its own facts are the owner and the handoff act.
174
178
  */
175
- const STEPS: { key: string; label: string; desk?: Desk }[] = [
176
- { key: "sales", label: "Sales", desk: "sales" },
179
+ const RUNGS: { key: string; label: string }[] = [
177
180
  { key: "quoted", label: "Quote sent" },
178
- { key: "operations", label: "Operations", desk: "operations" },
179
181
  { key: "collected", label: "Collected" },
180
182
  { key: "cleared", label: "Customs cleared" },
181
183
  { key: "delivered", label: "Delivered" },
182
- { key: "accounting", label: "Accounting", desk: "accounting" },
183
184
  { key: "pod", label: "POD received" },
184
185
  ];
186
+ const rungAt = (key: string) => RUNGS.findIndex((r) => r.key === key);
187
+
188
+ const PHASES: { desk: Desk; label: string; rungs: string[] }[] = [
189
+ { desk: "sales", label: "Sales", rungs: ["quoted"] },
190
+ { desk: "operations", label: "Operations", rungs: ["collected", "cleared", "delivered"] },
191
+ { desk: "accounting", label: "Accounting", rungs: ["pod"] },
192
+ ];
185
193
 
186
194
  const STAGES: { key: Stage; label: string }[] = [
187
195
  ...DESKS,
@@ -365,15 +373,27 @@ const RAIL_GAP = 48;
365
373
  /** The reserved gutter: the rail plus its gap. Mirrored empty on the right, this
366
374
  * is what centres the column. */
367
375
  const GUTTER = RAIL_W + RAIL_GAP;
368
- /** The discussion panel's column. A record's comments are not a SECTION — they
369
- * are about the whole record, so they do not belong at one position in a
370
- * top-to-bottom reading order, and a coworker's note has to stay readable while
371
- * you work anywhere on the page. It takes the right gutter, which until now was
372
- * reserved purely to balance the rail. */
373
- const PANEL_W = 360;
376
+ /** The discussion panel's column. A record's comments are not a SECTION when
377
+ * there is room beside it — they are about the whole record, so they do not
378
+ * belong at one position in a top-to-bottom reading order, and a coworker's note
379
+ * has to stay readable while you work anywhere on the page. It takes the right
380
+ * gutter, which was otherwise reserved purely to balance the rail.
381
+ *
382
+ * NARROWER than the rail's side and much narrower than the record: the record is
383
+ * the SUBJECT and the discussion supports it, so when the two compete the panel
384
+ * is what gives way. A column flanked by two of its own width reads as one of
385
+ * three equals rather than as the thing the page is about. 300 still seats a
386
+ * comment's line comfortably at `sm`. */
387
+ const PANEL_W = 300;
374
388
  const PANEL_GUTTER = PANEL_W + RAIL_GAP;
375
- /** The reading column's ceiling. */
376
- const CONTENT_MAX = 720;
389
+ /** The reading column's ceiling.
390
+ *
391
+ * Narrower than the 720 it started at — which is what buys the discussion its
392
+ * column on the widths a laptop actually has, instead of folding the thread away
393
+ * — but still comfortably wider than either gutter. Taken all the way down to a
394
+ * `Drawer`'s 600 it sat between two columns of its own order and stopped reading
395
+ * as the thing the page is about. */
396
+ const CONTENT_MAX = 680;
377
397
 
378
398
  // ── the DOCUMENT DESK — the Agents "Document desk" pattern, carried as the
379
399
  // record's documents surface: files feed ONE "Use AI" entry that forks into
@@ -568,14 +588,14 @@ const DOC_COLUMNS: TableColumn[] = [
568
588
  // need, grouped PER PARTY; the common ones ship pre-checked, the long tail
569
589
  // folds behind "Show all forms". (The Documents section above is the INTAKE
570
590
  // register — received files; this registry is what the record can PRODUCE.)
571
- // READINESS: a form declares which RECORD FIELDS it reads (`needs`). The
572
- // fields' HOME stays their DATA section (the colocation law). Warning and fix
573
- // are CO-LOCATED on the row: a not-ready row's mark NAMES what's missing (muted
574
- // at rest so gaps show without checking, warning once checked), and the fix sits
575
- // right under it in the `expansion` an `Inset` (a FORM surface, never a Callout,
576
- // which is an ARIA alert) with the editors, saved onto the record in place. Drafts
577
- // are keyed BY FIELD, so a field two checked forms share is entered once and one
578
- // Save resolves both. A ready form stays silent.
591
+ // READINESS: a form declares which RECORD FIELDS it reads (`needs`). The row
592
+ // NAMES what is missing and says WHERE it lives (muted at rest so gaps show
593
+ // without checking, warning once checked it now blocks); a ready form stays
594
+ // silent. It does NOT collect: the fields' home is their own DATA section, and a
595
+ // checklist that also edited them made every row two things at once a status
596
+ // and a form with nothing to say which one the reader was in, and put an editor
597
+ // beside the field's real home where its description and its neighbours live.
598
+ // A checklist REPORTS; the section that owns a value COLLECTS it.
579
599
  type NeedKey = "delivery_address" | "commodity_code" | "hazard_class";
580
600
  const NEEDS: Record<NeedKey, { label: string; hint: string }> = {
581
601
  delivery_address: { label: "Delivery address", hint: "The consignee's dock — printed on the delivery note" },
@@ -891,7 +911,12 @@ function PartyRow({ role, noun, rec, options, onPick, onOpen, onUnset, onSaveFac
891
911
  * false, so the rail, the panel and the mirrored gutters collapse on their own.
892
912
  * What the flag decides is what a nested surface must NOT do — open comments in
893
913
  * a Drawer inside a Drawer, or draw a back button over the one the Drawer has. */
894
- export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?: "page" | "drawer"; code?: string } = {}) {
914
+ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection }: { chrome?: "page" | "drawer"; code?: string;
915
+ /** Land on this section instead of the top — a register row's comment bubble
916
+ * opens the record ON its discussion, because that is the only reason the
917
+ * reader pressed it. Applied once the skeleton clears: a jump at mount scrolls
918
+ * a layout that is still a placeholder and lands nowhere. */
919
+ openSection?: string } = {}) {
895
920
  const isDrawer = chrome === "drawer";
896
921
  const id = useRef(1);
897
922
  const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
@@ -905,6 +930,22 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
905
930
  return () => clearTimeout(t);
906
931
  }, []);
907
932
 
933
+ /**
934
+ * Land on a section when the caller named one — a register row's comment
935
+ * bubble opens the record ON its discussion.
936
+ *
937
+ * AFTER the skeleton clears, never at mount: the placeholder has different
938
+ * heights from the real layout, so a jump measured against it scrolls to a
939
+ * position that stops existing the moment the record renders. The section
940
+ * registers its offset on layout, so the frame after `booting` flips is the
941
+ * first one where the target is real.
942
+ */
943
+ useEffect(() => {
944
+ if (booting || !openSection) return;
945
+ const t = setTimeout(() => nav.jumpTo(openSection as Parameters<typeof nav.jumpTo>[0]), 0);
946
+ return () => clearTimeout(t);
947
+ }, [booting, openSection]);
948
+
908
949
  // ── the discussion thread (CommentList + Composer; author-only edit/delete)
909
950
  const [comments, setComments] = useState<ThreadComment[]>(SEED_COMMENTS);
910
951
  // Files staged on the NEXT comment — consumer-owned, exactly how the
@@ -1270,15 +1311,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1270
1311
  const isEligible = (f: SetForm) => f.id !== "c3" || hazardousGoods;
1271
1312
  const checkedForms = ALL_FORMS.filter((f) => isEligible(f) && chosenForms.has(f.id));
1272
1313
  const blockingNeeds = [...new Set(checkedForms.flatMap(missingOf))];
1273
- // the per-row fills' DRAFTS — keyed by field, SHARED between the rows of forms that
1274
- // need the same field, so filling it under one form resolves it for all; Save commits
1275
- // onto the record and the field's home row shows the value (the need resolves, the
1276
- // row's mark clears, the fill drops away).
1277
- const [fillDrafts, setFillDrafts] = useState<Record<string, string>>({});
1278
- const needSetters: Record<NeedKey, (v: string) => void> = { delivery_address: setDeliveryAddress, commodity_code: setCommodityCode, hazard_class: setHazardClass };
1279
- const saveNeeds = (keys: NeedKey[]) => {
1280
- for (const k of keys) needSetters[k]((fillDrafts[k] ?? "").trim());
1281
- };
1282
1314
  // Generation is DETERMINISTIC template fill — NOT AI, so no AgentRun
1283
1315
  // theater: the CTA carries a brief loading state and the whole set
1284
1316
  // appears at once (a real app awaits its render call the same way).
@@ -1332,22 +1364,77 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1332
1364
  };
1333
1365
 
1334
1366
  // ── desk CUSTODY — what each desk stamps about its own turn
1335
- // Each desk owns two facts about its own custody: when it took the record and
1336
- // who holds it. Stored per desk so a passed desk keeps them editable a date
1337
- // typed wrong must not become unreachable the moment the record moves on.
1338
- const [deskSince, setDeskSince] = useState<Record<string, string>>({ sales: "2026-06-15" });
1339
- // Which passed stage has been opened for editing. A stage BEHIND the live one
1340
- // shows its facts and nothing else — the ladder is read far more often than it
1341
- // is corrected, and a column of live editors on history invites changing it by
1342
- // accident. "Edit" is the deliberate step that turns one stage back into a form.
1343
- //
1344
- // ONE key, so opening a second stage closes the first. That is the intent, not a
1345
- // limitation: the editors commit as you leave them, so nothing is lost by the
1346
- // close, and two open stages would put two versions of the same ladder on screen
1347
- // — one being read, one being changed — with no cue for which is which.
1348
- const [editingDesk, setEditingDesk] = useState("");
1367
+ // The MILESTONE stamps, keyed by rung. Only rungs are stamped: a phase's ring
1368
+ // is derived from these, so "how far along" cannot be answered two ways.
1369
+ // Editable forever — a date typed wrong must not become unreachable the moment
1370
+ // the record moves on.
1371
+ const [deskSince, setDeskSince] = useState<Record<string, string>>({});
1349
1372
  const [deskOwner, setDeskOwner] = useState<Record<string, string | null>>({ sales: "mem_01" });
1350
1373
 
1374
+ /**
1375
+ * WHERE THE RECORD IS: the highest rung anyone has stamped. Everything below it
1376
+ * is REACHED — you cannot have cleared customs without collecting — so the run
1377
+ * reads done from POSITION, not from each rung carrying its own date.
1378
+ *
1379
+ * That is what lets one click say "everything up to here". Ticking `Delivered`
1380
+ * puts the record there and the two rungs under it read done immediately. The
1381
+ * alternative — writing today onto every rung below — marks the same three
1382
+ * boxes but INVENTS two dates: a record imported in June would show milestones
1383
+ * dated today, and nothing on screen would admit they were guessed.
1384
+ *
1385
+ * A reached rung with no stamp of its own shows a BLANK date, which is the
1386
+ * honest answer to "when" — and it stays one click from being filled in.
1387
+ */
1388
+ const topRung = RUNGS.reduce((top, r, i) => (deskSince[r.key] ? i : top), -1);
1389
+ const rungDone = (i: number) => i <= topRung;
1390
+
1391
+ /**
1392
+ * Which phases the reader has COLLAPSED. Everything is open by default: a
1393
+ * record ladder is read to see the whole route, and a run that opens one phase
1394
+ * at a time answers "where am I" while hiding "what is left". Collapsing is for
1395
+ * folding away a phase you are done with, not a mode you navigate in — so any
1396
+ * number may be closed at once, and none is the resting state.
1397
+ */
1398
+ const [closedPhases, setClosedPhases] = useState<Set<string>>(new Set());
1399
+ const togglePhase = (desk: string) =>
1400
+ setClosedPhases((prev) => {
1401
+ const next = new Set(prev);
1402
+ if (!next.delete(desk)) next.add(desk);
1403
+ return next;
1404
+ });
1405
+
1406
+ /** Tick rung `i` — ONLY rung `i`. Position does the rest. */
1407
+ const stampRung = (i: number) => {
1408
+ const today = new Date().toISOString().slice(0, 10);
1409
+ setDeskSince((prev) => ({ ...prev, [RUNGS[i].key]: today }));
1410
+ };
1411
+
1412
+ /**
1413
+ * Un-tick rung `i`: it and everything above walk back.
1414
+ *
1415
+ * Destructive and invisible from the ring, so it is NAMED before it happens —
1416
+ * which milestones, by their own labels. A rung with nothing recorded above it
1417
+ * needs no warning: there is nothing to lose.
1418
+ */
1419
+ const clearRungsFrom = (i: number, what: string) => {
1420
+ const above = RUNGS.slice(i + 1).filter((x) => deskSince[x.key]);
1421
+ const revert = () =>
1422
+ setDeskSince((prev) => {
1423
+ const next = { ...prev };
1424
+ RUNGS.slice(i).forEach((x) => { next[x.key] = ""; });
1425
+ return next;
1426
+ });
1427
+ if (above.length === 0) { revert(); return; }
1428
+ Alert.alert(
1429
+ `Reopen ${what}?`,
1430
+ `${above.map((x) => x.label).join(" and ")} ${above.length > 1 ? "are" : "is"} recorded after it. Reopening clears their dates. This can't be undone.`,
1431
+ [
1432
+ { text: "Cancel", style: "cancel" },
1433
+ { text: "Reopen and clear", style: "destructive", onPress: revert },
1434
+ ],
1435
+ );
1436
+ };
1437
+
1351
1438
  const customerOptions: PickerOption<string, Customer>[] = customers.map((c) => ({
1352
1439
  value: c.id,
1353
1440
  label: c.name,
@@ -1553,13 +1640,25 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1553
1640
  // drawer, minus the trigger's padding) and the `wide` threshold. One consumer
1554
1641
  // cannot tell those apart from the contract — two can.
1555
1642
  const SECTIONS = [
1643
+ // PROGRESS leads. The header already IDENTIFIES the record — it titles the
1644
+ // customer and carries the code and city beneath it — so the first section
1645
+ // does not have to introduce it, and what a reader opens a work record to
1646
+ // learn is where it stands and what is owed. General became reference the
1647
+ // moment the identity band stopped leading with the code, and reference
1648
+ // follows state. The facts that GATE progress travel to it anyway: the
1649
+ // overdue warning renders on the row that owes the work, not in a section
1650
+ // above it.
1651
+ //
1652
+ // The rail lists sections in PAGE order, or the scroll-spy highlights one
1653
+ // entry while the reader is looking at another.
1654
+ { key: "progress", label: "Progress", icon: "git-branch" },
1556
1655
  { key: "general", label: "General", icon: "file-text" },
1557
- // Files sits SECOND: what arrived on the record is read before what the
1558
- // record is doing, and it puts the intake affordance near the top of the
1559
- // scroll instead of eight sections down.
1656
+ // Only a SECTION when the page is too narrow to seat the panel — so the wide
1657
+ // rail filters it out rather than offering a jump to something that is not
1658
+ // there.
1659
+ { key: "comments", label: "Comments", icon: "message-square" },
1560
1660
  { key: "files", label: "Files", icon: "folder-closed" },
1561
1661
  { key: "transport", label: "Transport", icon: "map-pin" },
1562
- { key: "progress", label: "Progress", icon: "git-branch" },
1563
1662
  { key: "fees", label: "Fees", icon: "receipt" },
1564
1663
  { key: "billing", label: "Billing", icon: "credit-card" },
1565
1664
  { key: "docset", label: "Documents", icon: "file-stack" },
@@ -1568,7 +1667,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1568
1667
  ] as const;
1569
1668
  // Kept in step with `SECTIONS` above by hand — a key added there and forgotten
1570
1669
  // here type-errors at every `nav.register` call, which is the cheap failure.
1571
- const nav = useSectionNav(["general", "files", "transport", "progress", "fees", "billing", "docset", "receipt", "danger"] as const);
1670
+ const nav = useSectionNav(["progress", "general", "comments", "files", "transport", "fees", "billing", "docset", "receipt", "danger"] as const);
1572
1671
  // ONE record, ONE page. A section is a place you SCROLL to, never a
1573
1672
  // destination you swap to: routing a record was tried here and lost, because
1574
1673
  // every fix it needed rebuilt the whole-record view in miniature — a dot to
@@ -1614,16 +1713,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1614
1713
  // only to centre the column — so the panel costs no width the page did not
1615
1714
  // already spend. Both only earn their place while a readable column survives
1616
1715
  // between them; below that they collapse to the pinned bar and its drawer.
1617
- const wide = pageWidth != null && pageWidth >= GUTTER + PANEL_GUTTER + 560;
1716
+ // The panel appears once the reading column can still hold its FLOOR beside the
1717
+ // two gutters. The floor is what the column needs to stay readable (a label
1718
+ // column plus its values), not what it would like — hold out for the ceiling and
1719
+ // the thread folds away on every screen under ~1220.
1720
+ const wide = pageWidth != null && pageWidth >= GUTTER + PANEL_GUTTER + 420;
1618
1721
  // Narrow: the discussion is one tap off the same bar the sections are on —
1619
1722
  // on the RIGHT, opposite the section picker, since it is about the record as a
1620
1723
  // whole rather than a place in it.
1621
- const [commentsOpen, setCommentsOpen] = useState(false);
1622
1724
  // Drawer chrome switches the BODY between the record and the discussion. As a
1623
1725
  // section it competed with nine others for one scroll — first it pushed the
1624
1726
  // record down, last it sat 7,466px from the top. They are two things a reader
1625
1727
  // alternates between, so the bar names both and the body shows one.
1626
- const [commentsView, setCommentsView] = useState(false);
1627
1728
  // The rail floats over its RESERVED gutter rather than sitting in the scroll
1628
1729
  // row, so the ScrollView keeps the FULL page width — wheel anywhere and the
1629
1730
  // record scrolls. (Capping the ScrollView at the column's width instead makes
@@ -1633,6 +1734,53 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1633
1734
  const railLeft = pageWidth == null ? 0 : 28 + Math.max(0, (pageWidth - 28 * 2 - groupW) / 2);
1634
1735
  const panelRight = railLeft;
1635
1736
 
1737
+ /**
1738
+ * The discussion, defined ONCE and rendered wherever it lands — beside the
1739
+ * record when the page can hold a panel, under General when it cannot, and in
1740
+ * place of the body inside a drawer. Two copies of a thread is how two
1741
+ * surfaces drift apart on the day one of them gains attachments.
1742
+ */
1743
+ const commentsThread = (
1744
+ <>
1745
+ <CommentList
1746
+ comments={comments}
1747
+ currentMemberId="mem_01"
1748
+ resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
1749
+ onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
1750
+ onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
1751
+ /* the file-capable edit form — the same injection the product uses */
1752
+ renderEditForm={(p) => <CommentFileEditForm {...p} />}
1753
+ /* attachments render as the SAME thumbnail grid the product uses —
1754
+ press previews in the gallery (the desk's preview state) */
1755
+ renderFiles={(files) => (
1756
+ <FileGrid
1757
+ files={files.map(commentFileToDisplay)}
1758
+ itemSize={84}
1759
+ onFilePress={(f) => setPreview({ files: [f], index: 0 })}
1760
+ />
1761
+ )}
1762
+ /* compact timestamp — the product formats RELATIVE ("2 days ago",
1763
+ date-fns); a template stays dependency-free and deterministic */
1764
+ 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" })}`; }}
1765
+ />
1766
+ {/* The pane the toggle switches TO has to be the WHOLE discussion, not
1767
+ half of it: reading without a reply is a dead end, and this is the
1768
+ only surface where the thread has no panel or sheet to fall back on. */}
1769
+ <Composer
1770
+ onSend={(content) => {
1771
+ 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() }]);
1772
+ setPendingFiles([]);
1773
+ }}
1774
+ placeholder="Write a comment…"
1775
+ sendLabel="Send"
1776
+ actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
1777
+ files={pendingFiles.length > 0 ? (
1778
+ <FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
1779
+ ) : undefined}
1780
+ />
1781
+ </>
1782
+ );
1783
+
1636
1784
  return (
1637
1785
  // Measure-then-reveal: hidden until the width is known, so the rail/bar
1638
1786
  // choice never visibly reshuffles the page on first paint.
@@ -1665,7 +1813,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1665
1813
  <Button
1666
1814
  title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
1667
1815
  color="muted"
1668
- icon="list-collapse"
1669
1816
  onPress={() => setSectionsOpen(true)}
1670
1817
  />
1671
1818
  </View>
@@ -1679,10 +1826,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1679
1826
  current={nav.activeKey === sec.key}
1680
1827
  onPress={() => {
1681
1828
  setSectionsOpen(false);
1682
- // Leaving comments is part of jumping: in comments view the
1683
- // sections are not rendered, so a jump with no exit was a
1684
- // control that did nothing.
1685
- setCommentsView(false);
1686
1829
  nav.jumpTo(sec.key);
1687
1830
  }}
1688
1831
  />
@@ -1700,21 +1843,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1700
1843
  it toggles the body; on a narrow page it still opens the sheet, which
1701
1844
  is that layout's panel. The pair reads as a switch: whichever side the
1702
1845
  body is showing wears the filled `secondary`. */}
1703
- {isDrawer ? (
1704
- <Button
1705
- title={commentsView ? "Close comments" : `Comments (${comments.length})`}
1706
- color="muted"
1707
- icon="message-square"
1708
- onPress={() => setCommentsView((v) => !v)}
1709
- />
1710
- ) : (
1711
- <Button
1712
- title={`Comments (${comments.length})`}
1713
- color="muted"
1714
- icon="message-square"
1715
- onPress={() => setCommentsOpen(true)}
1716
- />
1717
- )}
1718
1846
  </View>
1719
1847
  )}
1720
1848
  <View style={{ flex: 1 }}>
@@ -1782,61 +1910,123 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1782
1910
  slot on RecordSummary at its second consumer. */}
1783
1911
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
1784
1912
  <View style={{ flex: 1, minWidth: 0 }}>
1913
+ {/* The SUBJECT leads, the key goes under it. A reader talks about
1914
+ "the Harbor Freight order", never "RC-2026-0418" — the code is how
1915
+ the SYSTEM refers to the record, which makes it a supporting value.
1916
+ The register's identity cell makes the same trade, so the two
1917
+ surfaces name a record the same way. */}
1785
1918
  <RecordSummary
1786
- title={code}
1787
- subtitle={customer ? [customer.name, customer.city].filter(Boolean).join(", ") : "No customer"}
1919
+ title={customer ? customer.name : "No customer"}
1920
+ subtitle={[code, customer?.city].filter(Boolean).join(" · ")}
1788
1921
  />
1789
1922
  </View>
1790
1923
  </View>
1791
- {isDrawer && commentsView ? (
1792
- <View style={{ gap: 28 }}>
1924
+ <SectionStack>
1925
+
1926
+
1927
+ {/* PROGRESS — the desk handoff, as the ordered positions it is. Sales →
1928
+ Operations → Accounting: one desk owns the record at a time, the live
1929
+ one carries the act that leaves it, and a passed desk keeps its own
1930
+ facts editable (a date typed wrong must stay reachable). This replaced
1931
+ a per-desk CHECKLIST: ticking boxes described the work, never the
1932
+ record's POSITION, and it put the handoff CTA a section away from the
1933
+ desk it belonged to. */}
1934
+ <View onLayout={nav.register("progress")}>
1793
1935
  <Section>
1794
1936
  <SectionHeading>
1795
- <SectionHeadingTitle description="Notes on this record everyone with access sees them.">Comments</SectionHeadingTitle>
1796
- <SectionHeadingMeta>{`${comments.length} ${comments.length === 1 ? "comment" : "comments"}`}</SectionHeadingMeta>
1937
+ <SectionHeadingTitle description="One desk owns the record at a time. The live desk carries the handoff.">Progress</SectionHeadingTitle>
1797
1938
  </SectionHeading>
1798
- <CommentList
1799
- comments={comments}
1800
- currentMemberId="mem_01"
1801
- resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
1802
- onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
1803
- onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
1804
- /* the file-capable edit form the same injection the product uses */
1805
- renderEditForm={(p) => <CommentFileEditForm {...p} />}
1806
- /* attachments render as the SAME thumbnail grid the product uses —
1807
- press previews in the gallery (the desk's preview state) */
1808
- renderFiles={(files) => (
1809
- <FileGrid
1810
- files={files.map(commentFileToDisplay)}
1811
- itemSize={84}
1812
- onFilePress={(f) => setPreview({ files: [f], index: 0 })}
1813
- />
1814
- )}
1815
- /* compact timestamp — the product formats RELATIVE ("2 days ago",
1816
- date-fns); a template stays dependency-free and deterministic */
1817
- 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" })}`; }}
1818
- />
1819
- {/* The pane the toggle switches TO has to be the WHOLE discussion, not
1820
- half of it: reading without a reply is a dead end, and this is the
1821
- only surface where the thread has no panel or sheet to fall back on. */}
1822
- <Composer
1823
- onSend={(content) => {
1824
- 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() }]);
1825
- setPendingFiles([]);
1826
- }}
1827
- placeholder="Write a comment…"
1828
- sendLabel="Send"
1829
- actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
1830
- files={pendingFiles.length > 0 ? (
1831
- <FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
1832
- ) : undefined}
1833
- />
1939
+ <Checklist connected accessibilityLabel="Desk handoff progress">
1940
+ {PHASES.flatMap((p, pi) => {
1941
+ const idxs = p.rungs.map(rungAt);
1942
+ const allDone = idxs.every(rungDone);
1943
+ // The live phase is the first with anything still owed derived,
1944
+ // like every other position here, from the stamps alone.
1945
+ const firstOpen = PHASES.findIndex((x) => x.rungs.some((k) => !deskSince[k]));
1946
+ const live = !allDone && pi === firstOpen;
1947
+ const shown = !closedPhases.has(p.desk);
1948
+ const owner = deskOwner[p.desk];
1949
+ return [
1950
+ // A PHASE is a name and nothing else: no control, because it owns no
1951
+ // completion of its own, and no body, because a condition hung off a
1952
+ // heading leaves the reader working out which row it was about.
1953
+ <ChecklistGroup
1954
+ key={p.desk}
1955
+ title={p.label}
1956
+ open={shown}
1957
+ onToggleOpen={() => togglePhase(p.desk)}
1958
+ />,
1959
+ ...(shown ? p.rungs : []).map((k) => {
1960
+ const i = rungAt(k);
1961
+ const stamped = deskSince[k] ?? "";
1962
+ // The one still owed the rung past the position, read off the
1963
+ // SAME number everything else here reads.
1964
+ const isNext = live && i === topRung + 1;
1965
+ return (
1966
+ <ChecklistItem
1967
+ key={k}
1968
+ title={RUNGS[i].label}
1969
+ done={rungDone(i)}
1970
+ current={isNext}
1971
+ onToggle={(next) => (next ? stampRung(i) : clearRungsFrom(i, RUNGS[i].label))}
1972
+ // Offered on every REACHED rung, stamped or not: a rung the
1973
+ // record passed without anyone recording the day is exactly the
1974
+ // one worth filling in, and a blank date is the honest report
1975
+ // until someone does. An unreached rung shows nothing — its
1976
+ // empty ring already says "not yet".
1977
+ trailing={
1978
+ rungDone(i) || isNext ? (
1979
+ <DateStamp
1980
+ value={stamped || null}
1981
+ onChange={(iso) => setDeskSince((prev) => ({ ...prev, [k]: iso }))}
1982
+ locale="en-GB"
1983
+ accessibilityLabel={"Date for " + RUNGS[i].label}
1984
+ />
1985
+ ) : undefined
1986
+ }
1987
+ // How long THIS rung has been owed — since the one before it was
1988
+ // stamped, or since the record opened. Measured at the row rather
1989
+ // than the phase, because that is the wait a reader asks about.
1990
+ meta={isNext ? heldFor(i > 0 ? deskSince[RUNGS[i - 1].key] ?? "" : orderDate) : undefined}
1991
+ >
1992
+ {isNext ? (
1993
+ <>
1994
+ {stage !== "closed" && deliverBy !== "" && new Date(deliverBy) < new Date() ? (
1995
+ <ChecklistNote tone="warning">Past the due date — chase it or move the date.</ChecklistNote>
1996
+ ) : null}
1997
+ {!owner ? (
1998
+ <ChecklistNote tone="warning" action={{ label: "Set in General", onPress: () => nav.jumpTo("general") }}>
1999
+ Nobody is assigned to this desk.
2000
+ </ChecklistNote>
2001
+ ) : null}
2002
+ {p.desk === stage && gate ? (
2003
+ <ChecklistActions>
2004
+ <Button
2005
+ title={gate.cta}
2006
+ color="primary"
2007
+ onPress={gate.handoff ? () => { setHandoffTo(gate.next); setHandoffOpen(true); } : closeRecord}
2008
+ />
2009
+ </ChecklistActions>
2010
+ ) : null}
2011
+ </>
2012
+ ) : null}
2013
+ </ChecklistItem>
2014
+ );
2015
+ }),
2016
+ ];
2017
+ })}
2018
+ </Checklist>
1834
2019
  </Section>
1835
- </View>
1836
- ) : (
1837
- <SectionStack>
2020
+ </View>
1838
2021
 
1839
2022
 
2023
+ {/* FEES — the DETAILED money ledger, both directions (charge = billed
2024
+ to the customer, cost = paid to a vendor), distinct from Billing's
2025
+ invoice DOCUMENTS. The drill-down law for a child collection: a
2026
+ compact register row, and EVERY row EXPANDS to its full detail in
2027
+ place — one at a time, no drawer, so the surrounding rows stay
2028
+ readable and coming back costs nothing. Add is create-then-refine —
2029
+ a blank fee appends already open, where it will live. */}
1840
2030
  {/* GENERAL — the MAIN details, FIRST section: the core facts of the
1841
2031
  item; every other section is supplementary. The body is a
1842
2032
  SubsectionStack (space-only beat): a headingless LEAD group (the
@@ -2177,6 +2367,29 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2177
2367
  </Section>
2178
2368
  </View>
2179
2369
 
2370
+ {/* NO ROOM FOR THE PANEL — so the thread reads as a SECTION, directly
2371
+ under General, because a coworker's note is the first thing to read on
2372
+ a shared record.
2373
+
2374
+ The question is the CONTAINER's width and nothing else: a narrow page
2375
+ and a drawer answer it the same way, and a drawer is simply always
2376
+ narrow. It was briefly a body-SWAP there, behind a toggle — the same
2377
+ fault as the old top-right button in a different shape, since either
2378
+ way the reader has to know a control exists, press it, and give up the
2379
+ record to read a note. Wide keeps the thread beside the record; every
2380
+ other width puts it here. */}
2381
+ {!wide ? (
2382
+ <View onLayout={nav.register("comments")}>
2383
+ <Section>
2384
+ <SectionHeading>
2385
+ <SectionHeadingTitle description="Notes on this record — everyone with access sees them.">Comments</SectionHeadingTitle>
2386
+ <SectionHeadingMeta>{`${comments.length} ${comments.length === 1 ? "comment" : "comments"}`}</SectionHeadingMeta>
2387
+ </SectionHeading>
2388
+ {commentsThread}
2389
+ </Section>
2390
+ </View>
2391
+ ) : null}
2392
+
2180
2393
  {/* DOCUMENTS — the document desk: the register Table (search, Create
2181
2394
  documents, Add files) whose selection feeds the Use-AI fork below.
2182
2395
  No FileDropTarget of its OWN — the WHOLE-RECORD target above already
@@ -2382,7 +2595,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2382
2595
  <DetailRow label="Trip no.">
2383
2596
  <InlineTextInput value={trip} onSave={persist(setTrip)} placeholder="Add trip number…" accessibilityLabel="Trip number" />
2384
2597
  </DetailRow>
2385
- {/* A ROUTE IS NOT FIELDS — see `RouteStops` above. It lives in
2598
+ {/* A ROUTE IS NOT FIELDS — see `RouteStops` above. It lives in
2386
2599
  a `DetailRow` so it keeps the section's label column and the
2387
2600
  reader's eye stays on one x; what sits in the VALUE is a list,
2388
2601
  because that is what a route is. */}
@@ -2402,192 +2615,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2402
2615
  </View>
2403
2616
 
2404
2617
 
2405
- {/* PROGRESS — the desk handoff, as the ordered positions it is. Sales →
2406
- Operations → Accounting: one desk owns the record at a time, the live
2407
- one carries the act that leaves it, and a passed desk keeps its own
2408
- facts editable (a date typed wrong must stay reachable). This replaced
2409
- a per-desk CHECKLIST: ticking boxes described the work, never the
2410
- record's POSITION, and it put the handoff CTA a section away from the
2411
- desk it belonged to. */}
2412
- <View onLayout={nav.register("progress")}>
2413
- <Section>
2414
- <SectionHeading>
2415
- <SectionHeadingTitle description="One desk owns the record at a time. The live desk carries the handoff.">Progress</SectionHeadingTitle>
2416
- </SectionHeading>
2417
- <Pipeline accessibilityLabel="Desk handoff progress">
2418
- {/* The ladder reads its own STAMPS, and nothing else. Driving `status`
2419
- off the record's `stage` while the tick wrote a date left two
2420
- sources of truth for one position: the ring could never change
2421
- state, so pressing it did nothing visible — no fill, no spring, no
2422
- movement — which is what "the progress is broken" looks like.
2423
- A rung is done when IT carries a date, independently of its
2424
- neighbours, so any rung can be ticked and un-ticked on its own. */}
2425
- {STEPS.map((d, i) => {
2426
- const stampedAt = deskSince[d.key] ?? "";
2427
- const firstOpen = STEPS.findIndex((x) => !deskSince[x.key]);
2428
- const status: "done" | "current" | "upcoming" = stampedAt ? "done" : i === firstOpen ? "current" : "upcoming";
2429
- const owner = deskOwner[d.key];
2430
- return (
2431
- <PipelineStage
2432
- key={d.key}
2433
- status={status}
2434
- title={d.label}
2435
- // Prose the reader can't set — how long it has sat here.
2436
- meta={status === "current" ? heldFor(stampedAt) : undefined}
2437
- // The tick IS the stamp: pressing the ring records today, pressing
2438
- // it again clears the stage. ANY ring takes a press (`Pipeline`'s
2439
- // default) because this ladder is a RECORD — entering three stages
2440
- // that already happened must not cost three presses plus three
2441
- // date corrections.
2442
- onToggle={(next) => {
2443
- if (next) {
2444
- // Ticking AHEAD fills the gap. A ladder is a run, not a set
2445
- // of independent boxes: leaving 3 and 4 blank because someone
2446
- // ticked 5 renders "done, blank, blank, done", which claims a
2447
- // record reached the end without passing through the middle.
2448
- // Every rung at or below the one pressed that has no stamp
2449
- // gets today — the day this is being recorded, which is the
2450
- // same thing a single tick already means. Each one is then
2451
- // one click away from its real date, and the ones that
2452
- // already carry a date are left exactly as they are.
2453
- const today = new Date().toISOString().slice(0, 10);
2454
- setDeskSince((prev) => {
2455
- const filled = { ...prev };
2456
- STEPS.slice(0, i + 1).forEach((x) => { if (!filled[x.key]) filled[x.key] = today; });
2457
- return filled;
2458
- });
2459
- return;
2460
- }
2461
- // Un-ticking walks the record BACK, and everything the stages
2462
- // above it recorded goes with it — their dates and the facts
2463
- // they own. That is destructive and invisible from the ring,
2464
- // so it is named before it happens: which stages, and that it
2465
- // is their data too, not just their dates. A stage with
2466
- // nothing above it needs no warning — there is nothing to lose.
2467
- const above = STEPS.slice(i + 1).filter((x) => deskSince[x.key] || deskOwner[x.key]);
2468
- const revert = () =>
2469
- STEPS.slice(i).forEach((x) => {
2470
- setDeskSince((prev) => ({ ...prev, [x.key]: "" }));
2471
- setDeskOwner((prev) => ({ ...prev, [x.key]: "" }));
2472
- });
2473
- if (above.length === 0) { revert(); return; }
2474
- Alert.alert(
2475
- `Reopen ${d.label}?`,
2476
- `${above.map((x) => x.label).join(" and ")} ${above.length > 1 ? "are" : "is"} recorded after it. Reopening clears their dates and everything they hold — owner included. This can't be undone.`,
2477
- [
2478
- { text: "Cancel", style: "cancel" },
2479
- { text: "Reopen and clear", style: "destructive", onPress: revert },
2480
- ],
2481
- );
2482
- }}
2483
- // The date rides the title's own row and READS as text — `bare`
2484
- // shows nothing at rest and fades its border in on hover, so the
2485
- // ladder stays a column of dates to scan rather than a column of
2486
- // controls, and the one that needs correcting is still one click.
2487
- //
2488
- // That click is how a stamp gets BACKDATED, which the tick alone
2489
- // cannot do: ticking always writes today, and a ladder filled in
2490
- // after the fact is full of days that already passed. Rendering
2491
- // the date as inert text would have cost exactly that.
2492
- //
2493
- // An unstamped rung shows nothing — its empty ring already says
2494
- // "not yet", and a "Not recorded" placeholder on every rung ahead
2495
- // says the same thing again in more words.
2496
- // What the stage HOLDS, beside its name: the date it was
2497
- // stamped, and any value it owns that has actually been set.
2498
- // A stage's facts are otherwise only visible once you read down
2499
- // into its body, so a ladder of five says nothing but five
2500
- // names — the reader has to open each to learn anything. Only
2501
- // values that EXIST are shown; an empty one would print a label
2502
- // with nothing after it on every rung ahead.
2503
- // The stage's own line carries what is ABOUT the stage — when it
2504
- // happened, and the way into changing it — while the body holds
2505
- // what it CONTAINS. Edit sat under the values, which put the verb
2506
- // below the thing it acts on and moved it down every time a stage
2507
- // gained a field.
2508
- trailing={
2509
- <View style={{ flexDirection: "row", alignItems: "baseline", gap: 10 }}>
2510
- <DateStamp
2511
- value={stampedAt || null}
2512
- onChange={(iso) => setDeskSince((prev) => ({ ...prev, [d.key]: iso }))}
2513
- locale="en-GB"
2514
- accessibilityLabel={"Date for " + d.label}
2515
- />
2516
- {/* Edit ⇄ Done, one control in one place. It is a text LINK
2517
- and not a button on purpose: the inline editors commit on
2518
- their own (blur, Enter, a pick), so nothing is pending when
2519
- you press this — it only puts the stage back to reading.
2520
- A `Button` would promise a SAVE that already happened and
2521
- make a reader wonder what they lose by navigating away. */}
2522
- {d.desk && status !== "upcoming" && status !== "current" ? (
2523
- <Pressable
2524
- onPress={() => setEditingDesk(editingDesk === d.key ? "" : d.key)}
2525
- accessibilityRole="button"
2526
- accessibilityLabel={`${editingDesk === d.key ? "Done editing" : "Edit"} ${d.label}`}
2527
- >
2528
- <TextLink size="xs">{editingDesk === d.key ? "Done" : "Edit"}</TextLink>
2529
- </Pressable>
2530
- ) : null}
2531
- </View>
2532
- }
2533
- >
2534
- {/* A reached desk owns its own facts — and keeps owning them
2535
- once the record has moved on. */}
2536
- {status !== "upcoming" && d.desk ? (
2537
- status === "current" || editingDesk === d.key ? (
2538
- <PipelineField label="Owner" maxWidth={260}>
2539
- <InlineMemberSelect
2540
- members={TEAM}
2541
- value={owner}
2542
- onSave={(v) => setDeskOwner((p) => ({ ...p, [d.key]: v }))}
2543
- placeholder="Unassigned"
2544
- accessibilityLabel={`Owner for ${d.label}`}
2545
- />
2546
- </PipelineField>
2547
- ) : (
2548
- // Read-only, but still SHOWN — and shown by whatever renders
2549
- // that kind of value properly: a member as a `MemberChip`, a
2550
- // select as its `Badge`. Collapsing them to muted text would
2551
- // make the same fact look different depending on whether the
2552
- // stage happened to be the live one, and a reader would have
2553
- // to decode two renderings of one field. Only what is SET
2554
- // appears; an empty field has nothing to display read-only.
2555
- // No label. A label tells you what you are FILLING IN —
2556
- // read-only, the value says what it is: a face and a name is
2557
- // an owner, a badge is a status. Printing "Owner" over a
2558
- // member chip is the field talking about itself, and down a
2559
- // ladder it doubles the lines for nothing.
2560
- owner ? <MemberChip name={TEAM.find((x) => x.id === owner)?.name ?? owner} size={22} /> : null
2561
- )
2562
- ) : null}
2563
- {/* A condition belongs to the desk it is about. */}
2564
- {status === "current" && stage !== "closed" && deliverBy !== "" && new Date(deliverBy) < new Date() ? (
2565
- <PipelineNote tone="warning">Past the due date — chase it or move the date.</PipelineNote>
2566
- ) : null}
2567
- {status === "current" && d.desk === stage && gate ? (
2568
- <PipelineActions>
2569
- <Button
2570
- title={gate.cta}
2571
- color="primary"
2572
- onPress={gate.handoff ? () => { setHandoffTo(gate.next); setHandoffOpen(true); } : closeRecord}
2573
- />
2574
- </PipelineActions>
2575
- ) : null}
2576
- </PipelineStage>
2577
- );
2578
- })}
2579
- </Pipeline>
2580
- </Section>
2581
- </View>
2582
-
2583
-
2584
- {/* FEES — the DETAILED money ledger, both directions (charge = billed
2585
- to the customer, cost = paid to a vendor), distinct from Billing's
2586
- invoice DOCUMENTS. The drill-down law for a child collection: a
2587
- compact register row, and EVERY row EXPANDS to its full detail in
2588
- place — one at a time, no drawer, so the surrounding rows stay
2589
- readable and coming back costs nothing. Add is create-then-refine —
2590
- a blank fee appends already open, where it will live. */}
2591
2618
  <View onLayout={nav.register("fees")}>
2592
2619
  <Section>
2593
2620
  <SectionHeading>
@@ -2987,74 +3014,40 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2987
3014
  <SubsectionHeading>
2988
3015
  <SubsectionHeadingTitle>{g.party(customer?.name ?? null)}</SubsectionHeadingTitle>
2989
3016
  </SubsectionHeading>
2990
- {/* the PICKER rows ride the Task compound IT owns the
2991
- geometry (the row band, the two gutters, the indent
2992
- everything hung under a row takes); hand-rolling this
2993
- anatomy is how alignment drifts. controlWidth 24 =
2994
- CheckboxInput; and no row here carries a ⋯, so the list
2995
- declines the action gutter and its captions run to the
2996
- surface's edge. */}
2997
- <TaskList controlWidth={24} actionWidth={0}>
3017
+ {/* A SELECTION, not a run of work: which forms to produce. So the
3018
+ mark is the SQUARE the kit spends on picking — a filled RING
3019
+ here would read as "already done" to anyone who has learned
3020
+ what a ring means one section above. And UNCONNECTED, because
3021
+ a line between them would claim an order the work does not
3022
+ have. Same compound as the Progress ladder, two axes apart —
3023
+ and the compound owns the geometry (the row band, the marker
3024
+ gutter, the indent anything hung under a row takes), because
3025
+ hand-rolling that anatomy is how alignment drifts. */}
3026
+ <Checklist connected={false} mark="select">
2998
3027
  {shown.map((f) => {
2999
- // READINESS per row, CO-LOCATED: a not-ready row's mark NAMES what's
3000
- // missing (muted at rest discoverable without checking; warning once
3001
- // checked, it now blocks), and the FIX sits right under it in the
3002
- // `expansion` — an `Inset` (a FORM surface, never a Callout, which is an
3003
- // ARIA alert) with the editor for each gap, saved onto the record in
3004
- // place. Drafts are keyed BY FIELD, so a field two checked forms share
3005
- // is entered once and one Save resolves both. A ready form stays SILENT.
3028
+ // READINESS per row: a not-ready row NAMES what is missing and
3029
+ // points at the section that owns it. Nothing is collected here
3030
+ // see the section header for why a checklist reports.
3006
3031
  const missing = missingOf(f);
3007
3032
  const checked = chosenForms.has(f.id);
3008
3033
  return (
3009
- <TaskItem key={f.id}>
3010
- <TaskStatus>
3011
- <CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />
3012
- </TaskStatus>
3013
- {/* A STRING title carries the cell inset and the row's band from the
3014
- compound, so it sits in line with the editable titles elsewhere on
3015
- the page and the checkbox stays beside it above its caption. */}
3016
- <TaskTitle>{f.label}</TaskTitle>
3017
- {/* "Needs: Tax ID, Delivery address" is a SENTENCE about this row, so it
3018
- is a `TaskCaption` — its own line under the title. Not a
3019
- `TaskSubRow`, which is a NAMED value the reader SETS: prose given a
3020
- label reads as a field nobody can edit. */}
3034
+ <ChecklistItem
3035
+ key={f.id}
3036
+ title={f.label}
3037
+ done={checked}
3038
+ onToggle={(on) => toggleForm(f.id, on)}
3039
+ >
3021
3040
  {missing.length > 0 ? (
3022
- <TaskCaption>
3023
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 5 }}>
3024
- <Icon name="circle-alert" size={13} color={checked ? colors.amber[500] : colors.zinc[400]} />
3025
- <Text size="xs" color={checked ? "warning" : "muted"} style={{ flexShrink: 1 }}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
3026
- </View>
3027
- </TaskCaption>
3028
- ) : null}
3029
- {checked && missing.length > 0 ? (
3030
- <TaskDetail>
3031
- <Inset>
3032
- <Text size="xs" color="muted">These values save onto the order and unlock the form.</Text>
3033
- {missing.map((k) => (
3034
- <FormTextInput
3035
- key={k}
3036
- label={NEEDS[k].label}
3037
- description={NEEDS[k].hint}
3038
- value={fillDrafts[k] ?? ""}
3039
- onChangeText={(t) => setFillDrafts((d) => ({ ...d, [k]: t }))}
3040
- accessibilityLabel={NEEDS[k].label}
3041
- />
3042
- ))}
3043
- <View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
3044
- <Button
3045
- title="Save fields"
3046
- color="secondary"
3047
- disabled={missing.every((k) => !(fillDrafts[k] ?? "").trim())}
3048
- onPress={() => saveNeeds(missing)}
3049
- />
3050
- </View>
3051
- </Inset>
3052
- </TaskDetail>
3041
+ // Muted at rest — discoverable without checking; WARNING
3042
+ // once checked, because it now blocks.
3043
+ <ChecklistNote tone={checked ? "warning" : "muted"}>
3044
+ {`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")} — fill these in Customer`}
3045
+ </ChecklistNote>
3053
3046
  ) : null}
3054
- </TaskItem>
3047
+ </ChecklistItem>
3055
3048
  );
3056
3049
  })}
3057
- </TaskList>
3050
+ </Checklist>
3058
3051
  {hidden > 0 ? (
3059
3052
  <View style={{ flexDirection: "row" }}>
3060
3053
  <Button
@@ -3087,7 +3080,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
3087
3080
  {blockingNeeds.length > 0 ? (
3088
3081
  <Callout tone="warning">
3089
3082
  <CalloutText>
3090
- {`The checked forms still need ${blockingNeeds.map((k) => NEEDS[k].label.toLowerCase()).join(" and ")} — ${blockingNeeds.length === 1 ? "it prints" : "they print"} blank unless filled above.`}
3083
+ {`The checked forms still need ${blockingNeeds.map((k) => NEEDS[k].label.toLowerCase()).join(" and ")} — ${blockingNeeds.length === 1 ? "it prints" : "they print"} blank until filled on the record.`}
3091
3084
  </CalloutText>
3092
3085
  </Callout>
3093
3086
  ) : null}
@@ -3222,7 +3215,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
3222
3215
  </View>
3223
3216
 
3224
3217
  </SectionStack>
3225
- )}
3226
3218
  </View>
3227
3219
  </FileDropTarget>
3228
3220
  </>
@@ -3358,53 +3350,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
3358
3350
  </View>
3359
3351
  </ScrollView>
3360
3352
 
3361
- {/* Narrow: the same thread, in the panel shape a small screen has — a
3362
- right-docked Drawer off the bar's own comments control. Never in drawer
3363
- mode: a Drawer inside a Drawer traps the reader one layer deeper than the
3364
- record they opened. */}
3365
- <Drawer open={commentsOpen && !isDrawer} onOpenChange={setCommentsOpen} title="Comments" width={380}>
3366
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 20, gap: 12 }}>
3367
- <CommentList
3368
- comments={comments}
3369
- currentMemberId="mem_01"
3370
- resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
3371
- onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
3372
- onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
3373
- /* the file-capable edit form — the same injection the product uses */
3374
- renderEditForm={(p) => <CommentFileEditForm {...p} />}
3375
- /* attachments render as the SAME thumbnail grid the product uses —
3376
- press previews in the gallery (the desk's preview state) */
3377
- renderFiles={(files) => (
3378
- <FileGrid
3379
- files={files.map(commentFileToDisplay)}
3380
- itemSize={84}
3381
- onFilePress={(f) => setPreview({ files: [f], index: 0 })}
3382
- />
3383
- )}
3384
- /* compact timestamp — the product formats RELATIVE ("2 days ago",
3385
- date-fns); a template stays dependency-free and deterministic */
3386
- 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" })}`; }}
3387
- />
3388
-
3389
- </ScrollView>
3390
- <View style={{ padding: 20, paddingTop: 0 }}>
3391
- {/* THE kit Composer — the one composing surface everywhere (chat,
3392
- agent prompts, comments): attach via actionsButton, the staged
3393
- files (removable until Send) ride the files slot. */}
3394
- <Composer
3395
- onSend={(content) => {
3396
- 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() }]);
3397
- setPendingFiles([]);
3398
- }}
3399
- placeholder="Write a comment…"
3400
- sendLabel="Send"
3401
- actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
3402
- files={pendingFiles.length > 0 ? (
3403
- <FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
3404
- ) : undefined}
3405
- />
3406
- </View>
3407
- </Drawer>
3408
3353
 
3409
3354
  {/* ── the document desk's overlays */}
3410
3355
  <FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
@@ -3695,45 +3640,12 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
3695
3640
  <SectionHeadingTitle>Comments</SectionHeadingTitle>
3696
3641
  <SectionHeadingMeta>{`${comments.length} ${comments.length === 1 ? "comment" : "comments"}`}</SectionHeadingMeta>
3697
3642
  </SectionHeading>
3643
+ {/* The panel's OWN scroller, so a long thread scrolls inside itself and
3644
+ never lengthens the record. The thread inside it is the same one the
3645
+ section renders — defined once, so the two cannot drift. */}
3698
3646
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12, paddingBottom: 8 }}>
3699
- <CommentList
3700
- comments={comments}
3701
- currentMemberId="mem_01"
3702
- resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
3703
- onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
3704
- onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
3705
- /* the file-capable edit form — the same injection the product uses */
3706
- renderEditForm={(p) => <CommentFileEditForm {...p} />}
3707
- /* attachments render as the SAME thumbnail grid the product uses —
3708
- press previews in the gallery (the desk's preview state) */
3709
- renderFiles={(files) => (
3710
- <FileGrid
3711
- files={files.map(commentFileToDisplay)}
3712
- itemSize={84}
3713
- onFilePress={(f) => setPreview({ files: [f], index: 0 })}
3714
- />
3715
- )}
3716
- /* compact timestamp — the product formats RELATIVE ("2 days ago",
3717
- date-fns); a template stays dependency-free and deterministic */
3718
- 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" })}`; }}
3719
- />
3720
-
3647
+ {commentsThread}
3721
3648
  </ScrollView>
3722
- {/* THE kit Composer — the one composing surface everywhere (chat,
3723
- agent prompts, comments): attach via actionsButton, the staged
3724
- files (removable until Send) ride the files slot. */}
3725
- <Composer
3726
- onSend={(content) => {
3727
- 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() }]);
3728
- setPendingFiles([]);
3729
- }}
3730
- placeholder="Write a comment…"
3731
- sendLabel="Send"
3732
- actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
3733
- files={pendingFiles.length > 0 ? (
3734
- <FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
3735
- ) : undefined}
3736
- />
3737
3649
  </View>
3738
3650
  )}
3739
3651
 
@@ -3747,7 +3659,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
3747
3659
  real app), visually apart from the outline items. */}
3748
3660
  <BackButton label="Records" onPress={() => {}} />
3749
3661
  <View style={{ gap: 2 }}>
3750
- {SECTIONS.map((sec) => (
3662
+ {SECTIONS.filter((sec) => sec.key !== "comments").map((sec) => (
3751
3663
  <MenuButton
3752
3664
  key={sec.key}
3753
3665
  icon={sec.icon}