@lotics/ui 41.0.1 → 41.3.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,19 +1,24 @@
1
1
  import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "react";
2
- import { ScrollView, View } from "react-native";
2
+ import { Pressable, ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
- import { colors } from "@lotics/ui/colors";
4
+ import { colors, solid } from "@lotics/ui/colors";
5
5
  import { DRAWER_GUTTER } from "@lotics/ui/drawer";
6
6
  import { Button } from "@lotics/ui/button";
7
7
  import { BackButton } from "@lotics/ui/back_button";
8
8
  import { Divider } from "@lotics/ui/divider";
9
9
  import { Link } from "@lotics/ui/link";
10
- import { Icon } from "@lotics/ui/icon";
10
+ import { Icon, type IconName } from "@lotics/ui/icon";
11
+ import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
12
+ import { MediaPlayer } from "@lotics/ui/media_player";
13
+ import { Markdown } from "@lotics/ui/markdown";
14
+ import { TextDisclosure } from "@lotics/ui/text_disclosure";
15
+ import { TextLink } from "@lotics/ui/text_link";
11
16
  import { Alert } from "@lotics/ui/alert";
12
17
  import type { PickerOption } from "@lotics/ui/picker";
13
18
  import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
14
19
  import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
15
20
  import { Callout, CalloutText } from "@lotics/ui/callout";
16
- import { Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
21
+ import { Section, SectionHeading, SectionHeadingTitle, SectionHeadingMeta, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
17
22
  import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
18
23
  import { Checklist, ChecklistActions, ChecklistGroup, ChecklistItem, ChecklistNote } from "@lotics/ui/checklist";
19
24
  import { DateStamp } from "@lotics/ui/date_stamp";
@@ -31,7 +36,9 @@ import { Skeleton } from "@lotics/ui/skeleton";
31
36
  import { CommentList, type CommentEditFormProps, type ThreadComment, type ThreadFile } from "@lotics/ui/comments_thread";
32
37
  import { Composer } from "@lotics/ui/composer";
33
38
  import { IconButton } from "@lotics/ui/icon_button";
39
+ import { FileBadge } from "@lotics/ui/file_badge";
34
40
  import { FileRows } from "@lotics/ui/file_rows";
41
+ import { InlineFiles } from "@lotics/ui/inline_files";
35
42
  import { FileGrid } from "@lotics/ui/file_grid";
36
43
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
37
44
  import { InlineStatic } from "@lotics/ui/inline_static";
@@ -47,6 +54,10 @@ import { FormField } from "@lotics/ui/form_field";
47
54
  import { TextInputField } from "@lotics/ui/text_input_field";
48
55
  import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
49
56
  import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
57
+ import { MemberChip } from "@lotics/ui/member_chip";
58
+ import { WaveAvatar } from "@lotics/ui/wave_avatar";
59
+ import { Inset } from "@lotics/ui/inset";
60
+ import { isImageMimeType } from "@lotics/ui/mime";
50
61
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
51
62
  import { DangerZone } from "@lotics/ui/danger_zone";
52
63
  import { FileRow } from "@lotics/ui/file_row";
@@ -295,6 +306,578 @@ const PRIORITY_DOT: Record<string, string> = {
295
306
  rush: colors.amber[500],
296
307
  };
297
308
 
309
+ // ── ACTIVITY — what has been SAID with the other party, in the order it
310
+ // happened. Every entry answers the same four questions (what came of it, which
311
+ // way, over what, when) and then carries a BODY whose shape depends on what the
312
+ // entry IS: a call has audio, a demo has video, an email has a subject, a note
313
+ // has only its own text.
314
+ //
315
+ // One row anatomy, one varying body — never a row type per medium. The four
316
+ // invariants are what makes the feed scannable; a per-medium row would put the
317
+ // same fact in four places and let them drift.
318
+ type ActivityKind = "call" | "video" | "email" | "message" | "note";
319
+
320
+ interface ActivityEntry {
321
+ key: string;
322
+ kind: ActivityKind;
323
+ /**
324
+ * What came of it, in the reader's words. THIS is the row — see the anatomy
325
+ * note at the section.
326
+ *
327
+ * OPTIONAL, because a feed fills from more than one direction. A person writes
328
+ * an entry; an automation also files one the instant a recording lands, and an
329
+ * extraction files one off a screenshot. Those arrive with no words in them,
330
+ * and the row has to say so rather than borrow a phrase from the enums.
331
+ */
332
+ gist?: string;
333
+ direction: "in" | "out";
334
+ /** The medium as a person would say it, not an enum: "Zalo", "Google Meet". */
335
+ over: string;
336
+ when: string;
337
+ /**
338
+ * WHO put this here — and this feed has two kinds of author, so the type has
339
+ * two shapes. A person gets a `MemberChip`; an automation, an extraction or an
340
+ * agent gets `WaveAvatar`, the kit's mark for a non-human identity. Modelling
341
+ * both as one string forces the surface to give a recording bot a face and two
342
+ * initials, which is a small fabrication every reader notices and no test
343
+ * catches.
344
+ */
345
+ by: { kind: "member"; name: string; image?: string } | { kind: "system"; name: string };
346
+ /** An unanswered outreach is a real state and reads differently from a reply. */
347
+ awaiting?: boolean;
348
+
349
+ // ── THE BODY IS A SET OF BLOCKS, NOT A SHAPE PER KIND.
350
+ //
351
+ // Every field below is optional and any combination is legal, because what an
352
+ // entry CARRIES is independent of what it IS: a call may arrive as a recording
353
+ // alone, gain a transcript minutes later and a summary after that; an email
354
+ // carries a subject, prose and attachments; a note carries prose and nothing
355
+ // else. A shape per kind would put the same block in five places and let them
356
+ // drift — and the sixth kind, the one nobody has thought of yet, would need a
357
+ // sixth. `kind` survives only to pick the ROW's glyph and the media element.
358
+ //
359
+ // The blocks, in the order they render:
360
+
361
+ /** The exchange itself, when it was recorded. `kind` picks audio vs video. */
362
+ media?: { src: string; label: string };
363
+ /** VERBATIM and long, folded behind its own toggle: it is the SOURCE a summary
364
+ * was made from — read rarely, and in full when it is read at all. */
365
+ transcript?: string;
366
+ /**
367
+ * Anyone on it the row does not ALREADY name — the other addresses on an email
368
+ * header, and nothing else.
369
+ *
370
+ * Two rules, both learned by getting this wrong. It must be CAPTURABLE: a
371
+ * call's attendees are stored nowhere, so a line naming them can only be
372
+ * invented, and a block with no source teaches app authors to fabricate one.
373
+ * And it must be ADDITIVE: the record IS the counterparty, the supporting line
374
+ * gives the direction and the footer names who logged it, so "From <them>, to
375
+ * <us>" is three facts the row already carries. What is left is the third
376
+ * party — which is the entire value of the block.
377
+ */
378
+ participants?: string;
379
+ /** An email's subject — the one thing an email has that nothing else does. */
380
+ subject?: string;
381
+ /** Prose a PERSON wrote: an email body, a note. Markdown. */
382
+ body?: string;
383
+ /** Prose a MODEL wrote. Separate from `body` rather than a flag on it, because
384
+ * one entry routinely holds both — a rep's own note and the machine's reading
385
+ * of the same call — and they are different claims that must not merge. */
386
+ bodyByAi?: string;
387
+ files?: DisplayFile[];
388
+ /** Where it happened, when that is somewhere the reader can open — a post, a
389
+ * thread, a ticket. */
390
+ sourceUrl?: string;
391
+ }
392
+
393
+ // A note somebody typed in FULL rather than summarising — the realistic worst
394
+ // case for a feed, and the one a fixture of tidy one-liners never produces. It
395
+ // is here so the template exercises `Timeline`'s label clamp and the drill-down
396
+ // that pairs with it; unclamped, prose this length drew a 180px row and dragged
397
+ // the disc off the line it names.
398
+ // A self-contained SVG so the capture tiles render with no network.
399
+ const img = (label: string, fill: string) =>
400
+ "data:image/svg+xml," +
401
+ encodeURIComponent(
402
+ `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><rect width="320" height="240" fill="${fill}"/><text x="160" y="128" font-family="sans-serif" font-size="20" fill="white" text-anchor="middle">${label}</text></svg>`,
403
+ );
404
+
405
+ // VERBATIM, and interleaved the way a real diarised transcript is — short turns,
406
+ // a name per line, no structure to lean on. It is here because the fold that
407
+ // hides it only earns its place against text of this shape: a tidy paragraph
408
+ // would have made an inline render look perfectly reasonable.
409
+ const TRANSCRIPT = [
410
+ "**Sarah:** …so that panel is the reconciliation view. Every paid shipment on the left, every bank line on the right.",
411
+ "**Duc:** And it matches them itself?",
412
+ "**Sarah:** It proposes the match. You confirm it. Nothing posts without a person.",
413
+ "**Duc:** Can you show that again? Marc should see this part.",
414
+ "**Sarah:** Of course. I'll wait.",
415
+ "**Duc:** *(off mic)* …Marc, có rảnh hai phút không?",
416
+ "**Marc:** Sorry — I'm here. What am I looking at?",
417
+ "**Sarah:** Reconciliation. This is the step that takes your team a morning a week.",
418
+ "**Marc:** It's more than a morning. And what does it cost?",
419
+ "**Sarah:** Per document. I'd rather put the figure in writing than say a number now.",
420
+ "**Marc:** Please do. I'm at the board on Thursday and I'm not walking in with a range.",
421
+ "**Duc:** One thing — the person who keeps that spreadsheet isn't on this call.",
422
+ ].join("\n\n");
423
+
424
+ // A PHONE call leaves a transcript exactly as a video call does — the medium
425
+ // decides which element plays it, never whether the words exist. Splitting that
426
+ // (video gets a transcript, audio does not) is the sort of gap a fixture creates
427
+ // and a real system never has.
428
+ const CALL_TRANSCRIPT = [
429
+ "**Duc:** …six months in. Changing it now is not a conversation I can win.",
430
+ "**Sarah:** Then let's not have it. What if nothing moves and we sit on top?",
431
+ "**Duc:** On top how?",
432
+ "**Sarah:** Two pieces. A carrier layer, so an order becomes a booking in one press. And reconciliation against what the bank actually paid.",
433
+ "**Duc:** The bookings are the part that hurts. We re-key every one.",
434
+ "**Sarah:** Into the carrier's own site?",
435
+ "**Duc:** Into three of them. Different fields, same shipment.",
436
+ "**Sarah:** That is the layer. Nothing you have today changes.",
437
+ "**Duc:** I'd still need leadership on it. Six months of licence left.",
438
+ ].join("\n\n");
439
+
440
+ const LONG_NOTE =
441
+ "Ran the whole process end to end with their coordinator. Costs arrive as a PDF payment " +
442
+ "slip per job, the invoices are then downloaded one at a time against the numbers listed on " +
443
+ "the slip, and everything is keyed twice — once into their software and once into a separate " +
444
+ "master spreadsheet — before it goes to accounts for payment. Their OCR reads the notes field " +
445
+ "and nothing else, so the invoice number and the amount are typed by hand every time. Roughly " +
446
+ "twenty minutes a job, and she does eight to twelve a day.";
447
+
448
+ // The fixture is deliberately UNEVEN: a 34-minute call with a machine summary, a
449
+ // one-line note typed between meetings, an email nobody has answered, a message
450
+ // with an attachment, and one entry of unsummarised prose. A tidy set of similar
451
+ // rows would prove nothing about a feed whose whole problem is that its entries
452
+ // are not alike — and would hide the length case entirely.
453
+ const ACTIVITY: ActivityEntry[] = [
454
+ {
455
+ // ARRIVED, NOT WRITTEN — an automation filed this the moment the recording
456
+ // landed, and nobody has said what came of it yet. The most common shape on
457
+ // a feed that fills from elsewhere, and the one a hand-built fixture never
458
+ // contains, so the row that has to say "no words yet" never gets designed.
459
+ // Note the author: a bot, which is why `by` is not a name string.
460
+ key: "a0",
461
+ kind: "call",
462
+ direction: "in",
463
+ over: "Phone",
464
+ when: "Today, 11:40",
465
+ by: { kind: "system", name: "Recording bot" },
466
+ media: { src: "/sample-audio.mp3", label: "Call recording" },
467
+ transcript: CALL_TRANSCRIPT,
468
+ },
469
+ {
470
+ // THE FULL CALL: the recording, the verbatim transcript folded behind its
471
+ // own toggle, and a machine reading of it — three blocks on one entry, each
472
+ // a different kind of claim. This is the shape an app should copy.
473
+ key: "a3",
474
+ kind: "video",
475
+ gist: "Demo — the reconciliation step is what sold it; pricing still open",
476
+ direction: "out",
477
+ over: "Google Meet",
478
+ when: "Today, 10:15",
479
+ by: { kind: "member", name: "Sarah Chen" },
480
+ media: { src: "/sample-video.webm", label: "Demo recording" },
481
+ transcript: TRANSCRIPT,
482
+ // NO human note on this entry, deliberately. It carried "the person who
483
+ // maintains it was not in the room" — which the summary's own Risk line says
484
+ // better, and which the participants line was gesturing at too: one fact,
485
+ // three places. When a machine summary is good the rep usually adds nothing,
486
+ // and the note block is already taught by the two note entries below.
487
+ bodyByAi:
488
+ "**Where it landed.** The bank-reconciliation step is what changed the " +
489
+ "room — Duc asked to see it twice and pulled Marc in for it.\n\n**Open.** Pricing. Marc " +
490
+ "wants a per-document figure in writing before the board on Thursday.\n\n**Risk.** The " +
491
+ "spreadsheet owner was absent and is the person whose work this replaces.",
492
+ },
493
+ {
494
+ key: "a1",
495
+ kind: "note",
496
+ gist: "They want the integration layer built and maintained, not the platform replaced",
497
+ direction: "out",
498
+ over: "Note",
499
+ when: "Today, 09:12",
500
+ by: { kind: "member", name: "Sarah Chen" },
501
+ },
502
+ {
503
+ key: "a1b",
504
+ kind: "note",
505
+ // The gist and the body are the SAME string on purpose: the row clamps it to
506
+ // two lines and the detail carries it whole. That is a truncation and its
507
+ // source — the drill-down every expandable register row makes — not a second
508
+ // copy of one value.
509
+ gist: LONG_NOTE,
510
+ body: LONG_NOTE,
511
+ direction: "in",
512
+ over: "Site visit",
513
+ when: "Today, 08:05",
514
+ by: { kind: "member", name: "Sarah Chen" },
515
+ },
516
+ {
517
+ // AN EMAIL carries three things nothing else does: who it was between, what
518
+ // it was called, and a body somebody else composed. The body is `body`, not
519
+ // `bodyByAi` — a counterparty's own words are not a machine's summary, and
520
+ // rendering them alike would be the same mistake in the other direction.
521
+ key: "a2",
522
+ kind: "email",
523
+ subject: "Re: Pricing for the customs documentation module",
524
+ participants: "Copied to accounts@",
525
+ gist: "Asked for the per-document price in writing before the board meeting",
526
+ direction: "in",
527
+ over: "Email",
528
+ when: "Yesterday, 16:40",
529
+ by: { kind: "system", name: "Mailbox sync" },
530
+ body:
531
+ "Thanks for the walkthrough. Before I take this to the board on Thursday I need the " +
532
+ "per-document price **in writing**, and confirmation that the customs forms come out of " +
533
+ "the same record without re-keying.\n\nCould you also confirm the setup is included?" +
534
+ "\n\n> Sent from the board pack thread",
535
+ files: [{ id: "f-pack", filename: "Board pack — draft.pdf", mimeType: "application/pdf", url: "/sample.pdf" }],
536
+ },
537
+ {
538
+ // A CAPTURE: an extraction filed this off a screenshot of a public post, so
539
+ // the evidence is an image and the entry can point back at where it lives.
540
+ // Images go in a GRID — a photo is identified by what is in it, never by a
541
+ // filename — where a document set would be a row list.
542
+ key: "a6",
543
+ kind: "message",
544
+ gist: "Asked in the forwarders' group who handles Japan customs paperwork",
545
+ direction: "in",
546
+ over: "Facebook Group",
547
+ when: "10 Aug, 21:30",
548
+ by: { kind: "system", name: "Capture" },
549
+ files: [
550
+ { id: "s1", filename: "post.png", mimeType: "image/png", url: img("Post", "#3f3f46") },
551
+ { id: "s2", filename: "profile.png", mimeType: "image/png", url: img("Profile", "#52525b") },
552
+ ],
553
+ sourceUrl: "https://example.com/groups/forwarders/posts/1849",
554
+ },
555
+ {
556
+ key: "a4",
557
+ kind: "call",
558
+ gist: "34 minutes — locked into their current platform for six months, wants us to sit on top of it",
559
+ direction: "out",
560
+ over: "Phone",
561
+ when: "3 Aug, 11:20",
562
+ by: { kind: "member", name: "Sarah Chen" },
563
+ media: { src: "/sample-audio.mp3", label: "Call recording" },
564
+ transcript: CALL_TRANSCRIPT,
565
+ bodyByAi:
566
+ "**Context.** International freight forwarder, Japanese ownership, six " +
567
+ "months into their current platform.\n\n**What they need**\n\n1. A carrier API layer — one " +
568
+ "press from an order to a booking.\n2. Bank reconciliation against paid shipments.\n\n" +
569
+ "**Why it has not closed.** Deep commitment to the incumbent; replacing it is a non-starter " +
570
+ "and leadership would have to approve.",
571
+ },
572
+ {
573
+ key: "a5",
574
+ kind: "message",
575
+ gist: "Sent the one-page summary; no reply yet",
576
+ direction: "out",
577
+ over: "Zalo",
578
+ when: "28 Jul, 18:05",
579
+ by: { kind: "member", name: "Sarah Chen" },
580
+ awaiting: true,
581
+ files: [{ id: "f-sum", filename: "Summary — one page.pdf", mimeType: "application/pdf", url: "/sample.pdf" }],
582
+ },
583
+ ];
584
+
585
+ /**
586
+ * What came WITH the entry, as a clause for the supporting line.
587
+ *
588
+ * A sentence rather than a row of glyphs: an icon run is compact but needs a key
589
+ * the reader has to learn before the first row means anything, and the line it
590
+ * would save already exists.
591
+ *
592
+ * ONLY on an entry with no gist. There, the attachments ARE the content — "not
593
+ * written up yet" is useless without "there is a recording to write it up from".
594
+ * Anywhere else it repeats what the body shows one press away, and it is not
595
+ * free: measured at 375 the supporting line already fills the two lines
596
+ * `Timeline` clamps it at, so an unconditional clause clips the date it was
597
+ * appended to. The clause APPEARING is therefore itself the signal that the row
598
+ * is otherwise empty.
599
+ */
600
+ function arrivedWith(a: ActivityEntry): string {
601
+ if (a.gist) return "";
602
+ // Name the thing, not its relation to the sentence: "Attachment attached" is
603
+ // the shape a templated clause produces and it reads as a stutter.
604
+ // Lower case throughout, capitalised once at the end — joining already-capital
605
+ // words gave "Recording, Transcript and file attached." the moment a third
606
+ // artifact appeared, which no fixture reached and every real row eventually
607
+ // will. The type guard rather than `filter(Boolean)` is what removes the `!`
608
+ // on the last element: `Boolean` does not narrow.
609
+ const parts = [
610
+ a.media ? "recording" : null,
611
+ a.transcript ? "transcript" : null,
612
+ a.files?.length ? "file" : null,
613
+ ].filter((x): x is string => x !== null);
614
+ if (parts.length === 0) return "";
615
+ const list =
616
+ parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
617
+ return ` ${list[0].toUpperCase()}${list.slice(1)} attached.`;
618
+ }
619
+
620
+ /**
621
+ * THE ACTIVITY BODY — one component, every block, each rendered only if the
622
+ * entry carries it.
623
+ *
624
+ * This is the half of the anatomy that VARIES, and the reason it is a component
625
+ * rather than five: an app that branches on `kind` writes the media block once
626
+ * per kind and then fixes a bug in four of them. Reading order is fixed and
627
+ * means something — who and what it was, then the artifact, then the verbatim
628
+ * source, then what people made of it, then what came with it, then where it
629
+ * came from and who filed it.
630
+ */
631
+ function ActivityBody({
632
+ a,
633
+ editing,
634
+ onEdit,
635
+ onToggleEdit,
636
+ onDelete,
637
+ }: {
638
+ a: ActivityEntry;
639
+ /** Set by the footer's Edit verb — see the fields below for why it is a MODE
640
+ * here rather than the resident editors every other section uses. */
641
+ editing: boolean;
642
+ onEdit: (patch: Partial<Pick<ActivityEntry, "gist" | "body">>) => void;
643
+ onToggleEdit: () => void;
644
+ onDelete: () => void;
645
+ }) {
646
+ const [showTranscript, setShowTranscript] = useState(false);
647
+ const images = (a.files ?? []).filter((f) => isImageMimeType(f.mimeType));
648
+ const docs = (a.files ?? []).filter((f) => !isImageMimeType(f.mimeType));
649
+
650
+ return (
651
+ <View style={{ gap: 12 }}>
652
+ {/* THE WORDS A PERSON WROTE — the only editable thing on an entry, and the
653
+ one place this template does NOT use a resident editor.
654
+
655
+ Exactly two blocks were authored by a person: the gist and the note.
656
+ Everything else is derived (participants, off a header), verbatim (the
657
+ transcript), an artifact (the media, the files) or a machine's output
658
+ (the summary — you re-run that, you do not hand-edit it). So the verb
659
+ is "Edit", not "Edit entry": an entry is not an editable thing, the
660
+ words typed into it are.
661
+
662
+ WHY A MODE, when every other section on this record is a resident
663
+ `InlineTextInput`. Those sections show each value ONCE. Here the gist is
664
+ already the row's label, so a resident field renders the same sentence
665
+ twice, stacked and identical — which is what it looked like, and no
666
+ amount of quieting the frame fixes a sentence appearing twice.
667
+
668
+ THE EXCEPTION IS AN EMPTY GIST, and it is the important half: an
669
+ automation files an entry the moment a recording lands, the row says
670
+ "Not written up yet", and there is nothing to duplicate. The field shows
671
+ itself and invites the sentence. Without that the feed can RECEIVE an
672
+ entry it gives you no way to finish — a dead end on the most common row
673
+ a multi-writer feed produces. An empty value shows its field; a filled
674
+ one waits to be asked. */}
675
+ {editing || !a.gist ? (
676
+ <InlineTextInput
677
+ value={a.gist ?? ""}
678
+ onSave={(v) => onEdit({ gist: v.trim() || undefined })}
679
+ placeholder="What came of it?"
680
+ accessibilityLabel="What came of it"
681
+ numberOfLines={2}
682
+ autoGrow
683
+ />
684
+ ) : null}
685
+
686
+ {/* HEADER — who it was between, and what it was called. A subject line
687
+ never says who was on it, and on a call there is no subject at all, so
688
+ these are two blocks rather than one formatted string. Both are read
689
+ off the message itself, so neither is editable. */}
690
+ {a.participants ? (
691
+ <Text size="xs" color="muted">{a.participants}</Text>
692
+ ) : null}
693
+ {a.subject ? <Text size="sm" weight="medium">{a.subject}</Text> : null}
694
+
695
+ {/* THE ARTIFACT. A recording rendered as a file row makes the reader leave
696
+ the record to hear thirty seconds of a call they are already reading
697
+ about. `kind` is the caller's to state, because one container can hold
698
+ both streams and only this surface knows whether it wants the picture.
699
+
700
+ THE BOX IS THE CALLER'S: `MediaPlayer` fills its parent rather than
701
+ carrying a size, so a player dropped bare into a gap-spaced stack
702
+ collapses to nothing — it renders, it reports no error, and there is
703
+ simply no pixel. Video takes a 16:9 frame so the row does not resize
704
+ when metadata arrives; audio carries its own intrinsic height. */}
705
+ {a.media ? (
706
+ a.kind === "video" ? (
707
+ <View style={{ aspectRatio: 16 / 9, borderRadius: 12, overflow: "hidden" }}>
708
+ <MediaPlayer src={a.media.src} kind="video" accessibilityLabel={a.media.label} />
709
+ </View>
710
+ ) : (
711
+ <MediaPlayer src={a.media.src} kind="audio" accessibilityLabel={a.media.label} />
712
+ )
713
+ ) : null}
714
+
715
+ {/* THE VERBATIM SOURCE, folded. It belongs next to the summary rather than
716
+ behind a dialog, because the reason anyone opens a transcript is to
717
+ check a claim the summary made — and a modal takes the claim off the
718
+ screen at the moment they want to compare. Revealed in FULL, not into a
719
+ scroll box: a scroller inside a drawer that also scrolls traps the
720
+ wheel, and a reader who pressed "Show transcript" asked for the length. */}
721
+ {a.transcript ? (
722
+ <View style={{ gap: 8, alignItems: "flex-start" }}>
723
+ {/* Underlined text that REVEALS rather than navigates — muted, so the
724
+ ink never promises a trip. Two controls were tried first and both
725
+ are wrong here: `Button color="muted"` measures transparent and
726
+ undecorated at rest (a hover-only affordance), and `Accordion` is a
727
+ list-row disclosure nested inside a list row. See the component's
728
+ own doc and composition.md §"Commit & feedback surfaces". */}
729
+ <TextDisclosure
730
+ expanded={showTranscript}
731
+ onToggle={setShowTranscript}
732
+ label="transcript"
733
+ />
734
+ {/* PLAIN, not a tinted well. The toggle directly above already says
735
+ what this is and where it came from, and a panel here would put two
736
+ identical recessed boxes on one row meaning two different things —
737
+ a verbatim record and a machine's reading of it. */}
738
+ {showTranscript ? <Markdown variant="embedded">{a.transcript}</Markdown> : null}
739
+ </View>
740
+ ) : null}
741
+
742
+ {/* PROSE A PERSON WROTE — a note of their own. It sits on the page's own
743
+ ground, which is what makes the recessed block below legible as "not
744
+ written here", and it is EDITABLE for the same reason the gist is:
745
+ somebody typed it.
746
+
747
+ The exception is a body that arrived FROM the counterparty — an email
748
+ they sent is a record of what they said, so it renders as markdown and
749
+ is not ours to rewrite. */}
750
+ {a.kind === "email" ? (
751
+ a.body ? <Markdown variant="embedded">{a.body}</Markdown> : null
752
+ ) : editing ? (
753
+ <InlineTextInput
754
+ value={a.body ?? ""}
755
+ onSave={(v) => onEdit({ body: v.trim() || undefined })}
756
+ placeholder="Add a note…"
757
+ accessibilityLabel="Note"
758
+ numberOfLines={2}
759
+ autoGrow
760
+ />
761
+ ) : a.body ? (
762
+ <Markdown variant="embedded">{a.body}</Markdown>
763
+ ) : null}
764
+
765
+ {/* PROSE A MODEL WROTE. Two devices carry the difference and neither is a
766
+ weight nudge: the line names the AUTHOR and the evidence it worked
767
+ from — naming only the source ("From the call") leaves the reader to
768
+ assume a person — and the recessed `Inset` says the text was not
769
+ written on this page. `embedded` stops the `##` headings a model emits
770
+ freely from outranking the section they were dropped inside. */}
771
+ {a.bodyByAi ? (
772
+ // The label sits INSIDE the panel it names. Floating above it, a 12px
773
+ // muted fragment over a tinted box reads as an orphan — the type was on
774
+ // the ladder and the PLACEMENT was the defect, which is why it looked
775
+ // wrong without looking measurably wrong.
776
+ //
777
+ // Two words, and they are the whole job: a model wrote this. It read
778
+ // "Written by AI from the recording" over markdown that then opened with
779
+ // its own `## Summary` — two labels for one thing, the longer one
780
+ // spending four words on evidence the reader can watch playing directly
781
+ // above. Provenance is weighted by consequence: name the source where it
782
+ // is NOT on screen.
783
+ <Inset>
784
+ <Text size="xs" color="muted" weight="medium">AI summary</Text>
785
+ <Markdown variant="embedded">{a.bodyByAi}</Markdown>
786
+ </Inset>
787
+ ) : null}
788
+
789
+ {/* WHAT CAME WITH IT, split by what IDENTIFIES each file. A document is its
790
+ NAME — every PDF thumbnail is the same grey page — so documents are
791
+ rows. A photo is its CONTENT (`IMG_4471.jpg` tells nobody anything), so
792
+ images are tiles. One `files` array, two surfaces, decided by the data
793
+ rather than by a prop the caller has to remember. */}
794
+ {docs.length ? <FileRows files={docs} /> : null}
795
+ {images.length ? <FileThumbnailGrid files={images} itemSize={88} /> : null}
796
+
797
+ {/* PROVENANCE — where it happened, and who filed it. Two shapes for the
798
+ author because there are two kinds: a person gets a face, and an
799
+ automation gets `WaveAvatar`, the kit's mark for a non-human identity.
800
+ Giving a bot initials and an avatar is a small fabrication that every
801
+ reader notices and no test catches. */}
802
+ {/* THE FOOTER — provenance on the left, the entry's verbs on the right.
803
+ Two kinds of thing on one line, so they are two GROUPS pushed apart by
804
+ the space between them rather than a uniform gap that would make them
805
+ peers.
806
+
807
+ THE VERBS LIVE HERE, NOT ON THE ROW, and the reason is not tidiness.
808
+ A control in the row's trailing slot produced three separate defects in
809
+ a row: it nested a button inside the row's own button (invalid HTML,
810
+ one click reaching two handlers), it needed a fixed-height box of its
811
+ own to stay on the label's first line, and it sat close enough to the
812
+ disclosure chevron to read as one cluster with it. All three exist only
813
+ because something interactive shares the row with the press target.
814
+ Down here there is no press target to share with, and the whole class
815
+ is gone.
816
+
817
+ It also puts the verbs where the thing they act on IS. Editing a gist
818
+ you cannot read is not a real act, and a delete you reach without
819
+ opening the entry is a delete you make without looking at it — the
820
+ expansion is the confirmation step, which is why "two clicks" is the
821
+ feature rather than the cost.
822
+
823
+ They are ordinary `Button`s, not the quiet text controls used inside
824
+ prose above: these MUTATE, so they carry a control surface. Right-
825
+ aligned in a footer band, they establish no text edge to betray, which
826
+ is the condition the fill-less rungs need. */}
827
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
828
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
829
+ <Text size="xs" color="muted">Logged by</Text>
830
+ {a.by.kind === "member" ? (
831
+ <MemberChip name={a.by.name} image={a.by.image} size="sm" />
832
+ ) : (
833
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
834
+ <WaveAvatar size={24} />
835
+ <Text size="sm">{a.by.name}</Text>
836
+ </View>
837
+ )}
838
+ </View>
839
+ {a.sourceUrl ? (
840
+ <Link size="xs" onPress={() => {}}>{a.sourceUrl}</Link>
841
+ ) : null}
842
+ {/* Pushes the verbs to the far edge, so provenance and actions are two
843
+ groups rather than a run of four items on one gap. */}
844
+ <View style={{ flex: 1, minWidth: 24 }} />
845
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
846
+ <Button
847
+ title={editing ? "Done" : "Edit"}
848
+ color="muted"
849
+ onPress={onToggleEdit}
850
+ />
851
+ <Button
852
+ title="Delete"
853
+ color="danger-secondary"
854
+ onPress={() =>
855
+ Alert.alert(
856
+ "Delete this entry?",
857
+ "It leaves the customer's history and stops counting toward the activity total. This cannot be undone.",
858
+ [
859
+ { text: "Cancel", style: "cancel" },
860
+ { text: "Delete", style: "destructive", onPress: onDelete },
861
+ ],
862
+ )
863
+ }
864
+ />
865
+ </View>
866
+ </View>
867
+ </View>
868
+ );
869
+ }
870
+
871
+ /** The glyph per medium. A medium is a CATEGORY, so it rides the icon and the
872
+ * supporting line — never a `Badge`, which this kit reserves for status. */
873
+ const ACTIVITY_ICON: Record<ActivityKind, IconName> = {
874
+ call: "phone",
875
+ video: "monitor",
876
+ email: "mail",
877
+ message: "message-circle",
878
+ note: "sticky-note",
879
+ };
880
+
298
881
  // ── billing — charges grouped into issuable invoice DOCUMENTS on this record
299
882
  type Method = "cash" | "transfer" | "card";
300
883
  const METHODS: PickerOption<Method>[] = [
@@ -399,6 +982,19 @@ const PANEL_GUTTER = PANEL_W + RAIL_GAP;
399
982
  * `Drawer`'s 600 it sat between two columns of its own order and stopped reading
400
983
  * as the thing the page is about. */
401
984
  const CONTENT_MAX = 680;
985
+ /** The page's own horizontal padding. Named because `groupW` subtracts it and
986
+ * the panel's fold threshold must add it back — they were written apart, and
987
+ * the threshold omitted it, so the reading column could sit a full 56px UNDER
988
+ * the floor the same code declared. A floor that is not enforced is not a
989
+ * floor; it is a comment. */
990
+ const PAGE_PAD = 28;
991
+ /** What the reading column needs to stay readable beside both gutters — a label
992
+ * column plus its values, not what it would like. Below this the discussion
993
+ * panel FOLDS to an inline section, which is the template's stated rule
994
+ * ("when the two compete the panel is what gives way") finally implemented:
995
+ * the panel's width is a constant, so without a floor on this side the reading
996
+ * column absorbed every squeeze and the rule ran backwards. */
997
+ const CONTENT_FLOOR = 420;
402
998
 
403
999
  // ── the DOCUMENT DESK — the Agents "Document desk" pattern, carried as the
404
1000
  // record's documents surface: files feed ONE "Use AI" entry that forks into
@@ -415,6 +1011,7 @@ function fmtSize(kb: number): string {
415
1011
  if (kb <= 0) return "—";
416
1012
  return kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
417
1013
  }
1014
+ const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
418
1015
  const MOCK_PHOTO_URL =
419
1016
  "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhOGEyOWUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjZGMyNjI2Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMyNTYzZWInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNmNTllMGInLz48cmVjdCB4PScyNjInIHk9JzI0JyB3aWR0aD0nMTAnIGhlaWdodD0nMTg2JyBmaWxsPScjNTI1MjUyJy8+PHJlY3QgeD0nMTUwJyB5PScyNCcgd2lkdGg9JzEyMicgaGVpZ2h0PScxMCcgZmlsbD0nIzUyNTI1MicvPjwvc3ZnPg==";
420
1017
  const DOCS: Doc[] = [
@@ -436,6 +1033,11 @@ const DOCS: Doc[] = [
436
1033
  { id: "p4", name: "cont-rong-mat-truoc.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1231, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2U5ZDVmZicvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhMWExYWEnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjYmUxMjNjJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMxZTI5M2InLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNiZTEyM2MnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
437
1034
  { id: "p5", name: "cont-rong-ben-trong.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1368, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2ZlY2FjYScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM0NDQwM2MnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjMGY3NjZlJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMzMzQxNTUnLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMwZjc2NmUnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
438
1035
  { id: "p6", name: "bien-so-xe.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1505, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2ZlZjA4YScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM1MjUyNTInLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjN2UyMmNlJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMxZTNhOGEnLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyM3ZTIyY2UnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
1036
+ // NOT an image, deliberately: the signed note is evidence of the same hand-over
1037
+ // as the frames around it, and it is the tile a fixture of eight JPGs never
1038
+ // rendered. `FileThumbnail` shows a doc tile (badge + name) — no code needed,
1039
+ // only a fixture honest enough to reach it.
1040
+ { id: "p8", name: "bien-ban-giao-nhan-da-ky.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 412, added: "02 Jul", addedAt: 702, url: MOCK_PDF_URL },
439
1041
  { id: "p7", name: "bang-ke-giao-nhan.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1642, added: "02 Jul", addedAt: 702, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2E1ZjNmYycvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM3MzczNzMnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjYzI0MTBjJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMwNjRlM2InLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNjMjQxMGMnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
440
1042
  ];
441
1043
 
@@ -607,14 +1209,30 @@ const PDF = "application/pdf";
607
1209
 
608
1210
  /**
609
1211
  * A file rendered as a table VALUE — the filename at the size every other cell
610
- * uses.
1212
+ * uses. The ONE-FACT cell: the name IS the value, so it is rendered like the
1213
+ * quantity beside it and nothing else is added.
611
1214
  *
612
1215
  * It carried a `FileBadge` at first, which in a cell that size shrank its own
613
1216
  * label to five pixels — off every rung of the type scale. Making it legible
614
1217
  * means a 34px box setting the height of a 52px row, for a fact the extension
615
- * already carries. The discriminator is what the file IS on the surface: the
616
- * row's SUBJECT (a `FileRow`, a tile) earns a badge; one VALUE among columns is
617
- * a value, rendered like the quantity beside it.
1218
+ * already carries.
1219
+ *
1220
+ * The discriminator is HOW MANY FACTS the cell owes the reader. This cell owes
1221
+ * ONE — which document — and the name is it.
1222
+ *
1223
+ * A cell owing TWO is a different shape and wants a mark this one must not
1224
+ * borrow: a reference the desk quotes (an invoice number) PLUS whether the
1225
+ * document behind it is on file, where nothing in the value answers the second
1226
+ * question. `tpl_item_list`'s register is the worked example — it is a
1227
+ * full-width surface with room for the column, which this page is not: measured
1228
+ * at 1280 the fee table already shows 3 of its 5 columns, because a record page
1229
+ * spends its width on a nav rail and a comments column. A column that always
1230
+ * drops teaches nothing, so the rule lives here and the example lives there.
1231
+ *
1232
+ * The mark is a monochrome clip, NOT a `FileBadge`: a badge's payload beyond
1233
+ * presence is the file's TYPE, its label is unreadable at row scale (5px
1234
+ * measured), and on a ledger where every document is a PDF it spends a hue on
1235
+ * something that never varies.
618
1236
  */
619
1237
  function DocName({ doc }: { doc: LineDoc }) {
620
1238
  return <Text size="sm" numberOfLines={1}>{doc.name}</Text>;
@@ -665,7 +1283,6 @@ const FINDINGS: Check[] = [
665
1283
  ];
666
1284
 
667
1285
  // A tiny REAL pdf (data URI) so the gallery preview genuinely renders.
668
- const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
669
1286
  const toDisplay = (d: Doc): DisplayFile => ({ id: d.id, filename: d.name, mimeType: d.mimeType, url: d.url ?? MOCK_PDF_URL });
670
1287
  /** A comment's ThreadFile (snake_case, the API shape) → the kit's DisplayFile. */
671
1288
  const commentFileToDisplay = (f: ThreadFile): DisplayFile => ({ id: f.id, filename: f.filename, mimeType: f.mime_type, url: f.url ?? "", thumbnailUrl: f.thumbnail_url });
@@ -707,6 +1324,16 @@ const iso = (d: Date): string => d.toISOString().slice(0, 10);
707
1324
  // display VAT / Invoice no / Note — it drops them silently. And `note` is prose:
708
1325
  // it has no column width at any measure.
709
1326
  type FeeDirection = "charge" | "cost";
1327
+ /** A picked `File` as the kit's `DisplayFile`. A real host uploads and returns
1328
+ * the stored object; the object URL stands in for that here. */
1329
+ let feeDocSeq = 0;
1330
+ const asDisplayFile = (f: File): DisplayFile => ({
1331
+ id: `fee-doc-u${(feeDocSeq += 1)}`,
1332
+ filename: f.name,
1333
+ mimeType: f.type || "application/octet-stream",
1334
+ url: URL.createObjectURL(f),
1335
+ });
1336
+
710
1337
  interface Fee {
711
1338
  id: string;
712
1339
  name: string;
@@ -717,25 +1344,41 @@ interface Fee {
717
1344
  vat: number | null;
718
1345
  due: string;
719
1346
  invoiceNo: string;
1347
+ /** The supplier's original, filed against THIS fee. A number is what the field
1348
+ * claimed; these are what actually arrived, and the gap between them is the
1349
+ * work a payables desk does. */
1350
+ docs: DisplayFile[];
720
1351
  paid: boolean;
721
1352
  note: string;
722
1353
  }
723
1354
  let feeSeq = 4;
724
1355
  const FEE_SEED: Fee[] = [
725
- { id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", paid: true, note: "" },
726
- { id: "fee_2", name: "Trucking", direction: "cost", party: "Northline Haulage", amount: 450_000, vat: 8, due: "2026-07-02", invoiceNo: "NH-2044", paid: false, note: "Last-mile to the port" },
727
- { id: "fee_3", name: "Customs advance", direction: "cost", party: "Blue Anchor Brokerage", amount: 275_000, vat: null, due: "", invoiceNo: "BA-118", paid: true, note: "" },
728
- { id: "fee_4", name: "Storage overrun", direction: "charge", party: "Harbor Freight Lines", amount: 120_000, vat: 8, due: "2026-08-02", invoiceNo: "", paid: false, note: "3 extra days at Central hub" },
1356
+ { id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", docs: [], paid: true, note: "" },
1357
+ { id: "fee_2", name: "Trucking", direction: "cost", party: "Northline Haulage", amount: 450_000, vat: 8, due: "2026-07-02", invoiceNo: "NH-2044", docs: [{ id: "fee-doc-1", filename: "NH-2044-invoice.pdf", mimeType: "application/pdf", url: MOCK_PDF_URL }], paid: false, note: "Last-mile to the port" },
1358
+ // UNPAID with a number claimed and nothing filed the state the status column
1359
+ // exists to surface. It was `paid: true`, which meant "Awaiting invoice" could
1360
+ // never render and the design could not be seen at all.
1361
+ { id: "fee_3", name: "Customs advance", direction: "cost", party: "Blue Anchor Brokerage", amount: 275_000, vat: null, due: "", invoiceNo: "BA-118", docs: [], paid: false, note: "" },
1362
+ // A PHOTO of an invoice, not a PDF — the case the badge exists for. A supplier
1363
+ // who sends a phone snap has not sent a tax document, and the colour says so
1364
+ // before anyone opens it. A fixture of nothing but PDFs makes the badge look
1365
+ // like decoration, because the one thing it carries never varies.
1366
+ { id: "fee_4", name: "Storage overrun", direction: "charge", party: "Harbor Freight Lines", amount: 120_000, vat: 8, due: "2026-08-02", invoiceNo: "HF-8821", docs: [{ id: "fee-doc-2", filename: "storage-invoice-photo.jpg", mimeType: "image/jpeg", url: MOCK_PHOTO_URL }], paid: false, note: "3 extra days at Central hub" },
729
1367
  ];
730
1368
  // `priority` = the mobile contract: Party then Type drop first on a narrow
731
1369
  // container, Amount + Status survive with the identity; at the floor the rows
732
1370
  // STACK label-over-value — and the expansion always carries the full fee.
1371
+ // THREE columns, not five — the register's grammar. A column per FACT spends
1372
+ // width on separation the reader never asked for: the party belongs to the fee's
1373
+ // identity and the payment state belongs to its amount, so each rides line 2 of
1374
+ // the cell it qualifies. Grouping bought 246px (Party 150 + Status 96), which is
1375
+ // more than the document column costs — the table gained information and lost
1376
+ // two columns. `tpl_item_list` stacks both of these already; this table was the
1377
+ // outlier.
733
1378
  const FEE_COLUMNS: TableColumn[] = [
734
1379
  { key: "fee", label: "Fee", flex: 1 },
735
- { key: "type", label: "Type", width: 72, priority: 3 },
736
- { key: "party", label: "Party", width: 150, priority: 4 },
737
1380
  { key: "amount", label: "Amount", width: 104, align: "right", priority: 1 },
738
- { key: "status", label: "Status", width: 96, priority: 2 },
1381
+ { key: "invoice", label: "Invoice", width: 116, priority: 2 },
739
1382
  ];
740
1383
  const feeOverdue = (f: Fee): boolean => !f.paid && f.due !== "" && new Date(f.due) < new Date();
741
1384
 
@@ -1162,7 +1805,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1162
1805
  const [feeView, setFeeView] = useState<{ kind: "edit"; id: string } | null>(null);
1163
1806
  const patchFee = (id: string, p: Partial<Fee>) => setFees((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
1164
1807
  const addFee = () => {
1165
- const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", paid: false, note: "" };
1808
+ const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", docs: [], paid: false, note: "" };
1166
1809
  setFees((prev) => [...prev, f]);
1167
1810
  setFeeView({ kind: "edit", id: f.id });
1168
1811
  };
@@ -1173,12 +1816,34 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1173
1816
  ]);
1174
1817
  };
1175
1818
  // The status column speaks in facts: Paid, Overdue, Due <date>, Unpaid.
1819
+ /**
1820
+ * MONEY state only — it rides under the amount it is about.
1821
+ *
1822
+ * "Awaiting docs" lived here while the document had no column of its own, and
1823
+ * it was a conflation: whether a supplier's original has arrived is not a fact
1824
+ * about the money, and putting it in the money's state meant a fee could not
1825
+ * report both at once. Now the Invoice column carries the document and this
1826
+ * carries the payment, and each says one thing.
1827
+ */
1176
1828
  const feeStatus = (f: Fee): { text: string; danger: boolean } =>
1177
1829
  f.paid ? { text: "Paid", danger: false }
1178
1830
  : feeOverdue(f) ? { text: "Overdue", danger: true }
1179
1831
  : f.due !== "" ? { text: `Due ${new Date(f.due).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`, danger: false }
1180
1832
  : { text: "Unpaid", danger: false };
1181
1833
 
1834
+ // ── activity — the feed is state so a logged note appears at the top of it,
1835
+ // where it happened, rather than after a refetch.
1836
+ const [activity, setActivity] = useState<ActivityEntry[]>(ACTIVITY);
1837
+ // A record's feed is not a register: you do not PAGE it, you fold the tail.
1838
+ // The recent entries are what the reader came for, and an unbounded feed makes
1839
+ // every other section on the record unreachable by scroll.
1840
+ const [showAllActivity, setShowAllActivity] = useState(false);
1841
+ const ACTIVITY_FOLD = 3;
1842
+ // ONE entry at a time. A feed with several rows open in edit mode gives the
1843
+ // reader two half-finished sentences and no way to tell which one the next
1844
+ // keystroke lands in.
1845
+ const [editingActivity, setEditingActivity] = useState<string | null>(null);
1846
+
1182
1847
  // ── the record — seeded mid-flight so every state is visible
1183
1848
  const [stage, setStage] = useState<Stage>("sales");
1184
1849
  // The customer book is STATE: the attached customer's tax ID edits inline in
@@ -1278,6 +1943,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1278
1943
  const [confirmIssueKey, setConfirmIssueKey] = useState<string | null>(null);
1279
1944
  const seq = useRef(414);
1280
1945
  const taxId = customer?.taxId ?? "";
1946
+ /** The ONE signed contract — a single-file field, not a document set. */
1947
+ const [contract, setContract] = useState<DisplayFile | null>(null);
1281
1948
  const taxIdValid = TAX_ID_RE.test(taxId);
1282
1949
 
1283
1950
  // ── the document desk (see the module banner above; the record's files ARE
@@ -1286,7 +1953,20 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1286
1953
  // The photo subset of the SAME collection — one list, two surfaces. Derived
1287
1954
  // rather than a second state, or the two sections would drift the moment
1288
1955
  // anything is added to one of them.
1289
- const photoDocs = useMemo(() => files.filter((f) => f.mimeType.startsWith("image/")), [files]);
1956
+ /**
1957
+ * EVERYTHING on the record, not the image subset.
1958
+ *
1959
+ * It filtered `mimeType.startsWith("image/")`, which read well until you asked
1960
+ * what happens to the signed delivery note: it is evidence of the same
1961
+ * hand-over as the photos beside it, and a MIME test filed it somewhere else.
1962
+ * A reader looking for "what came back from the yard" then has to know which
1963
+ * of two lenses holds each artifact — a question about the FILE FORMAT, which
1964
+ * is never the question they actually have.
1965
+ *
1966
+ * `FileThumbnail` already renders a non-image as a doc tile (badge + name), so
1967
+ * nothing here had to change for it to work — only the filter that hid it.
1968
+ */
1969
+ const photoDocs = files;
1290
1970
  /**
1291
1971
  * SELECT MODE — for the grid only, and only because a grid has nowhere free
1292
1972
  * to put a checkbox.
@@ -1862,6 +2542,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1862
2542
  // The rail lists sections in PAGE order, or the scroll-spy highlights one
1863
2543
  // entry while the reader is looking at another.
1864
2544
  { key: "progress", label: "Progress", icon: "git-branch" },
2545
+ { key: "activity", label: "Activity", icon: "message-square" },
1865
2546
  { key: "general", label: "General", icon: "file-text" },
1866
2547
  // Only a SECTION when the page is too narrow to seat the panel — so the wide
1867
2548
  // rail filters it out rather than offering a jump to something that is not
@@ -1884,7 +2565,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1884
2565
  // by scroll and invisible in the outline. Bound to `SecKey`, a key here that
1885
2566
  // `SECTIONS` does not carry stops being assignable, so the omission is a
1886
2567
  // compile error instead of a missing row nobody notices.
1887
- const nav = useSectionNav<SecKey>(["progress", "general", "comments", "files", "photos", "transport", "fees", "billing", "docset", "receipt", "danger"] as const);
2568
+ const nav = useSectionNav<SecKey>(["progress", "activity", "general", "comments", "files", "photos", "transport", "fees", "billing", "docset", "receipt", "danger"] as const);
1888
2569
  // ONE record, ONE page. A section is a place you SCROLL to, never a
1889
2570
  // destination you swap to: routing a record was tried here and lost, because
1890
2571
  // every fix it needed rebuilt the whole-record view in miniature — a dot to
@@ -1934,7 +2615,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1934
2615
  // two gutters. The floor is what the column needs to stay readable (a label
1935
2616
  // column plus its values), not what it would like — hold out for the ceiling and
1936
2617
  // the thread folds away on every screen under ~1220.
1937
- const wide = pageWidth != null && pageWidth >= GUTTER + PANEL_GUTTER + 420;
2618
+ //
2619
+ // Every term `groupW` spends must appear here, PAGE_PAD included. It did not,
2620
+ // and the omission ran in the dangerous direction: the check passed while the
2621
+ // column was under its floor, so the panel kept its constant 300 and the record
2622
+ // — the thing the page is about — took the loss. The two lines are one
2623
+ // calculation and have to be read as one.
2624
+ const wide = pageWidth != null && pageWidth >= PAGE_PAD * 2 + GUTTER + PANEL_GUTTER + CONTENT_FLOOR;
1938
2625
  // Narrow: the discussion is one tap off the same bar the sections are on —
1939
2626
  // on the RIGHT, opposite the section picker, since it is about the record as a
1940
2627
  // whole rather than a place in it.
@@ -1947,8 +2634,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1947
2634
  // record scrolls. (Capping the ScrollView at the column's width instead makes
1948
2635
  // the gutters dead zones.) Mirrors the flex maths the row does below: the
1949
2636
  // group is centred inside the 28px content padding.
1950
- const groupW = pageWidth == null ? 0 : Math.min(pageWidth - 28 * 2, GUTTER + CONTENT_MAX + PANEL_GUTTER);
1951
- const railLeft = pageWidth == null ? 0 : 28 + Math.max(0, (pageWidth - 28 * 2 - groupW) / 2);
2637
+ const groupW = pageWidth == null ? 0 : Math.min(pageWidth - PAGE_PAD * 2, GUTTER + CONTENT_MAX + PANEL_GUTTER);
2638
+ const railLeft = pageWidth == null ? 0 : PAGE_PAD + Math.max(0, (pageWidth - PAGE_PAD * 2 - groupW) / 2);
1952
2639
  const panelRight = railLeft;
1953
2640
 
1954
2641
  /**
@@ -2241,6 +2928,160 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2241
2928
  </Section>
2242
2929
  </View>
2243
2930
 
2931
+ {/* ACTIVITY — what has been SAID with the other party. It follows
2932
+ Progress because the two answer the reader's first two questions in
2933
+ order: where the record stands, then what passed between us to get
2934
+ it there.
2935
+
2936
+ It is NOT the handoff trail. Progress owns where the record SITS and
2937
+ the act that moves it; this owns COMMUNICATIONS with a counterparty,
2938
+ which a desk transition is not. Keeping that line is what stops this
2939
+ section becoming the audit log the Progress note deliberately
2940
+ refuses — an entry here is something a person said or sent, never a
2941
+ field that changed.
2942
+
2943
+ THE ROW ANATOMY, and the mistake it exists to prevent: the row's
2944
+ LABEL is the GIST — what came of the exchange, in the reader's own
2945
+ words. The medium and the direction are metadata and ride the muted
2946
+ supporting line under it. Putting the taxonomy in the label
2947
+ ("Outbound Meeting") and the substance behind the chevron is the
2948
+ natural way to build this and it makes the feed unreadable: every
2949
+ row renders as a pair of enum values, the reader has to expand each
2950
+ one to learn anything, and a column of near-identical labels defeats
2951
+ the scan the feed exists for.
2952
+
2953
+ ONE anatomy, a body that VARIES. A touchpoint can be a call, a
2954
+ recorded demo, an email, a message or a note typed between meetings,
2955
+ and those differ in what they CARRY, not in what they are: all five
2956
+ answer what-came-of-it, which way, over what, and when. So the four
2957
+ invariants are the row and the body is a slot — audio and video get
2958
+ a real MediaPlayer, an email gets its subject, prose gets Markdown,
2959
+ attachments get FileRows. A row TYPE per medium would put those four
2960
+ facts in five places and let them drift. */}
2961
+ <View onLayout={nav.register("activity")}>
2962
+ <Section>
2963
+ <SectionHeading>
2964
+ <SectionHeadingTitle description="Calls, mail and messages with the customer, newest first.">
2965
+ Activity
2966
+ </SectionHeadingTitle>
2967
+ <SectionHeadingMeta>{activity.length}</SectionHeadingMeta>
2968
+ </SectionHeading>
2969
+
2970
+ {/* The capture sits AT the feed, not in a section of its own. Logging
2971
+ a touchpoint is this section's input, not a distinct AREA of the
2972
+ record — and a section costs a rail entry, which is a claim that
2973
+ there is somewhere else to go. The Composer is compact until
2974
+ typed into, so the resting cost of "you can add one" is one row
2975
+ rather than a form nobody is filling. */}
2976
+ <Composer
2977
+ placeholder="Log a call, an email, a note…"
2978
+ accessibilityLabel="Log an activity"
2979
+ sendLabel="Log"
2980
+ onSend={(text) => {
2981
+ const t = text.trim();
2982
+ if (!t) return;
2983
+ setActivity((prev) => [
2984
+ {
2985
+ key: `a-${prev.length + 1}-${t.length}`,
2986
+ kind: "note",
2987
+ gist: t,
2988
+ direction: "out",
2989
+ over: "Note",
2990
+ when: "Just now",
2991
+ by: { kind: "member", name: "Sarah Chen" },
2992
+ },
2993
+ ...prev,
2994
+ ]);
2995
+ }}
2996
+ />
2997
+
2998
+ {activity.length === 0 ? (
2999
+ <EmptyState
3000
+ icon="message-circle"
3001
+ message="Nothing logged yet"
3002
+ hint="Add the first call, email or note above."
3003
+ />
3004
+ ) : (
3005
+ <>
3006
+ <Timeline
3007
+ items={(showAllActivity ? activity : activity.slice(0, ACTIVITY_FOLD)).map(
3008
+ (a): TimelineItem => ({
3009
+ id: a.key,
3010
+ icon: ACTIVITY_ICON[a.kind],
3011
+ // An unanswered outreach is the one state in this feed worth
3012
+ // a colour: it is the only entry that owes somebody
3013
+ // something. Everything else is history and reads neutral.
3014
+ iconColor: a.awaiting ? solid("amber") : colors.zinc[400],
3015
+ // NEVER a phrase assembled from the enums ("Inbound Call").
3016
+ // That renders in body ink and so claims somebody wrote it;
3017
+ // `placeholder` is how the row says the words are missing,
3018
+ // and the medium and direction are already on the line below.
3019
+ label: a.gist ?? "Not written up yet",
3020
+ placeholder: a.gist == null,
3021
+ // The metadata line — a real preposition and a comma, never
3022
+ // a middot standing in for the relation (§Microcopy) — and
3023
+ // then WHAT ARRIVED, because a closed row is the scanning
3024
+ // state and on an entry with no words the attachments are
3025
+ // the entire content. Written out rather than glyphed: an
3026
+ // icon run needs a key the reader has to learn first, and
3027
+ // this line is already here.
3028
+ description:
3029
+ `${a.direction === "in" ? "From them" : "From us"} over ${a.over}, ${a.when}.` +
3030
+ arrivedWith(a),
3031
+ // The row's verbs. `right` sits BESIDE the press target, so a
3032
+ // menu here is independently clickable and does not nest a
3033
+ // button inside the row's own button.
3034
+ //
3035
+ details: (
3036
+ <ActivityBody
3037
+ a={a}
3038
+ editing={editingActivity === a.key}
3039
+ onEdit={(patch) =>
3040
+ setActivity((prev) =>
3041
+ prev.map((e) => (e.key === a.key ? { ...e, ...patch } : e)),
3042
+ )
3043
+ }
3044
+ onToggleEdit={() =>
3045
+ setEditingActivity((cur) => (cur === a.key ? null : a.key))
3046
+ }
3047
+ onDelete={() => {
3048
+ setActivity((prev) => prev.filter((e) => e.key !== a.key));
3049
+ setEditingActivity((cur) => (cur === a.key ? null : cur));
3050
+ }}
3051
+ />
3052
+ ),
3053
+ }),
3054
+ )}
3055
+ />
3056
+ {/* The tail FOLDS rather than paging. A record's feed is read
3057
+ newest-first and the old entries are reference — but they are
3058
+ still on the record, so the count says how much is behind the
3059
+ toggle instead of hiding that there is more. */}
3060
+ {activity.length > ACTIVITY_FOLD ? (
3061
+ // A REVEAL, so the same control as the transcript's: it shows
3062
+ // more of what is already here, in place. It was a
3063
+ // `Button color="muted"` — transparent, borderless and
3064
+ // undecorated at rest, so the affordance only arrived on hover
3065
+ // and it read as a stray line under the feed. It also sat at
3066
+ // 420: its own 10px padding, which matches neither the section's
3067
+ // edge nor the rows' text column, so it aligned to nothing.
3068
+ //
3069
+ // No wrapper and no indent: the 44px the rows are inset by is
3070
+ // the disc rail, and at this line the rail has ended. An indent
3071
+ // is legitimate only where something visible occupies it.
3072
+ <View style={{ alignItems: "flex-start" }}>
3073
+ <TextDisclosure
3074
+ expanded={showAllActivity}
3075
+ onToggle={setShowAllActivity}
3076
+ label={`${activity.length - ACTIVITY_FOLD} earlier`}
3077
+ />
3078
+ </View>
3079
+ ) : null}
3080
+ </>
3081
+ )}
3082
+ </Section>
3083
+ </View>
3084
+
2244
3085
 
2245
3086
  {/* FEES — the DETAILED money ledger, both directions (charge = billed
2246
3087
  to the customer, cost = paid to a vendor), distinct from Billing's
@@ -2379,6 +3220,32 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2379
3220
  <DetailRow label="Opened">
2380
3221
  <InlineDatePicker value={orderDate} onSave={persist(setOrderDate)} locale="en-US" accessibilityLabel="Opened date" />
2381
3222
  </DetailRow>
3223
+ {/* FILES AS A FIELD, the SINGLE case — one signed contract, not
3224
+ a document set.
3225
+ `multiple={false}` changes the verb, and the verb is the
3226
+ point: "add another" is not something a one-file field can
3227
+ do, and offering it produces a record holding two of a thing
3228
+ the schema says there is one of. So the CTA reads REPLACE,
3229
+ the picker takes one, and the host SETS instead of appending
3230
+ — the component gets the affordance right but cannot enforce
3231
+ the persistence, which is why the difference is visible right
3232
+ here at the call site.
3233
+
3234
+ `blockedReason` is the third state: a field with nothing to
3235
+ attach TO yet says which act unblocks it, rather than showing
3236
+ a disabled button that says only that you may not. */}
3237
+ <DetailRow label="Signed contract">
3238
+ <InlineFiles
3239
+ files={contract === null ? [] : [contract]}
3240
+ multiple={false}
3241
+ onAdd={([picked]) => setContract({ id: "doc-contract", filename: picked.name, mimeType: picked.type || PDF, url: URL.createObjectURL(picked) })}
3242
+ onRemove={() => setContract(null)}
3243
+ addLabel="Attach contract"
3244
+ replaceLabel="Replace contract"
3245
+ accept="application/pdf"
3246
+ blockedReason={taxId.trim() === "" ? "Add the tax ID before filing a contract." : undefined}
3247
+ />
3248
+ </DetailRow>
2382
3249
  {/* The countdown ANNOTATES the date it is about — the worked
2383
3250
  example for @lotics/ui/deadline. It replaced a hand-rolled
2384
3251
  `error={overdue ? … }`, which was binary: a date due
@@ -2689,20 +3556,39 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2689
3556
  its NAME: a PDF thumbnail is a grey page identical to every other grey
2690
3557
  page, so a grid of them is a wall of one tile. A photo is identified
2691
3558
  by its CONTENT — `IMG_4471.jpg` tells nobody anything — so the tile IS
2692
- the identity and a grid puts twelve on screen at once. Same record,
2693
- same verbs, different surface, decided by what the reader recognises.
3559
+ the identity and a grid puts twelve on screen at once.
2694
3560
  (AGENTS.md: documents get a LIST, images get a GRID.)
2695
3561
 
2696
- READ THIS BEFORE COPYING THE SHAPE. Two sections here does NOT mean
2697
- "sort one pile by mime type into a list and a grid". This record holds
2698
- two distinct SETS the paperwork that arrived on it, and the hand-over
2699
- photos taken against it which the reader asks different questions of.
2700
- A SINGLE pile picks ONE surface by its majority and keeps it: the odd
2701
- .docx among forty phone photos stays in the grid, where it renders a
2702
- `DocumentCard` printing its filename, and the odd photo among forty
2703
- PDFs stays in the list, where its row carries the full name and a press
2704
- opens the gallery. Splitting one set by type would give the reader two
2705
- places to look for the same thing.
3562
+ BOTH SURFACES SHOW THE WHOLE COLLECTION they are two LENSES over
3563
+ one set, not two sets. Files answers "what is on this record" in a
3564
+ scannable column; Photos answers "what did it LOOK like" at a glance.
3565
+ A reader who guesses wrong still finds the artifact instead of
3566
+ concluding it was never filed, and nobody has to know a file's FORMAT
3567
+ before they know where to look for it.
3568
+
3569
+ The grid used to filter to `image/*`. That made the split a fact about
3570
+ MIME rather than about reading, and it put the signed delivery note in
3571
+ a different section from the photos of the same hand-over — while this
3572
+ very comment promised that the odd `.docx` among forty phone photos
3573
+ would stay in the grid. It could not: the filter forbade what the
3574
+ sentence described. `FileThumbnail` already renders a non-image as a
3575
+ doc tile (badge + filename), so nothing had to be built — only the
3576
+ filter that hid it had to go.
3577
+
3578
+ THE COST IS REAL AND DELIBERATE: every file now appears twice. The
3579
+ trade is that duplication is cheap to skim past, whereas an artifact
3580
+ filed under the wrong lens looks like an artifact that was never
3581
+ filed. A record whose attachments are overwhelmingly paperwork should
3582
+ drop the grid entirely rather than carry a wall of identical tiles —
3583
+ the choice is per-record-TYPE, made once, not per file.
3584
+
3585
+ Ordering follows the collection, so a record heavy in documents leads
3586
+ this grid with grey tiles. If that reads badly for your record type,
3587
+ sort images first rather than reinstating the filter.
3588
+
3589
+ The tiles are mixed by construction now, so the FIXTURE has to be
3590
+ mixed too — a grid of eight JPGs never once renders the doc tile it
3591
+ has always been able to render, which is how the gap survived.
2706
3592
 
2707
3593
  The ADD rides the heading row, right edge, exactly as it does on Files
2708
3594
  and Fees — a section's add sits in the same spot whether the section
@@ -2777,7 +3663,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2777
3663
  title="Add photos"
2778
3664
  color="primary"
2779
3665
  onPress={() => {
2780
- void pickFiles({ accept: "image/*", multiple: true }).then(intakeFiles);
3666
+ void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
2781
3667
  }}
2782
3668
  />
2783
3669
  )}
@@ -3058,6 +3944,35 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3058
3944
  <DetailRow label="Invoice no" description="The party's invoice or debit note">
3059
3945
  <InlineTextInput value={f.invoiceNo} onSave={persist((v: string) => patchFee(f.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
3060
3946
  </DetailRow>
3947
+ {/* THE FILE, ON THE ROW THAT OWES IT — the shape a
3948
+ payables desk actually works in: a table of
3949
+ charges, each row expanding to the document that
3950
+ justifies it. The register cell above can only
3951
+ MARK whether a document is held (and on this page
3952
+ not even that — the fee table measures 400px and
3953
+ its declared columns already total 422, so two
3954
+ drop before the flex column gets a pixel). The
3955
+ managing happens here.
3956
+
3957
+ `multiple`, because a supplier invoice arrives as
3958
+ one PDF or as three scanned pages, and the second
3959
+ page must not replace the first.
3960
+
3961
+ `blockedReason` when no number has been claimed:
3962
+ there is nothing to file the scan AGAINST yet, and
3963
+ a sentence naming that act beats a disabled button
3964
+ that only says no. */}
3965
+ <DetailRow label="Supplier original">
3966
+ <InlineFiles
3967
+ files={f.docs}
3968
+ onAdd={(picked) => patchFee(f.id, { docs: [...f.docs, ...picked.map(asDisplayFile)] })}
3969
+ onRemove={(doc) => patchFee(f.id, { docs: f.docs.filter((d) => d.id !== doc.id) })}
3970
+ addLabel="Attach original"
3971
+ addMoreLabel="Add page"
3972
+ accept="application/pdf,image/*"
3973
+ blockedReason={f.invoiceNo.trim() === "" ? "Enter the invoice number first — the scan is filed against it." : undefined}
3974
+ />
3975
+ </DetailRow>
3061
3976
  <DetailRow label="Status">
3062
3977
  <InlineSelect
3063
3978
  value={f.paid ? "paid" : "unpaid"}
@@ -3096,11 +4011,51 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3096
4011
  </View>
3097
4012
  }
3098
4013
  >
3099
- <TableCell><Text size="sm" weight="medium" numberOfLines={1}>{f.name || ""}</Text></TableCell>
3100
- <TableCell><Text size="sm" color="muted">{f.direction === "charge" ? "Charge" : "Cost"}</Text></TableCell>
3101
- <TableCell><Text size="sm" color="muted" numberOfLines={1}>{f.party || "—"}</Text></TableCell>
3102
- <TableCell><Text size="sm" tabular numberOfLines={1}>{formatMoney(f.amount)}</Text></TableCell>
3103
- <TableCell><Text size="sm" color={st.danger ? "danger" : "muted"} tabular numberOfLines={1}>{st.text}</Text></TableCell>
4014
+ {/* IDENTITY over its PARTY `tpl_item_list`'s customer cell
4015
+ exactly: the thing you scan for on line 1, what qualifies
4016
+ it on line 2, in one cell rather than two columns. */}
4017
+ <TableCell>
4018
+ <View style={{ gap: 2 }}>
4019
+ <Text size="sm" weight="medium" numberOfLines={1}>{f.name || "—"}</Text>
4020
+ <Text size="xs" color="muted" numberOfLines={1}>
4021
+ {f.party || (f.direction === "charge" ? "Charge" : "Cost")}
4022
+ </Text>
4023
+ </View>
4024
+ </TableCell>
4025
+ {/* AMOUNT over its PAYMENT state — the register's fee cell,
4026
+ same stack, same rungs. The state qualifies the number it
4027
+ sits under, so it needs no column and no repeated label. */}
4028
+ <TableCell>
4029
+ <View style={{ gap: 2, alignItems: "flex-end" }}>
4030
+ <Text size="sm" tabular numberOfLines={1}>{formatMoney(f.amount)}</Text>
4031
+ <Text size="xs" tabular numberOfLines={1} color={st.danger ? "danger" : "muted"}>{st.text}</Text>
4032
+ </View>
4033
+ </TableCell>
4034
+ {/* THE DOCUMENT, with a column of its own at last.
4035
+ Held: a clip + the number, pressable to preview. Claimed
4036
+ but not arrived: the number in WARNING tone — the fee
4037
+ cannot be paid, and that now reads in the column the fact
4038
+ belongs to instead of borrowing the money's state. Never
4039
+ claimed (cash at the gate): an em dash, because nothing is
4040
+ owed and a permanent warning on every such row trains the
4041
+ eye straight past the column. */}
4042
+ <TableCell>
4043
+ {f.invoiceNo.trim() === "" ? (
4044
+ <Text size="sm" color="muted">—</Text>
4045
+ ) : f.docs.length === 0 ? (
4046
+ <Text size="sm" color="muted" numberOfLines={1}>{f.invoiceNo}</Text>
4047
+ ) : (
4048
+ <Pressable
4049
+ onPress={() => openPreview(DOCS, 0)}
4050
+ accessibilityRole="button"
4051
+ accessibilityLabel={`Open invoice ${f.invoiceNo}`}
4052
+ style={{ flexDirection: "row", alignItems: "center", gap: 6 }}
4053
+ >
4054
+ <FileBadge mimeType={f.docs[0].mimeType} size={22} />
4055
+ <TextLink size="sm">{f.invoiceNo}</TextLink>
4056
+ </Pressable>
4057
+ )}
4058
+ </TableCell>
3104
4059
  </TableRow>
3105
4060
  );
3106
4061
  })}
@@ -3391,11 +4346,17 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3391
4346
  })}
3392
4347
  </Checklist>
3393
4348
  {hidden > 0 ? (
3394
- <View style={{ flexDirection: "row" }}>
3395
- <Button
3396
- title={open ? "Show fewer forms" : `Show all forms (${hidden} hidden)`}
3397
- color="muted"
3398
- onPress={() => toggleFold(g.id)}
4349
+ // The same reveal, the same control. The label is the NOUN
4350
+ // only — `TextDisclosure` supplies the verb, which is what
4351
+ // stops a pair drifting into two framings ("Show all forms
4352
+ // (3 hidden)" against "Show fewer forms" named different
4353
+ // things in each state, so pressing it twice taught the
4354
+ // reader nothing about what it does).
4355
+ <View style={{ alignItems: "flex-start" }}>
4356
+ <TextDisclosure
4357
+ expanded={open}
4358
+ onToggle={() => toggleFold(g.id)}
4359
+ label={`${hidden} more forms`}
3399
4360
  />
3400
4361
  </View>
3401
4362
  ) : null}