@lotics/ui 23.1.1 → 24.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.
@@ -13,17 +13,20 @@ 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 { DialogSectionHeadingTitle, Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
16
+ import { Section, SectionHeading, SectionHeadingMeta, 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";
20
20
  import { MenuButton } from "@lotics/ui/menu_button";
21
- import { Modal, ModalBody, ModalHeader } from "@lotics/ui/modal";
21
+ import { Popover, PopoverContent } from "@lotics/ui/popover";
22
22
  import { useSectionNav } from "@lotics/ui/use_section_nav";
23
23
  import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
24
24
  import { formatMoney } from "@lotics/ui/format_money";
25
25
  import { RecordSummary } from "@lotics/ui/record_summary";
26
26
  import { SummaryLine } from "@lotics/ui/summary_line";
27
+ import { Badge } from "@lotics/ui/badge";
28
+ import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline";
29
+ import { useLoticsLocale } from "@lotics/ui/locale";
27
30
  import { Skeleton } from "@lotics/ui/skeleton";
28
31
  import { CommentList, type CommentEditFormProps, type ThreadComment, type ThreadFile } from "@lotics/ui/comments_thread";
29
32
  import { Composer } from "@lotics/ui/composer";
@@ -38,7 +41,7 @@ import { InlineSelect } from "@lotics/ui/inline_select";
38
41
  import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
39
42
  import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
40
43
  import { MemberSelect, type MemberSelectMember } from "@lotics/ui/member_select";
41
- import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
44
+ import { Drawer } from "@lotics/ui/drawer";
42
45
  import { EmptyState } from "@lotics/ui/empty_state";
43
46
  import { FormField } from "@lotics/ui/form_field";
44
47
  import { TextInputField } from "@lotics/ui/text_input_field";
@@ -53,13 +56,11 @@ import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
53
56
  import { Finding, FindingComparison } from "@lotics/ui/finding";
54
57
  import { FormTextInput } from "@lotics/ui/form_text_input";
55
58
  import { CheckboxInput } from "@lotics/ui/checkbox_input";
56
- import { Popover, PopoverContent } from "@lotics/ui/popover";
57
- import { ChipGroup } from "@lotics/ui/chip_group";
59
+ import { ChipGroup, type ChipOption } from "@lotics/ui/chip_group";
58
60
  import { Sequence, SequenceItem, SEQUENCE_INSET } from "@lotics/ui/sequence";
59
- import { TextLink } from "@lotics/ui/text_link";
61
+ import { ReferenceField } from "@lotics/ui/reference_field";
62
+ import { TextButton } from "@lotics/ui/text_button";
60
63
  import { InlineButton } from "@lotics/ui/inline_button";
61
- import { InlineEditView } from "@lotics/ui/inline_edit";
62
- import { RadioPicker } from "@lotics/ui/radio_picker";
63
64
  import { useSelection } from "@lotics/ui/use_selection";
64
65
  import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
65
66
  import { CardSelectItem } from "@lotics/ui/card_select_item";
@@ -88,7 +89,7 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
88
89
  // THE SECTION PATTERNS (what each is, when to use it):
89
90
  // - General — the CORE FACTS: a headingless key-facts lead + named
90
91
  // groups + Classification (the right-input-per-field
91
- // showcase) + System ids. Always present, first in rail.
92
+ // showcase). Always present, first in rail.
92
93
  // - Comments — the discussion thread. When people collaborate here.
93
94
  // - Progress — WHERE the record sits: the desks as a `Pipeline`, each
94
95
  // owning its own facts, the live one owning the act that
@@ -437,9 +438,21 @@ const iso = (d: Date): string => d.toISOString().slice(0, 10);
437
438
 
438
439
  // ── the FEES ledger — the DETAILED money view (both directions), distinct
439
440
  // from Billing's invoice DOCUMENTS: every fee the record incurs or charges,
440
- // with its party, due date, proof reference and paid state. The register +
441
- // right-docked entity drawer (◀ ▶ steps the rows) is the drill-down law:
442
- // every child row opens to its FULL detail.
441
+ // with its party, due date, proof reference and paid state.
442
+ //
443
+ // The drill-down law for a CHILD COLLECTION inside a record: the row EXPANDS
444
+ // (`TableRow` `detail` + `expanded`), it does not open a drawer. A register
445
+ // screen's row still docks a workspace Drawer — on a list screen there is
446
+ // nowhere else for the detail to go — but here the record page IS the context
447
+ // the drawer was recreating, so docking one charged for navigation twice and
448
+ // took the surrounding rows away to show nine fields.
449
+ //
450
+ // This is also why the ledger is not simply WIDER. All nine fields as columns
451
+ // demand 946px + 112 gap + 40 gutter = 1098px against a 720px reading column;
452
+ // over budget `table_fit` drops droppable columns by priority until the rest
453
+ // fit, which at 720 leaves about the five below. Widening the register does not
454
+ // display VAT / Invoice no / Note — it drops them silently. And `note` is prose:
455
+ // it has no column width at any measure.
443
456
  type FeeDirection = "charge" | "cost";
444
457
  interface Fee {
445
458
  id: string;
@@ -463,7 +476,7 @@ const FEE_SEED: Fee[] = [
463
476
  ];
464
477
  // `priority` = the mobile contract: Party then Type drop first on a narrow
465
478
  // container, Amount + Status survive with the identity; at the floor the rows
466
- // STACK label-over-value — and the entity drawer always carries the full fee.
479
+ // STACK label-over-value — and the expansion always carries the full fee.
467
480
  const FEE_COLUMNS: TableColumn[] = [
468
481
  { key: "fee", label: "Fee", flex: 1 },
469
482
  { key: "type", label: "Type", width: 72, priority: 3 },
@@ -565,6 +578,23 @@ const SERVICE_LEVELS: PickerOption<string>[] = [
565
578
  { value: "economy", label: "Economy" },
566
579
  { value: "scheduled", label: "Scheduled" },
567
580
  ];
581
+ // Both of these were RadioPickers rendered INLINE on the record: 4 and 3 options
582
+ // permanently expanded, 232px and 156px against a 40px field grid — 49% of the
583
+ // Classification group, spent on values a reader almost never changes. A pick from a
584
+ // short list is the SAME SHAPE as Service level above, so it gets the same control;
585
+ // the gloss that justified the radio rides into the dropdown as `data.hint`, which is
586
+ // where a description is read anyway — while CHOOSING, not on every later read.
587
+ const FULFILMENT_STATUSES: PickerOption<string, { hint?: string }>[] = [
588
+ { value: "ready", label: "Ready at the warehouse" },
589
+ { value: "production", label: "In production", data: { hint: "Confirmed ready by the ship date." } },
590
+ { value: "awaiting", label: "Awaiting inbound supply" },
591
+ { value: "partial", label: "Partial", data: { hint: "Ships in two lots — the rest follows on the next run." } },
592
+ ];
593
+ const INSURANCE_LEVELS: ChipOption<string>[] = [
594
+ { value: "none", label: "None" },
595
+ { value: "standard", label: "Standard" },
596
+ { value: "all_risk", label: "All-risk" },
597
+ ];
568
598
  const SERVICE_LEVEL_DESC: Record<string, string> = {
569
599
  standard: "Door to door on the regular schedule.",
570
600
  express: "Priority handling — the first available departure.",
@@ -582,134 +612,6 @@ const DESTINATIONS: PickerOption<string, { country: string }>[] = [
582
612
  { value: "dubai", label: "Dubai", data: { country: "United Arab Emirates" } },
583
613
  ];
584
614
 
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
615
 
714
616
  /** One stop on a route. `place` + when the movement is expected there; the ROLE
715
617
  * (origin, via, destination) is DERIVED from position, never stored — a stored
@@ -783,8 +685,7 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
783
685
  <View style={{ flexDirection: "row", paddingLeft: SEQUENCE_INSET }}>
784
686
  {/* A stop is added BEFORE the destination — a new leg is always in the
785
687
  middle of a journey; nobody adds one past the end. */}
786
- <TextLink
787
- size="sm"
688
+ <TextButton
788
689
  onPress={() =>
789
690
  onChange([
790
691
  ...stops.slice(0, -1),
@@ -794,7 +695,7 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
794
695
  }
795
696
  >
796
697
  Add stop
797
- </TextLink>
698
+ </TextButton>
798
699
  </View>
799
700
  </View>
800
701
  );
@@ -815,7 +716,7 @@ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onRemove }:
815
716
  return (
816
717
  <DetailRow label={role}>
817
718
  {rec ? (
818
- <ReferenceValue
719
+ <ReferenceField
819
720
  name={rec.name}
820
721
  code={rec.code}
821
722
  facts={[
@@ -838,7 +739,21 @@ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onRemove }:
838
739
  );
839
740
  }
840
741
 
841
- export function TplRecord() {
742
+ /** `page` (default) = the record's own screen: an outline rail in the left gutter
743
+ * and the discussion docked in the right. `drawer` = the same CONTENT inside a
744
+ * register's workspace `Drawer`, which owns its own header, close and sequencing.
745
+ *
746
+ * It is a MODE, not a second component, and that is the point: a register's
747
+ * drawer showing a COPY of this surface is how the two drifted apart the first
748
+ * time — a checklist here, a pipeline there. Rendering one component in two
749
+ * chromes makes them identical by construction.
750
+ *
751
+ * Most of the difference needs no flag: at a drawer's width `wide` is already
752
+ * false, so the rail, the panel and the mirrored gutters collapse on their own.
753
+ * What the flag decides is what a nested surface must NOT do — open comments in
754
+ * a Drawer inside a Drawer, or draw a back button over the one the Drawer has. */
755
+ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?: "page" | "drawer"; code?: string } = {}) {
756
+ const isDrawer = chrome === "drawer";
842
757
  const id = useRef(1);
843
758
  const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
844
759
  const { small } = useScreenSize();
@@ -870,9 +785,13 @@ export function TplRecord() {
870
785
  // ── the audit trail — real actions on this surface append to it. An entry
871
786
  // DRILLS DOWN via the Timeline `details` chevron: a field change carries its
872
787
  // From → To diff, a document entry the pressable file, an issue its amount.
873
- // ── the fees ledger — register + entity drawer (create-then-refine: Add
874
- // creates a blank fee and opens it; every field refines in the drawer)
788
+ // ── the fees ledger — a register whose rows EXPAND (create-then-refine: Add
789
+ // appends a blank fee already open; every field refines in the expansion)
875
790
  const [fees, setFees] = useState<Fee[]>(FEE_SEED);
791
+ // Which fee is expanded — ONE at a time, the caller's rule (`TableRow.expanded`
792
+ // is controlled precisely so this is a decision and not a default). Two open
793
+ // 380px details would push the third row off screen, and a register that can't
794
+ // be scanned has stopped being a register.
876
795
  const [feeView, setFeeView] = useState<{ kind: "edit"; id: string } | null>(null);
877
796
  const patchFee = (id: string, p: Partial<Fee>) => setFees((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
878
797
  const addFee = () => {
@@ -880,8 +799,6 @@ export function TplRecord() {
880
799
  setFees((prev) => [...prev, f]);
881
800
  setFeeView({ kind: "edit", id: f.id });
882
801
  };
883
- const openFee = feeView ? fees.find((f) => f.id === feeView.id) ?? null : null;
884
- const feeIdx = openFee ? fees.findIndex((f) => f.id === openFee.id) : -1;
885
802
  const deleteFee = (f: Fee) => {
886
803
  Alert.alert(`Remove ${f.name || "this fee"}?`, "The fee comes off the record's ledger. This can't be undone.", [
887
804
  { text: "Cancel", style: "cancel" },
@@ -896,7 +813,6 @@ export function TplRecord() {
896
813
  : { text: "Unpaid", danger: false };
897
814
 
898
815
  // ── the record — seeded mid-flight so every state is visible
899
- const code = "RC-2026-0418";
900
816
  const [stage, setStage] = useState<Stage>("sales");
901
817
  // The customer book is STATE: the attached customer's tax ID edits inline in
902
818
  // the Customer section (that's what un-gates invoicing).
@@ -1357,8 +1273,6 @@ export function TplRecord() {
1357
1273
  const [handoffTo, setHandoffTo] = useState<Desk>("operations");
1358
1274
  const [handoffAssignee, setHandoffAssignee] = useState<string | null>(null);
1359
1275
  const [handoffNote, setHandoffNote] = useState("");
1360
- // ── the customer detail drawer (the linked record's full info)
1361
- const [customerOpen, setCustomerOpen] = useState(false);
1362
1276
  // Confirm MARKS the handoff — whom, where, the note.
1363
1277
  //
1364
1278
  // It commits what the DIALOG captured (`handoffTo`), never what `stage` implies
@@ -1395,21 +1309,81 @@ export function TplRecord() {
1395
1309
  // GENERAL leads the rail — the item's core details (the customer among them,
1396
1310
  // since a reference is a field); everything after it is supplementary
1397
1311
  // (discussion, work, documents, money, lifecycle).
1312
+ //
1313
+ // THE SECTION NAV IS HAND-ROLLED ON PURPOSE, for now. `useSectionNav` is the kit
1314
+ // half — the scroll-spy, `activeKey`, `jumpTo`, unit-tested RN-free — and the
1315
+ // CHROME below is this template's. It has exactly one consumer, and the kit's bar
1316
+ // is the SECOND hand-roll, so lifting it today would ship an abstraction shaped by
1317
+ // a single surface. `ReferenceField` sat here under this same note until a
1318
+ // register's drawer needed it, and that is what let the extraction land on the
1319
+ // PEEK contract instead of on the layout.
1320
+ //
1321
+ // When a second surface wants this, the contract worth lifting is: one section
1322
+ // list, rendered as a RAIL where the container affords a gutter and as a pinned
1323
+ // CURRENT-SECTION bar where it does not, the switch reading the CONTAINER (never
1324
+ // the window); the picker anchored to the control that opens it, never a Modal
1325
+ // that escapes its container; and the per-section attention dot, which is what
1326
+ // makes the rail answer "where is the problem" and not merely "where am I".
1327
+ //
1328
+ // What is NOT the contract, and would poison it if lifted along: the issue-tone
1329
+ // MAP below (its keys are this record's domain), the comments toggle (drawer
1330
+ // chrome), the BackButton (page chrome), the gutter arithmetic (28 page / 20
1331
+ // drawer, minus the trigger's padding) and the `wide` threshold. One consumer
1332
+ // cannot tell those apart from the contract — two can.
1398
1333
  const SECTIONS = [
1399
1334
  { key: "general", label: "General", icon: "file-text" },
1335
+ // Files sits SECOND: what arrived on the record is read before what the
1336
+ // record is doing, and it puts the intake affordance near the top of the
1337
+ // scroll instead of eight sections down.
1338
+ { key: "files", label: "Files", icon: "folder-closed" },
1400
1339
  { key: "transport", label: "Transport", icon: "map-pin" },
1401
1340
  { key: "progress", label: "Progress", icon: "git-branch" },
1402
- { key: "documents", label: "Documents", icon: "folder-closed" },
1403
1341
  { key: "fees", label: "Fees", icon: "receipt" },
1404
1342
  { key: "billing", label: "Billing", icon: "credit-card" },
1405
- { key: "docset", label: "Document set", icon: "file-stack" },
1343
+ { key: "docset", label: "Documents", icon: "file-stack" },
1406
1344
  { key: "receipt", label: "Delivery receipt", icon: "log-in" },
1407
1345
  { key: "danger", label: "Danger zone", icon: "circle-alert" },
1408
1346
  ] as const;
1409
- const nav = useSectionNav(["general", "transport", "progress", "documents", "fees", "billing", "docset", "receipt", "danger"] as const);
1347
+ const nav = useSectionNav(["general", "files", "transport", "progress", "fees", "billing", "docset", "receipt", "danger"] as const);
1348
+ // ONE record, ONE page. A section is a place you SCROLL to, never a
1349
+ // destination you swap to: routing a record was tried here and lost, because
1350
+ // every fix it needed rebuilt the whole-record view in miniature — a dot to
1351
+ // say which hidden section changed, a header action to re-surface an
1352
+ // affordance a section had swallowed, an "all" route that is just this page.
1353
+ // The tax it charges is real and permanent (browser find reaches only the
1354
+ // open section, two sections can never be read together, a cross-section
1355
+ // check costs a click each way); the length it fixes is a DENSITY problem,
1356
+ // and density is cuttable. So: cut the sections, keep the page.
1357
+ type SecKey = (typeof SECTIONS)[number]["key"];
1358
+ const activeSection = nav.activeKey;
1359
+ // WHICH sections have something wrong — one map, read by the rail. Each section
1360
+ // still states the condition itself beside the fields it concerns; this only says
1361
+ // where to look, which is the job the removed banner was doing badly.
1362
+ // ONE clock per render. Two rows each calling `new Date()` can land on
1363
+ // opposite sides of midnight and disagree about what "today" is.
1364
+ const now = new Date();
1365
+ const words = useLoticsLocale();
1366
+ // The countdown for any date field on this record: "2 days overdue" / "Today"
1367
+ // / "5 days left", routed to the annotation slot its urgency earns
1368
+ // (error / warning / description) — @lotics/ui/deadline owns the wording and
1369
+ // the thresholds so this screen cannot disagree with the register beside it.
1370
+ const dueAnnotation = (iso: string, open: boolean) =>
1371
+ open && iso !== "" ? deadlineAnnotation(daysUntil(new Date(iso), now), words.deadline) : {};
1372
+ const overdue = stage !== "closed" && deliverBy !== "" && daysUntil(new Date(deliverBy), now) < 0;
1373
+ const sectionIssue: Partial<Record<SecKey, "warning" | "danger">> = {
1374
+ general: overdue ? "warning" : undefined,
1375
+ transport: scheduleNote !== "on-time" ? "warning" : undefined,
1376
+ billing: customer === null ? "warning" : undefined,
1377
+ };
1410
1378
  // Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
1411
1379
  // it opens a full-page section picker (Escape / the close control dismiss).
1412
1380
  const [sectionsOpen, setSectionsOpen] = useState(false);
1381
+ // The picker anchors to the BAR BUTTON, so it is scoped to whatever container
1382
+ // that button sits in — the page, or a drawer. A Modal escaped the container
1383
+ // and took the whole window, which inside a drawer covered the register behind
1384
+ // it. `triggerRef` (not `PopoverTrigger`) because the trigger IS a Button and a
1385
+ // button must not nest in another pressable.
1386
+ const sectionsAnchor = useRef<View>(null);
1413
1387
  const [pageWidth, setPageWidth] = useState<number | null>(null);
1414
1388
  // THREE COLUMNS: the outline rail in the left gutter, the record, and the
1415
1389
  // discussion panel in the right. The right gutter used to be empty — reserved
@@ -1421,6 +1395,11 @@ export function TplRecord() {
1421
1395
  // on the RIGHT, opposite the section picker, since it is about the record as a
1422
1396
  // whole rather than a place in it.
1423
1397
  const [commentsOpen, setCommentsOpen] = useState(false);
1398
+ // Drawer chrome switches the BODY between the record and the discussion. As a
1399
+ // section it competed with nine others for one scroll — first it pushed the
1400
+ // record down, last it sat 7,466px from the top. They are two things a reader
1401
+ // alternates between, so the bar names both and the body shows one.
1402
+ const [commentsView, setCommentsView] = useState(false);
1424
1403
  // The rail floats over its RESERVED gutter rather than sitting in the scroll
1425
1404
  // row, so the ScrollView keeps the FULL page width — wheel anywhere and the
1426
1405
  // record scrolls. (Capping the ScrollView at the column's width instead makes
@@ -1438,31 +1417,88 @@ export function TplRecord() {
1438
1417
  // Narrow: a pinned bar names the CURRENT section (the spy keeps it
1439
1418
  // honest while scrolling); tapping opens the full-page section picker —
1440
1419
  // no tab strip the thumb has to scroll.
1441
- <View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: 12, paddingVertical: 6, flexDirection: "row", alignItems: "center", gap: 8 }}>
1420
+ // 18, not 12: the bar's gutter is derived from the CONTENT's (28) minus the
1421
+ // trigger's own horizontal padding (10), so the section NAME starts on the
1422
+ // same left edge as every section title below it. A fill-less button shows
1423
+ // no box, so its ink is what the eye lines up — 18 + 10 = 28. Set the bar,
1424
+ // not a negative margin on the button: the gutter belongs to the bar, and
1425
+ // one number here cannot drift out of step with itself.
1426
+ <View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: (isDrawer ? 20 : 28) - 10, paddingVertical: 6, flexDirection: "row", alignItems: "center", gap: 8 }}>
1442
1427
  {/* narrow keeps the standard too — the bare back glyph beside the
1443
- section bar (the rail that would carry it is collapsed). */}
1444
- <BackButton onPress={() => {}} />
1445
- <Button
1446
- title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
1447
- color="secondary"
1448
- icon="list-collapse"
1449
- onPress={() => setSectionsOpen(true)}
1450
- />
1428
+ section bar (the rail that would carry it is collapsed). A DRAWER
1429
+ already has one in its own header; two would be two exits. */}
1430
+ {isDrawer ? null : <BackButton onPress={() => {}} />}
1431
+ <Popover open={sectionsOpen} onOpenChange={setSectionsOpen} triggerRef={sectionsAnchor} side="bottom" align="start">
1432
+ <View ref={sectionsAnchor}>
1433
+ {/* MUTED, not `secondary`: this names WHERE YOU ARE, so it is a
1434
+ position readout that happens to be pressable, and a filled chip
1435
+ competes with the record's own actions for the eye. The usual cost
1436
+ of a fill-less button does not apply here — it is chrome in a bar
1437
+ with its own gutter, not a verb aligned to a column of text, so
1438
+ there is nothing below it for its ink to line up with. The glyph
1439
+ carries the affordance: `list-collapse` says WHAT the list is,
1440
+ where `Switcher` uses a chevron for the same shape. */}
1441
+ <Button
1442
+ title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
1443
+ color="muted"
1444
+ icon="list-collapse"
1445
+ onPress={() => setSectionsOpen(true)}
1446
+ />
1447
+ </View>
1448
+ <PopoverContent style={{ width: 248 }} disableBodyScroll>
1449
+ <View style={{ gap: 2 }}>
1450
+ {SECTIONS.map((sec) => (
1451
+ <MenuButton
1452
+ key={sec.key}
1453
+ icon={sec.icon}
1454
+ title={sec.label}
1455
+ current={nav.activeKey === sec.key}
1456
+ onPress={() => {
1457
+ setSectionsOpen(false);
1458
+ // Leaving comments is part of jumping: in comments view the
1459
+ // sections are not rendered, so a jump with no exit was a
1460
+ // control that did nothing.
1461
+ setCommentsView(false);
1462
+ nav.jumpTo(sec.key);
1463
+ }}
1464
+ />
1465
+ ))}
1466
+ </View>
1467
+ </PopoverContent>
1468
+ </Popover>
1451
1469
  <View style={{ flex: 1 }} />
1452
1470
  {/* WHERE it sits carries the meaning: sections on the left (where you
1453
1471
  ARE), the discussion on the right (what is being SAID) — the same
1454
1472
  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
- />
1473
+ {/* A LABEL with its count, not a bare glyph: this is one of the two
1474
+ things the body can show, so it has to say which — and how much is
1475
+ waiting, which is what decides whether you look now. In drawer chrome
1476
+ it toggles the body; on a narrow page it still opens the sheet, which
1477
+ is that layout's panel. The pair reads as a switch: whichever side the
1478
+ body is showing wears the filled `secondary`. */}
1479
+ {isDrawer ? (
1480
+ <Button
1481
+ title={commentsView ? "Close comments" : `Comments (${comments.length})`}
1482
+ color="muted"
1483
+ icon="message-square"
1484
+ onPress={() => setCommentsView((v) => !v)}
1485
+ />
1486
+ ) : (
1487
+ <Button
1488
+ title={`Comments (${comments.length})`}
1489
+ color="muted"
1490
+ icon="message-square"
1491
+ onPress={() => setCommentsOpen(true)}
1492
+ />
1493
+ )}
1461
1494
  </View>
1462
1495
  )}
1463
1496
  <View style={{ flex: 1 }}>
1464
1497
  {/* FULL-WIDTH scroller: the whole page scrolls, wherever the pointer is. */}
1465
- <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: 28, paddingBottom: 96 }}>
1498
+ {/* The gutter is CHROME's, not the record's: 28 is the page canvas, 20 is what
1499
+ a `Drawer` pads its header with — so in a drawer the content lines up with
1500
+ the record NAME in the header above it instead of sitting 8px inside it. */}
1501
+ <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: isDrawer ? 20 : 28, paddingBottom: 96 }}>
1466
1502
  {/* The centred group: a gutter RESERVED for the rail, the reading column,
1467
1503
  and an equal empty gutter that balances it — so the column's centre is
1468
1504
  the window's centre. Both gutters live INSIDE the scroller, so they
@@ -1500,32 +1536,82 @@ export function TplRecord() {
1500
1536
  {/* the page column is a SectionStack — it owns the 56px beat + the
1501
1537
  hairline BETWEEN top-level blocks (a conditional block that renders
1502
1538
  null never leaves a stray divider) */}
1503
- <SectionStack style={{ flex: 1, minWidth: 0 }}>
1504
- {/* header band identity, attention, a light summary. No stage chip
1505
- here (Progress carries the desk and the act), no breadcrumb,
1506
- no create CTA: back lives in the panel, creation belongs to the
1507
- REGISTER (the list owns "new"), the record page only edits. */}
1508
- <View style={{ gap: 16 }}>
1509
- <RecordSummary
1510
- title={code}
1511
- subtitle={customer ? [customer.name, customer.city].filter(Boolean).join(", ") : "No customer"}
1539
+ {/* Routed shows ONE section, so there is nothing to separate — and the
1540
+ hairlines are owned by SectionStack, which draws one per non-null child.
1541
+ Hidden-but-mounted children still count, so leaving them on would stack a
1542
+ divider above the section for every section before it. */}
1543
+ {/* The header band sits ABOVE the stack, on the canvas beat it is the
1544
+ record's IDENTITY, not a section, so the 56px between-sections rhythm
1545
+ (and, in scroll mode, the hairline that comes with it) does not apply.
1546
+ Every condition it used to announce now lives on the field it concerns,
1547
+ with a dot on the rail saying which section to look in. */}
1548
+ <View style={{ flex: 1, minWidth: 0, gap: 28 }}>
1549
+ {/* The record's own ACTS live here, beside its identity — not inside a
1550
+ section. Dropping a file is something you do TO THE RECORD, but its
1551
+ only affordance used to sit in the Files section, which scoped the
1552
+ discoverability to that section: from anywhere else on the page
1553
+ (or from 3,975px above it) nothing said the record took files at
1554
+ all, and a whole-record drop target nobody can see is an
1555
+ accelerator, never an entry point. The Files register keeps its own
1556
+ Add — a register's Add belongs under its register — this is the
1557
+ record-level way in. A template composition for now; it earns a kit
1558
+ slot on RecordSummary at its second consumer. */}
1559
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
1560
+ <View style={{ flex: 1, minWidth: 0 }}>
1561
+ <RecordSummary
1562
+ title={code}
1563
+ subtitle={customer ? [customer.name, customer.city].filter(Boolean).join(", ") : "No customer"}
1564
+ />
1565
+ </View>
1566
+ </View>
1567
+ {isDrawer && commentsView ? (
1568
+ <View style={{ gap: 28 }}>
1569
+ <Section>
1570
+ <SectionHeading>
1571
+ <SectionHeadingTitle description="Notes on this record — everyone with access sees them.">Comments</SectionHeadingTitle>
1572
+ <SectionHeadingMeta>{`${comments.length} ${comments.length === 1 ? "comment" : "comments"}`}</SectionHeadingMeta>
1573
+ </SectionHeading>
1574
+ <CommentList
1575
+ comments={comments}
1576
+ currentMemberId="mem_01"
1577
+ resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
1578
+ onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
1579
+ onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
1580
+ /* the file-capable edit form — the same injection the product uses */
1581
+ renderEditForm={(p) => <CommentFileEditForm {...p} />}
1582
+ /* attachments render as the SAME thumbnail grid the product uses —
1583
+ press previews in the gallery (the desk's preview state) */
1584
+ renderFiles={(files) => (
1585
+ <FileGrid
1586
+ files={files.map(commentFileToDisplay)}
1587
+ itemSize={84}
1588
+ onFilePress={(f) => setPreview({ files: [f], index: 0 })}
1589
+ />
1590
+ )}
1591
+ /* compact timestamp — the product formats RELATIVE ("2 days ago",
1592
+ date-fns); a template stays dependency-free and deterministic */
1593
+ 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" })}`; }}
1512
1594
  />
1513
- {/* the record's ATTENTION state, on top record-scoped, so it reads
1514
- as a Callout here (field-scoped state stays on its field) */}
1515
- {stage !== "closed" && deliverBy !== "" && new Date(deliverBy) < new Date() ? (
1516
- <Callout tone="warning">
1517
- <CalloutText>Overdue the due date has passed. Chase the delivery or move the date.</CalloutText>
1518
- </Callout>
1519
- ) : null}
1520
- {/* the light summary — what's on this record, one quiet line */}
1521
- <SummaryLine
1522
- items={[
1523
- { label: "desk", value: stageOf(stage).label, format: "none" },
1524
- { label: "documents", value: files.length },
1525
- { label: "to collect", value: grandTotal, format: "currency", compact: true },
1526
- ]}
1595
+ {/* The pane the toggle switches TO has to be the WHOLE discussion, not
1596
+ half of it: reading without a reply is a dead end, and this is the
1597
+ only surface where the thread has no panel or sheet to fall back on. */}
1598
+ <Composer
1599
+ onSend={(content) => {
1600
+ 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() }]);
1601
+ setPendingFiles([]);
1602
+ }}
1603
+ placeholder="Write a comment…"
1604
+ sendLabel="Send"
1605
+ actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
1606
+ files={pendingFiles.length > 0 ? (
1607
+ <FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
1608
+ ) : undefined}
1527
1609
  />
1528
- </View>
1610
+ </Section>
1611
+ </View>
1612
+ ) : (
1613
+ <SectionStack>
1614
+
1529
1615
 
1530
1616
  {/* GENERAL — the MAIN details, FIRST section: the core facts of the
1531
1617
  item; every other section is supplementary. The body is a
@@ -1536,6 +1622,8 @@ export function TplRecord() {
1536
1622
  the grid stays aligned through the groups; read-only values are
1537
1623
  InlineStatic in the same column, aligned to the pixel. */}
1538
1624
  <View onLayout={nav.register("general")}>
1625
+ {/* the record's own overdue state — co-located with the Due field it reads,
1626
+ never as a banner above every section */}
1539
1627
  <Section>
1540
1628
  <SectionHeading>
1541
1629
  <SectionHeadingTitle description="The core facts of the order — what every desk reads first.">General</SectionHeadingTitle>
@@ -1552,7 +1640,7 @@ export function TplRecord() {
1552
1640
  its picker, and its annotation. So it sits among the order's
1553
1641
  other core facts, where a reader looks for the bill-to party.
1554
1642
 
1555
- The value is a `ReferenceValue` (above): the inline editor's
1643
+ The value is a `ReferenceField` (the kit primitive): the inline editor's
1556
1644
  own resting surface, so the reference sits in the value column
1557
1645
  like every editor around it rather than as a card that
1558
1646
  outweighs the fields it sits among. Its three verbs are three
@@ -1564,7 +1652,7 @@ export function TplRecord() {
1564
1652
  warning={customer && !taxIdValid ? "No valid tax ID — invoicing stays blocked until this customer carries one." : undefined}
1565
1653
  >
1566
1654
  {customer ? (
1567
- <ReferenceValue
1655
+ <ReferenceField
1568
1656
  name={customer.name}
1569
1657
  code={customer.code}
1570
1658
  facts={[
@@ -1574,7 +1662,13 @@ export function TplRecord() {
1574
1662
  ]}
1575
1663
  accessibilityLabel={`${customer.name} — details`}
1576
1664
  openLabel="Open customer"
1577
- onOpen={() => setCustomerOpen(true)}
1665
+ /* A real app navigates to the customer's OWN record page —
1666
+ same as Ship to / Notify / Carrier below. This used to
1667
+ open a local drawer that re-showed the five facts the
1668
+ peek had just shown, so "Open" resolved to "read that
1669
+ again, wider" and the customer's record was unreachable
1670
+ from the one control that promised it. */
1671
+ onOpen={() => {}}
1578
1672
  onRemove={() => setCustomerId(null)}
1579
1673
  />
1580
1674
  ) : (
@@ -1618,7 +1712,11 @@ export function TplRecord() {
1618
1712
  <DetailRow label="Opened">
1619
1713
  <InlineDatePicker value={orderDate} onSave={persist(setOrderDate)} locale="en-US" accessibilityLabel="Opened date" />
1620
1714
  </DetailRow>
1621
- <DetailRow label="Due">
1715
+ {/* The countdown ANNOTATES the date it is about — the worked
1716
+ example for @lotics/ui/deadline. It replaced a hand-rolled
1717
+ `error={overdue ? … }`, which was binary: a date due
1718
+ tomorrow read exactly like one due in three months. */}
1719
+ <DetailRow label="Due" {...dueAnnotation(deliverBy, stage !== "closed")}>
1622
1720
  <InlineDatePicker value={deliverBy} onSave={persist(setDeliverBy)} locale="en-US" accessibilityLabel="Due date" placeholder="Set a due date…" />
1623
1721
  </DetailRow>
1624
1722
  {/* a LONG description — it wraps in the value column, the
@@ -1703,8 +1801,8 @@ export function TplRecord() {
1703
1801
  </Subsection>
1704
1802
  {/* CLASSIFICATION — the right-input-per-field law, worked. A field
1705
1803
  gets the control its SHAPE wants, not a default text box:
1706
- , ≤5 exclusive options the user should SEE RadioPicker
1707
- (a stacked column of full-width choices)
1804
+ , 2-3 SHORT options switched often ChipGroup (every choice
1805
+ visible, switching is ONE press, still one row)
1708
1806
  , a longer closed list → InlineSelect (rich options carry a
1709
1807
  description line — it shows in the resting row too)
1710
1808
  , a pick from a big REGISTRY → InlineSelect searchable (the
@@ -1741,28 +1839,26 @@ export function TplRecord() {
1741
1839
  />
1742
1840
  </DetailRow>
1743
1841
  <DetailRow label="Fulfilment status">
1744
- <RadioPicker
1745
- accessibilityLabel="Fulfilment status"
1842
+ <InlineSelect
1746
1843
  value={fulfilmentStatus}
1747
- onValueChange={setFulfilmentStatus}
1748
- options={[
1749
- { value: "ready", label: "Ready at the warehouse" },
1750
- { value: "production", label: "In production", description: "Confirmed ready by the ship date." },
1751
- { value: "awaiting", label: "Awaiting inbound supply" },
1752
- { value: "partial", label: "Partial", description: "Ships in two lots — the rest follows on the next run." },
1753
- ]}
1844
+ options={FULFILMENT_STATUSES}
1845
+ onSave={persist(setFulfilmentStatus)}
1846
+ accessibilityLabel="Fulfilment status"
1847
+ renderOptionContent={(o) => (
1848
+ <View style={{ gap: 1, flexShrink: 1 }}>
1849
+ <Text size="sm">{o.label}</Text>
1850
+ {o.data?.hint ? <Text size="xs" color="muted">{o.data.hint}</Text> : null}
1851
+ </View>
1852
+ )}
1853
+ renderSelected={(o) => <Text size="sm">{o.label}</Text>}
1754
1854
  />
1755
1855
  </DetailRow>
1756
1856
  <DetailRow label="Insurance">
1757
- <RadioPicker
1857
+ <ChipGroup
1758
1858
  accessibilityLabel="Insurance"
1759
1859
  value={insurance}
1760
1860
  onValueChange={setInsurance}
1761
- options={[
1762
- { value: "none", label: "None" },
1763
- { value: "standard", label: "Standard" },
1764
- { value: "all_risk", label: "All-risk" },
1765
- ]}
1861
+ options={INSURANCE_LEVELS}
1766
1862
  />
1767
1863
  </DetailRow>
1768
1864
  {insurance !== "none" ? (
@@ -1814,21 +1910,87 @@ export function TplRecord() {
1814
1910
  ) : null}
1815
1911
  </DetailTable>
1816
1912
  </Subsection>
1817
- {/* system-managed identifiers get a HOME, not a per-row badge */}
1818
- <Subsection>
1819
- <SubsectionHeading>
1820
- <SubsectionHeadingTitle>System</SubsectionHeadingTitle>
1821
- </SubsectionHeading>
1822
- <DetailTable labelWidth={150}>
1823
- <DetailRow label="Record ID">
1824
- <InlineStatic value={code} tabular />
1825
- </DetailRow>
1826
- </DetailTable>
1827
- </Subsection>
1913
+ {/* No System group. It held ONE row "Record ID", printing the
1914
+ value already set in xxl semibold at the top of the page — and
1915
+ spent a subsection heading plus 193px restating the title. A
1916
+ system identifier the record is NAMED BY needs no field: the
1917
+ identity band IS its display, and copying it belongs to that
1918
+ band, not to a row 1,400px below. Genuine system metadata
1919
+ (created, last updated) is audit data and reads in the
1920
+ Activity timeline, where it has a time axis to sit on. */}
1828
1921
  </SubsectionStack>
1829
1922
  </Section>
1830
1923
  </View>
1831
1924
 
1925
+ {/* DOCUMENTS — the document desk: the register Table (search, Create
1926
+ documents, Add files) whose selection feeds the Use-AI fork below.
1927
+ No FileDropTarget of its OWN — the WHOLE-RECORD target above already
1928
+ captures a drop/paste here (and everywhere else on the surface); this
1929
+ section keeps only its explicit Add-files CTA and the affordance line. */}
1930
+ <View onLayout={nav.register("files")}>
1931
+ <Section>
1932
+ <SectionHeading>
1933
+ {/* The standard files-section affordance line — drag / paste / click
1934
+ stay discoverable even though the drop target is the whole record. */}
1935
+ <SectionHeadingTitle description="Everything that ARRIVED on this record. Drag, paste, or click to add.">Files</SectionHeadingTitle>
1936
+ </SectionHeading>
1937
+ <Table
1938
+ columns={DOC_COLUMNS}
1939
+ leading={24}
1940
+ trailing={44}
1941
+ sort={docSort}
1942
+ onSort={(key) => setDocSort(cycleSort(docSort, key))}
1943
+ selectAll={
1944
+ <CheckboxInput
1945
+ accessibilityLabel="Select all documents"
1946
+ checked={sel.allSelected(visibleFiles.map((f) => f.id))}
1947
+ indeterminate={sel.indeterminate(visibleFiles.map((f) => f.id))}
1948
+ onChange={(on) => sel.setAll(visibleFiles.map((f) => f.id), on)}
1949
+ />
1950
+ }
1951
+ >
1952
+ {visibleFiles.map((f, fi) => (
1953
+ <TableRow
1954
+ key={f.id}
1955
+ onPress={() => openPreview(visibleFiles, fi)}
1956
+ marked={sel.has(f.id)}
1957
+ accessibilityLabel={`Open ${f.name}`}
1958
+ leading={<CheckboxInput accessibilityLabel={`Select ${f.name}`} checked={sel.has(f.id)} onChange={(on) => sel.toggle(f.id, on)} />}
1959
+ trailing={<ActionMenu items={fileMenuFor(f)} accessibilityLabel={`Actions for ${f.name}`} />}
1960
+ >
1961
+ <TableCell>
1962
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
1963
+ {/* ONE square 32px slot for every type: an image fills it as a
1964
+ real thumbnail, a document centers its badge in it */}
1965
+ <FileThumbnail file={toDisplay(f)} size={32} onPress={() => openPreview(visibleFiles, fi)} />
1966
+ <Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{f.name}</Text>
1967
+ </View>
1968
+ </TableCell>
1969
+ <TableCell>
1970
+ <Text size="sm" tabular>{fmtSize(f.sizeKB)}</Text>
1971
+ </TableCell>
1972
+ <TableCell>
1973
+ <Text size="sm" tabular>{f.added}</Text>
1974
+ </TableCell>
1975
+ </TableRow>
1976
+ ))}
1977
+ </Table>
1978
+ {/* the Add, under the register it extends — see the Fees section for
1979
+ why it is not in the heading. PENDING until the user chooses:
1980
+ saving is a decision the dialog asks for (save only / run a task),
1981
+ never a side effect of picking — closing the dialog discards. */}
1982
+ <View style={{ flexDirection: "row" }}>
1983
+ <Button
1984
+ title="Add files"
1985
+ color="primary"
1986
+ onPress={() => {
1987
+ void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
1988
+ }}
1989
+ />
1990
+ </View>
1991
+ </Section>
1992
+ </View>
1993
+
1832
1994
  {/* TRANSPORT — the DENSE section, and the honest one. Two groups of ~10
1833
1995
  fields, three record references, mixed editor types, a derived
1834
1996
  warning, one "Show more".
@@ -2059,81 +2221,14 @@ export function TplRecord() {
2059
2221
  </Section>
2060
2222
  </View>
2061
2223
 
2062
- {/* DOCUMENTS — the document desk: the register Table (search, Create
2063
- documents, Add files) whose selection feeds the Use-AI fork below.
2064
- No FileDropTarget of its OWN — the WHOLE-RECORD target above already
2065
- captures a drop/paste here (and everywhere else on the surface); this
2066
- section keeps only its explicit Add-files CTA and the affordance line. */}
2067
- <View onLayout={nav.register("documents")}>
2068
- <Section>
2069
- <SectionHeading>
2070
- {/* The standard files-section affordance line — drag / paste / click
2071
- stay discoverable even though the drop target is the whole record. */}
2072
- <SectionHeadingTitle description="Drag, paste, or click to add files">Documents</SectionHeadingTitle>
2073
- </SectionHeading>
2074
- <Table
2075
- columns={DOC_COLUMNS}
2076
- leading={24}
2077
- trailing={44}
2078
- sort={docSort}
2079
- onSort={(key) => setDocSort(cycleSort(docSort, key))}
2080
- selectAll={
2081
- <CheckboxInput
2082
- accessibilityLabel="Select all documents"
2083
- checked={sel.allSelected(visibleFiles.map((f) => f.id))}
2084
- indeterminate={sel.indeterminate(visibleFiles.map((f) => f.id))}
2085
- onChange={(on) => sel.setAll(visibleFiles.map((f) => f.id), on)}
2086
- />
2087
- }
2088
- >
2089
- {visibleFiles.map((f, fi) => (
2090
- <TableRow
2091
- key={f.id}
2092
- onPress={() => openPreview(visibleFiles, fi)}
2093
- marked={sel.has(f.id)}
2094
- accessibilityLabel={`Open ${f.name}`}
2095
- leading={<CheckboxInput accessibilityLabel={`Select ${f.name}`} checked={sel.has(f.id)} onChange={(on) => sel.toggle(f.id, on)} />}
2096
- trailing={<ActionMenu items={fileMenuFor(f)} accessibilityLabel={`Actions for ${f.name}`} />}
2097
- >
2098
- <TableCell>
2099
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
2100
- {/* ONE square 32px slot for every type: an image fills it as a
2101
- real thumbnail, a document centers its badge in it */}
2102
- <FileThumbnail file={toDisplay(f)} size={32} onPress={() => openPreview(visibleFiles, fi)} />
2103
- <Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{f.name}</Text>
2104
- </View>
2105
- </TableCell>
2106
- <TableCell>
2107
- <Text size="sm" tabular>{fmtSize(f.sizeKB)}</Text>
2108
- </TableCell>
2109
- <TableCell>
2110
- <Text size="sm" tabular>{f.added}</Text>
2111
- </TableCell>
2112
- </TableRow>
2113
- ))}
2114
- </Table>
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>
2128
- </Section>
2129
- </View>
2130
2224
 
2131
2225
  {/* FEES — the DETAILED money ledger, both directions (charge = billed
2132
2226
  to the customer, cost = paid to a vendor), distinct from Billing's
2133
- invoice DOCUMENTS. The drill-down law: a compact register row, and
2134
- EVERY row opens its FULL detail in a right-docked entity drawer
2135
- (◀ steps the rows). Add is create-then-refine a blank fee
2136
- opens in the drawer. */}
2227
+ invoice DOCUMENTS. The drill-down law for a child collection: a
2228
+ compact register row, and EVERY row EXPANDS to its full detail in
2229
+ place one at a time, no drawer, so the surrounding rows stay
2230
+ readable and coming back costs nothing. Add is create-then-refine —
2231
+ a blank fee appends already open, where it will live. */}
2137
2232
  <View onLayout={nav.register("fees")}>
2138
2233
  <Section>
2139
2234
  <SectionHeading>
@@ -2147,13 +2242,114 @@ export function TplRecord() {
2147
2242
  ]}
2148
2243
  />
2149
2244
  {fees.length === 0 ? (
2150
- <EmptyState icon="receipt" message="No fees on this order" hint="Add the first charge or cost — it opens ready to fill in." action={<Button title="Add fee" color="primary" onPress={addFee} />} />
2245
+ <EmptyState icon="receipt" message="No fees on this order" hint="Add the first charge or cost — it expands ready to fill in." action={<Button title="Add fee" color="primary" onPress={addFee} />} />
2151
2246
  ) : (
2152
2247
  <Table columns={FEE_COLUMNS}>
2153
2248
  {fees.map((f) => {
2154
2249
  const st = feeStatus(f);
2155
2250
  return (
2156
- <TableRow key={f.id} onPress={() => setFeeView({ kind: "edit", id: f.id })} selected={feeView?.id === f.id} accessibilityLabel={`Open ${f.name || "fee"}`}>
2251
+ <TableRow
2252
+ key={f.id}
2253
+ /* TOGGLE, unlike the peek's open-never-toggle: a popover
2254
+ fights its own outside-press dismissal, an expansion has
2255
+ none, so the row that opened it must also close it. */
2256
+ onPress={() => setFeeView((cur) => (cur?.id === f.id ? null : { kind: "edit", id: f.id }))}
2257
+ expanded={feeView?.id === f.id}
2258
+ /* `— details`, the peek's convention: the door is a disclosure,
2259
+ not a trip, so "Open" would name the wrong act. `PressDoor`
2260
+ carries the state as aria-expanded. */
2261
+ accessibilityLabel={`${f.name || "New fee"} — details`}
2262
+ /* NO `selected`: `TableRow` already derives it —
2263
+ `selected={selected || showDetail}` — so the expanded row
2264
+ wears the wash without being told, and passing it here
2265
+ would only restate the kit. */
2266
+ detail={
2267
+ /* The detail carries ALL NINE fields, including the five the
2268
+ row already shows — because the row's cells are read-only
2269
+ Text, and because tier 2 DROPS Party and Type on a narrow
2270
+ container. `table_fit` licenses that drop on the promise
2271
+ that "the door opens the record where dropped values
2272
+ live"; the expansion is now that door, so anything it
2273
+ omitted would be unreachable, not merely inconvenient.
2274
+ Table does not expose its fit result, so this cannot be
2275
+ conditional — nor should it be. */
2276
+ /* 24 (`SPACE.lg`), not the 12 this file uses for intra-block
2277
+ gaps: the field grid's own row gap is `SPACE.md` (16), so
2278
+ anything separating a DIFFERENT block has to beat it or
2279
+ the verb below reads as one more field. Same rung logic
2280
+ `DetailRow` uses for its stacked rows. */
2281
+ <View style={{ gap: 24 }}>
2282
+ {/* labelWidth 130 (the kit default) is NOT the page's 150,
2283
+ and must not be "fixed" to it. The page's invariant is
2284
+ one pixel-aligned VALUE column: General sits at x=0 and
2285
+ puts values at 0+150, this detail is inset by
2286
+ ROW_GUTTER and puts them at 20+130 — the same 150. At
2287
+ 150 they would land at 170 and break the column. In an
2288
+ inset field grid the label width is 150 − inset. */}
2289
+ <DetailTable labelWidth={130}>
2290
+ <DetailRow label="Fee">
2291
+ <InlineTextInput value={f.name} onSave={persist((v: string) => patchFee(f.id, { name: v }))} placeholder="Name the fee…" accessibilityLabel="Fee name" />
2292
+ </DetailRow>
2293
+ <DetailRow label="Type" description="Charge — billed to the customer; Cost — paid to a vendor">
2294
+ <InlineSelect
2295
+ value={f.direction}
2296
+ options={[{ value: "charge", label: "Charge" }, { value: "cost", label: "Cost" }]}
2297
+ onSave={persist((v: FeeDirection) => patchFee(f.id, { direction: v }))}
2298
+ accessibilityLabel="Fee type"
2299
+ />
2300
+ </DetailRow>
2301
+ <DetailRow label="Party">
2302
+ <InlineTextInput value={f.party} onSave={persist((v: string) => patchFee(f.id, { party: v }))} placeholder="Who pays, or is paid…" accessibilityLabel="Party" />
2303
+ </DetailRow>
2304
+ <DetailRow label="Amount">
2305
+ <InlineNumberInput value={f.amount || null} onSave={persist((v: number | null) => patchFee(f.id, { amount: v ?? 0 }))} min={0} format={money} placeholder="—" accessibilityLabel="Amount" />
2306
+ </DetailRow>
2307
+ <DetailRow label="VAT (%)">
2308
+ <InlineNumberInput value={f.vat} onSave={persist((v: number | null) => patchFee(f.id, { vat: v }))} min={0} placeholder="—" accessibilityLabel="VAT percent" />
2309
+ </DetailRow>
2310
+ <DetailRow label="Due" {...dueAnnotation(f.due, !f.paid)}>
2311
+ <InlineDatePicker value={f.due} onSave={persist((v: string) => patchFee(f.id, { due: v }))} locale="en-US" placeholder="Set a due date…" accessibilityLabel="Fee due date" />
2312
+ </DetailRow>
2313
+ <DetailRow label="Invoice no" description="The party's invoice or debit note">
2314
+ <InlineTextInput value={f.invoiceNo} onSave={persist((v: string) => patchFee(f.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
2315
+ </DetailRow>
2316
+ <DetailRow label="Status">
2317
+ <InlineSelect
2318
+ value={f.paid ? "paid" : "unpaid"}
2319
+ options={[{ value: "unpaid", label: "Unpaid" }, { value: "paid", label: "Paid" }]}
2320
+ onSave={persist((v: string) => patchFee(f.id, { paid: v === "paid" }))}
2321
+ accessibilityLabel="Paid status"
2322
+ />
2323
+ </DetailRow>
2324
+ <DetailRow label="Note">
2325
+ <InlineTextInput value={f.note} onSave={persist((v: string) => patchFee(f.id, { note: v }))} placeholder="Add a note…" accessibilityLabel="Fee note" />
2326
+ </DetailRow>
2327
+ </DetailTable>
2328
+ {/* The destructive verb, LEFT — the peek footer's
2329
+ convention, which is the kit's. It replaces the
2330
+ DrawerFooter that used to pin it.
2331
+
2332
+ `TextButton`, same as the peek's detach: a text-weight
2333
+ destructive act aligned to a column of text. It lands its
2334
+ ink ON the label column by construction, keeps a 40px
2335
+ target via hitSlop, and drops the trash glyph — "Remove
2336
+ fee" already names the object, which is the safeguard the
2337
+ icon was only decorating.
2338
+
2339
+ NO Divider. Every hairline a `Table` draws is
2340
+ full-bleed (the row `Divider`s carry no padding, the
2341
+ header band's border spans its whole box), so one
2342
+ inset by ROW_GUTTER puts two hairline lengths in the
2343
+ same vertical run — and it would be saying twice what
2344
+ the expanded row's own wash already says. The row
2345
+ wrapper is required, not decoration: a `Button` alone
2346
+ in a column View stretches to full width. */}
2347
+ <View style={{ flexDirection: "row" }}>
2348
+ <TextButton color="danger" onPress={() => deleteFee(f)}>Remove fee</TextButton>
2349
+ </View>
2350
+ </View>
2351
+ }
2352
+ >
2157
2353
  <TableCell><Text size="sm" weight="medium" numberOfLines={1}>{f.name || "—"}</Text></TableCell>
2158
2354
  <TableCell><Text size="sm" color="muted">{f.direction === "charge" ? "Charge" : "Cost"}</Text></TableCell>
2159
2355
  <TableCell><Text size="sm" color="muted" numberOfLines={1}>{f.party || "—"}</Text></TableCell>
@@ -2334,7 +2530,7 @@ export function TplRecord() {
2334
2530
  <View onLayout={nav.register("docset")}>
2335
2531
  <Section>
2336
2532
  <SectionHeading>
2337
- <SectionHeadingTitle description="The paperwork this record produces — pick each party's forms, generate on demand.">Document set</SectionHeadingTitle>
2533
+ <SectionHeadingTitle description="The paperwork this record PRODUCES — pick each party's forms, generate on demand.">Documents</SectionHeadingTitle>
2338
2534
  </SectionHeading>
2339
2535
  <SubsectionStack>
2340
2536
  {FORM_GROUPS.map((g) => {
@@ -2580,33 +2776,14 @@ export function TplRecord() {
2580
2776
  </DangerZone>
2581
2777
  </Section>
2582
2778
  </View>
2779
+
2583
2780
  </SectionStack>
2781
+ )}
2782
+ </View>
2584
2783
  </FileDropTarget>
2585
2784
  </>
2586
2785
  )}
2587
2786
 
2588
- {/* the narrow-mode section picker — a full-page takeover: pick a section
2589
- to jump there; Escape or the close control dismisses */}
2590
- <Modal open={sectionsOpen} onClose={() => setSectionsOpen(false)}>
2591
- <ModalHeader title="Sections" onClose={() => setSectionsOpen(false)} />
2592
- <ModalBody>
2593
- <View style={{ gap: 2 }}>
2594
- {SECTIONS.map((sec) => (
2595
- <MenuButton
2596
- key={sec.key}
2597
- icon={sec.icon}
2598
- title={sec.label}
2599
- current={nav.activeKey === sec.key}
2600
- onPress={() => {
2601
- setSectionsOpen(false);
2602
- nav.jumpTo(sec.key);
2603
- }}
2604
- />
2605
- ))}
2606
- </View>
2607
- </ModalBody>
2608
- </Modal>
2609
-
2610
2787
  {/* find-or-create's CREATE branch — a focused Dialog (4 fields is
2611
2788
  dialog-weight; an inline swap shifts the page). Essentials only: the
2612
2789
  dialog's Fetch fills contact + city off the tax ID. */}
@@ -2711,93 +2888,6 @@ export function TplRecord() {
2711
2888
  </DialogFooter>
2712
2889
  </Dialog>
2713
2890
 
2714
- {/* the fee ENTITY DRAWER — the drill-down law: a register row opens its
2715
- full detail right-docked; ◀ ▶ + the position caption step the rows
2716
- without closing. Every field refines inline; Remove is confirmed. */}
2717
- <Drawer
2718
- open={openFee !== null}
2719
- onOpenChange={(o) => { if (!o) setFeeView(null); }}
2720
- title={openFee?.name || "New fee"}
2721
- width={480}
2722
- onPrev={feeIdx > 0 ? () => setFeeView({ kind: "edit", id: fees[feeIdx - 1].id }) : undefined}
2723
- onNext={feeIdx >= 0 && feeIdx < fees.length - 1 ? () => setFeeView({ kind: "edit", id: fees[feeIdx + 1].id }) : undefined}
2724
- position={feeIdx >= 0 ? `${feeIdx + 1}/${fees.length}` : undefined}
2725
- >
2726
- {openFee ? (
2727
- <View style={{ padding: 20, gap: 8 }}>
2728
- <DetailTable labelWidth={130}>
2729
- <DetailRow label="Fee">
2730
- <InlineTextInput value={openFee.name} onSave={persist((v: string) => patchFee(openFee.id, { name: v }))} placeholder="Name the fee…" accessibilityLabel="Fee name" />
2731
- </DetailRow>
2732
- <DetailRow label="Type" description="Charge — billed to the customer; Cost — paid to a vendor">
2733
- <InlineSelect
2734
- value={openFee.direction}
2735
- options={[{ value: "charge", label: "Charge" }, { value: "cost", label: "Cost" }]}
2736
- onSave={persist((v: FeeDirection) => patchFee(openFee.id, { direction: v }))}
2737
- accessibilityLabel="Fee type"
2738
- />
2739
- </DetailRow>
2740
- <DetailRow label="Party">
2741
- <InlineTextInput value={openFee.party} onSave={persist((v: string) => patchFee(openFee.id, { party: v }))} placeholder="Who pays, or is paid…" accessibilityLabel="Party" />
2742
- </DetailRow>
2743
- <DetailRow label="Amount">
2744
- <InlineNumberInput value={openFee.amount || null} onSave={persist((v: number | null) => patchFee(openFee.id, { amount: v ?? 0 }))} min={0} format={money} placeholder="—" accessibilityLabel="Amount" />
2745
- </DetailRow>
2746
- <DetailRow label="VAT (%)">
2747
- <InlineNumberInput value={openFee.vat} onSave={persist((v: number | null) => patchFee(openFee.id, { vat: v }))} min={0} placeholder="—" accessibilityLabel="VAT percent" />
2748
- </DetailRow>
2749
- <DetailRow label="Due" error={feeOverdue(openFee) ? "Overdue — chase the payment or move the date." : undefined}>
2750
- <InlineDatePicker value={openFee.due} onSave={persist((v: string) => patchFee(openFee.id, { due: v }))} locale="en-US" placeholder="Set a due date…" accessibilityLabel="Fee due date" />
2751
- </DetailRow>
2752
- <DetailRow label="Invoice no" description="The party's invoice or debit note">
2753
- <InlineTextInput value={openFee.invoiceNo} onSave={persist((v: string) => patchFee(openFee.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
2754
- </DetailRow>
2755
- <DetailRow label="Status">
2756
- <InlineSelect
2757
- value={openFee.paid ? "paid" : "unpaid"}
2758
- options={[{ value: "unpaid", label: "Unpaid" }, { value: "paid", label: "Paid" }]}
2759
- onSave={persist((v: string) => patchFee(openFee.id, { paid: v === "paid" }))}
2760
- accessibilityLabel="Paid status"
2761
- />
2762
- </DetailRow>
2763
- <DetailRow label="Note">
2764
- <InlineTextInput value={openFee.note} onSave={persist((v: string) => patchFee(openFee.id, { note: v }))} placeholder="Add a note…" accessibilityLabel="Fee note" />
2765
- </DetailRow>
2766
- </DetailTable>
2767
- </View>
2768
- ) : (
2769
- <View />
2770
- )}
2771
- {openFee ? (
2772
- <DrawerFooter>
2773
- <Button title="Remove fee" color="danger-secondary" icon="trash" onPress={() => deleteFee(openFee)} />
2774
- </DrawerFooter>
2775
- ) : null}
2776
- </Drawer>
2777
-
2778
- {/* the CUSTOMER detail drawer — the linked record's full info, read-only
2779
- (it's ANOTHER record; editing happens on its own page). The footer
2780
- carries the record-level verbs: navigate there, or detach. */}
2781
- <Drawer open={customerOpen && customer !== null} onOpenChange={(o) => { if (!o) setCustomerOpen(false); }} title={customer?.name ?? ""} width={440}>
2782
- {customer ? (
2783
- <View style={{ padding: 20 }}>
2784
- <DetailTable labelWidth={110}>
2785
- <DetailRow label="Name"><InlineStatic value={customer.name} /></DetailRow>
2786
- <DetailRow label="Code"><InlineStatic value={customer.code} tabular /></DetailRow>
2787
- <DetailRow label="Tax ID"><InlineStatic value={customer.taxId || "—"} tabular /></DetailRow>
2788
- <DetailRow label="Contact"><InlineStatic value={customer.contact || "—"} /></DetailRow>
2789
- <DetailRow label="City"><InlineStatic value={customer.city || "—"} /></DetailRow>
2790
- </DetailTable>
2791
- </View>
2792
- ) : (
2793
- <View />
2794
- )}
2795
- <DrawerFooter>
2796
- <Button title="Remove from record" color="danger-secondary" icon="x" onPress={() => { setCustomerOpen(false); setCustomerId(null); }} />
2797
- {/* a real app navigates to the customer's own record page */}
2798
- <Button title="Open record" color="secondary" onPress={() => {}} />
2799
- </DrawerFooter>
2800
- </Drawer>
2801
2891
 
2802
2892
  {/* the handoff dialog — who receives the record + a note; the rows name
2803
2893
  exactly WHAT is being handed off. Confirm writes the trail entry. */}
@@ -2825,8 +2915,10 @@ export function TplRecord() {
2825
2915
  </ScrollView>
2826
2916
 
2827
2917
  {/* 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}>
2918
+ right-docked Drawer off the bar's own comments control. Never in drawer
2919
+ mode: a Drawer inside a Drawer traps the reader one layer deeper than the
2920
+ record they opened. */}
2921
+ <Drawer open={commentsOpen && !isDrawer} onOpenChange={setCommentsOpen} title="Comments" width={380}>
2830
2922
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 20, gap: 12 }}>
2831
2923
  <CommentList
2832
2924
  comments={comments}
@@ -3157,6 +3249,7 @@ export function TplRecord() {
3157
3249
  it was written to mean. */}
3158
3250
  <SectionHeading>
3159
3251
  <SectionHeadingTitle>Comments</SectionHeadingTitle>
3252
+ <SectionHeadingMeta>{`${comments.length} ${comments.length === 1 ? "comment" : "comments"}`}</SectionHeadingMeta>
3160
3253
  </SectionHeading>
3161
3254
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12, paddingBottom: 8 }}>
3162
3255
  <CommentList
@@ -3211,7 +3304,14 @@ export function TplRecord() {
3211
3304
  <BackButton label="Records" onPress={() => {}} />
3212
3305
  <View style={{ gap: 2 }}>
3213
3306
  {SECTIONS.map((sec) => (
3214
- <MenuButton key={sec.key} icon={sec.icon} title={sec.label} current={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
3307
+ <MenuButton
3308
+ key={sec.key}
3309
+ icon={sec.icon}
3310
+ title={sec.label}
3311
+ current={activeSection === sec.key}
3312
+ right={sectionIssue[sec.key] ? <Badge variant="dot" color={sectionIssue[sec.key] === "danger" ? "red" : "amber"} /> : undefined}
3313
+ onPress={() => nav.jumpTo(sec.key)}
3314
+ />
3215
3315
  ))}
3216
3316
  </View>
3217
3317
  </View>