@lotics/ui 41.1.0 → 41.4.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,20 +1,24 @@
1
1
  import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "react";
2
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";
11
15
  import { TextLink } from "@lotics/ui/text_link";
12
16
  import { Alert } from "@lotics/ui/alert";
13
17
  import type { PickerOption } from "@lotics/ui/picker";
14
18
  import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
15
19
  import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
16
20
  import { Callout, CalloutText } from "@lotics/ui/callout";
17
- 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";
18
22
  import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
19
23
  import { Checklist, ChecklistActions, ChecklistGroup, ChecklistItem, ChecklistNote } from "@lotics/ui/checklist";
20
24
  import { DateStamp } from "@lotics/ui/date_stamp";
@@ -50,6 +54,10 @@ import { FormField } from "@lotics/ui/form_field";
50
54
  import { TextInputField } from "@lotics/ui/text_input_field";
51
55
  import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
52
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";
53
61
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
54
62
  import { DangerZone } from "@lotics/ui/danger_zone";
55
63
  import { FileRow } from "@lotics/ui/file_row";
@@ -298,6 +306,578 @@ const PRIORITY_DOT: Record<string, string> = {
298
306
  rush: colors.amber[500],
299
307
  };
300
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
+
301
881
  // ── billing — charges grouped into issuable invoice DOCUMENTS on this record
302
882
  type Method = "cash" | "transfer" | "card";
303
883
  const METHODS: PickerOption<Method>[] = [
@@ -1251,6 +1831,19 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1251
1831
  : f.due !== "" ? { text: `Due ${new Date(f.due).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`, danger: false }
1252
1832
  : { text: "Unpaid", danger: false };
1253
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
+
1254
1847
  // ── the record — seeded mid-flight so every state is visible
1255
1848
  const [stage, setStage] = useState<Stage>("sales");
1256
1849
  // The customer book is STATE: the attached customer's tax ID edits inline in
@@ -1949,6 +2542,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1949
2542
  // The rail lists sections in PAGE order, or the scroll-spy highlights one
1950
2543
  // entry while the reader is looking at another.
1951
2544
  { key: "progress", label: "Progress", icon: "git-branch" },
2545
+ { key: "activity", label: "Activity", icon: "message-square" },
1952
2546
  { key: "general", label: "General", icon: "file-text" },
1953
2547
  // Only a SECTION when the page is too narrow to seat the panel — so the wide
1954
2548
  // rail filters it out rather than offering a jump to something that is not
@@ -1971,7 +2565,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1971
2565
  // by scroll and invisible in the outline. Bound to `SecKey`, a key here that
1972
2566
  // `SECTIONS` does not carry stops being assignable, so the omission is a
1973
2567
  // compile error instead of a missing row nobody notices.
1974
- 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);
1975
2569
  // ONE record, ONE page. A section is a place you SCROLL to, never a
1976
2570
  // destination you swap to: routing a record was tried here and lost, because
1977
2571
  // every fix it needed rebuilt the whole-record view in miniature — a dot to
@@ -2334,6 +2928,160 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2334
2928
  </Section>
2335
2929
  </View>
2336
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
+
2337
3085
 
2338
3086
  {/* FEES — the DETAILED money ledger, both directions (charge = billed
2339
3087
  to the customer, cost = paid to a vendor), distinct from Billing's
@@ -3598,11 +4346,17 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3598
4346
  })}
3599
4347
  </Checklist>
3600
4348
  {hidden > 0 ? (
3601
- <View style={{ flexDirection: "row" }}>
3602
- <Button
3603
- title={open ? "Show fewer forms" : `Show all forms (${hidden} hidden)`}
3604
- color="muted"
3605
- 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`}
3606
4360
  />
3607
4361
  </View>
3608
4362
  ) : null}