@lotics/ui 43.1.0 → 43.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,14 +1,13 @@
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, solid } from "@lotics/ui/colors";
4
+ import { colors, solid, withAlpha } 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
10
  import { Icon, type IconName } from "@lotics/ui/icon";
11
- import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
12
11
  import { MediaPlayer } from "@lotics/ui/media_player";
13
12
  import { Markdown } from "@lotics/ui/markdown";
14
13
  import { TextDisclosure } from "@lotics/ui/text_disclosure";
@@ -40,6 +39,7 @@ import { FileBadge } from "@lotics/ui/file_badge";
40
39
  import { FileRows } from "@lotics/ui/file_rows";
41
40
  import { InlineFiles } from "@lotics/ui/inline_files";
42
41
  import { FileGrid } from "@lotics/ui/file_grid";
42
+ import { FilesEditor } from "@lotics/ui/files_editor";
43
43
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
44
44
  import { InlineStatic } from "@lotics/ui/inline_static";
45
45
  import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
@@ -52,12 +52,9 @@ import { MemberSelect, type MemberSelectMember } from "@lotics/ui/member_select"
52
52
  import { EmptyState } from "@lotics/ui/empty_state";
53
53
  import { FormField } from "@lotics/ui/form_field";
54
54
  import { TextInputField } from "@lotics/ui/text_input_field";
55
- import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
55
+ import { FileThumbnail, THUMBNAIL_SIZE, type DisplayFile } from "@lotics/ui/file_thumbnail";
56
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
57
  import { Inset } from "@lotics/ui/inset";
60
- import { isImageMimeType } from "@lotics/ui/mime";
61
58
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
62
59
  import { DangerZone } from "@lotics/ui/danger_zone";
63
60
  import { FileRow } from "@lotics/ui/file_row";
@@ -330,19 +327,9 @@ interface ActivityEntry {
330
327
  * and the row has to say so rather than borrow a phrase from the enums.
331
328
  */
332
329
  gist?: string;
333
- direction: "in" | "out";
334
330
  /** The medium as a person would say it, not an enum: "Zalo", "Google Meet". */
335
331
  over: string;
336
332
  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
333
  /** An unanswered outreach is a real state and reads differently from a reply. */
347
334
  awaiting?: boolean;
348
335
 
@@ -370,15 +357,16 @@ interface ActivityEntry {
370
357
  * Two rules, both learned by getting this wrong. It must be CAPTURABLE: a
371
358
  * call's attendees are stored nowhere, so a line naming them can only be
372
359
  * 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.
360
+ * And it must be ADDITIVE: the record IS the counterparty, so "From <them>, to
361
+ * <us>" is a fact the surface already carries. What is left is the third party
362
+ * which is the entire value of the block.
377
363
  */
378
364
  participants?: string;
379
365
  /** An email's subject — the one thing an email has that nothing else does. */
380
366
  subject?: string;
381
- /** Prose a PERSON wrote: an email body, a note. Markdown. */
367
+ /** The message a COUNTERPARTY sent an email body. Markdown, and read-only:
368
+ * it is a record of what they said, which is the same reason a transcript is
369
+ * not editable. Our own write-up of an entry is `gist`, which is a field. */
382
370
  body?: string;
383
371
  /** Prose a MODEL wrote. Separate from `body` rather than a flag on it, because
384
372
  * one entry routinely holds both — a rep's own note and the machine's reading
@@ -459,10 +447,8 @@ const ACTIVITY: ActivityEntry[] = [
459
447
  // Note the author: a bot, which is why `by` is not a name string.
460
448
  key: "a0",
461
449
  kind: "call",
462
- direction: "in",
463
450
  over: "Phone",
464
451
  when: "Today, 11:40",
465
- by: { kind: "system", name: "Recording bot" },
466
452
  media: { src: "/sample-audio.mp3", label: "Call recording" },
467
453
  transcript: CALL_TRANSCRIPT,
468
454
  },
@@ -473,10 +459,8 @@ const ACTIVITY: ActivityEntry[] = [
473
459
  key: "a3",
474
460
  kind: "video",
475
461
  gist: "Demo — the reconciliation step is what sold it; pricing still open",
476
- direction: "out",
477
462
  over: "Google Meet",
478
463
  when: "Today, 10:15",
479
- by: { kind: "member", name: "Sarah Chen" },
480
464
  media: { src: "/sample-video.webm", label: "Demo recording" },
481
465
  transcript: TRANSCRIPT,
482
466
  // NO human note on this entry, deliberately. It carried "the person who
@@ -494,24 +478,21 @@ const ACTIVITY: ActivityEntry[] = [
494
478
  key: "a1",
495
479
  kind: "note",
496
480
  gist: "They want the integration layer built and maintained, not the platform replaced",
497
- direction: "out",
498
481
  over: "Note",
499
482
  when: "Today, 09:12",
500
- by: { kind: "member", name: "Sarah Chen" },
501
483
  },
502
484
  {
503
485
  key: "a1b",
504
486
  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.
487
+ // A LONG one, to prove the entry grows with its prose instead of clamping it.
488
+ // This used to be stored twice the same string as `gist` AND as `body` —
489
+ // on the argument that one was a truncation of the other. It was not: both
490
+ // rendered, one under the other, and the reader saw the sentence twice. The
491
+ // field auto-grows, so the long form IS the value and there is nothing to
492
+ // drill into.
509
493
  gist: LONG_NOTE,
510
- body: LONG_NOTE,
511
- direction: "in",
512
494
  over: "Site visit",
513
495
  when: "Today, 08:05",
514
- by: { kind: "member", name: "Sarah Chen" },
515
496
  },
516
497
  {
517
498
  // AN EMAIL carries three things nothing else does: who it was between, what
@@ -523,10 +504,8 @@ const ACTIVITY: ActivityEntry[] = [
523
504
  subject: "Re: Pricing for the customs documentation module",
524
505
  participants: "Copied to accounts@",
525
506
  gist: "Asked for the per-document price in writing before the board meeting",
526
- direction: "in",
527
507
  over: "Email",
528
508
  when: "Yesterday, 16:40",
529
- by: { kind: "system", name: "Mailbox sync" },
530
509
  body:
531
510
  "Thanks for the walkthrough. Before I take this to the board on Thursday I need the " +
532
511
  "per-document price **in writing**, and confirmation that the customs forms come out of " +
@@ -542,10 +521,8 @@ const ACTIVITY: ActivityEntry[] = [
542
521
  key: "a6",
543
522
  kind: "message",
544
523
  gist: "Asked in the forwarders' group who handles Japan customs paperwork",
545
- direction: "in",
546
524
  over: "Facebook Group",
547
525
  when: "10 Aug, 21:30",
548
- by: { kind: "system", name: "Capture" },
549
526
  files: [
550
527
  { id: "s1", filename: "post.png", mimeType: "image/png", url: img("Post", "#3f3f46") },
551
528
  { id: "s2", filename: "profile.png", mimeType: "image/png", url: img("Profile", "#52525b") },
@@ -556,10 +533,8 @@ const ACTIVITY: ActivityEntry[] = [
556
533
  key: "a4",
557
534
  kind: "call",
558
535
  gist: "34 minutes — locked into their current platform for six months, wants us to sit on top of it",
559
- direction: "out",
560
536
  over: "Phone",
561
537
  when: "3 Aug, 11:20",
562
- by: { kind: "member", name: "Sarah Chen" },
563
538
  media: { src: "/sample-audio.mp3", label: "Call recording" },
564
539
  transcript: CALL_TRANSCRIPT,
565
540
  bodyByAi:
@@ -573,50 +548,13 @@ const ACTIVITY: ActivityEntry[] = [
573
548
  key: "a5",
574
549
  kind: "message",
575
550
  gist: "Sent the one-page summary; no reply yet",
576
- direction: "out",
577
551
  over: "Zalo",
578
552
  when: "28 Jul, 18:05",
579
- by: { kind: "member", name: "Sarah Chen" },
580
553
  awaiting: true,
581
554
  files: [{ id: "f-sum", filename: "Summary — one page.pdf", mimeType: "application/pdf", url: "/sample.pdf" }],
582
555
  },
583
556
  ];
584
557
 
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
558
  /**
621
559
  * THE ACTIVITY BODY — one component, every block, each rendered only if the
622
560
  * entry carries it.
@@ -628,60 +566,201 @@ function arrivedWith(a: ActivityEntry): string {
628
566
  * source, then what people made of it, then what came with it, then where it
629
567
  * came from and who filed it.
630
568
  */
631
- function ActivityBody({
569
+ /**
570
+ * ONE ENTRY in the activity feed — a comment, not a log line.
571
+ *
572
+ * `Timeline` renders its label INSIDE the row's press target. That is right for
573
+ * an event whose text is derived and read-only ("Stage changed to Won"), and
574
+ * wrong here, because the gist is the one thing on an entry a PERSON wrote, and
575
+ * text inside a button cannot be edited where it sits. Four repairs came out of
576
+ * working around that — the verbs moved off the row, the label learned to
577
+ * un-clamp, the body's second copy of the gist came out, and the Edit verb ended
578
+ * up a scroll away from the sentence it edits. None were independent: each was
579
+ * the arrangement pushing back.
580
+ *
581
+ * So the gist stops being a label. It is a RESIDENT field, always editable in
582
+ * place, with no mode and no Edit verb — the same treatment every other authored
583
+ * value on this record gets. The old argument against a resident editor here
584
+ * ("the gist is already the row's label, so it renders the sentence twice")
585
+ * disappears the moment it stops being one.
586
+ */
587
+ function ActivityEntryRow({
632
588
  a,
633
- editing,
634
589
  onEdit,
635
- onToggleEdit,
636
590
  onDelete,
637
591
  }: {
638
592
  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;
593
+ onEdit: (patch: Partial<Pick<ActivityEntry, "gist" | "files">>) => void;
644
594
  onDelete: () => void;
645
595
  }) {
646
596
  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));
597
+ const files = a.files ?? [];
598
+ // ONE function behind every way in — the menu verb, and a drop onto the entry.
599
+ // Two paths that append to the same list must not be two implementations of
600
+ // appending: that is where one of them ends up REPLACING instead, silently,
601
+ // months later.
602
+ const addFiles = (picked: File[]) => {
603
+ if (picked.length === 0) return;
604
+ onEdit({ files: [...files, ...picked.map(asDisplayFile)] });
605
+ };
606
+ // The menu verb exists because it has to work on an entry that has NO files
607
+ // yet — that entry has no list to hang a CTA off, and no obvious place to aim
608
+ // a drag either.
609
+ const attach = () => { void pickFiles({ accept: ATTACH_ACCEPT, multiple: true }).then(addFiles); };
610
+ const accent = a.awaiting ? solid("amber") : colors.zinc[400];
611
+ // What the fold contains, named so the reader can decide without opening it.
612
+ // NOT `arrivedWith` — that answers a different question (it speaks only when
613
+ // the entry has no gist, because there the attachments are the whole content)
614
+ // and reusing it here left the fold unrendered, and the recording unreachable,
615
+ // on every entry somebody had written up.
649
616
 
650
617
  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 ? (
618
+ /* AN ENTRY IS ITS OWN DROP REGION — a file dropped on THIS conversation
619
+ attaches to it, rather than travelling to the record's Documents intake
620
+ and leaving the reader to relate the two by hand.
621
+
622
+ Nesting inside the whole-record target is sound rather than lucky:
623
+ `FileDropTarget` stops propagation on every drag/drop event, so the
624
+ innermost region wins and the outer one neither fires nor lights. The
625
+ target paints its own affordance from a plain child, which is what makes
626
+ the destination legible mid-drag with two live regions the drag has to
627
+ say WHICH one it is about to land in. */
628
+ <FileDropTarget onFiles={addFiles} accept={ATTACH_ACCEPT} style={{ flexDirection: "row", gap: 12 }}>
629
+ {/* The medium as a glyph, replacing the disc and spine. A feed of ENTRIES
630
+ reads as a stack; a spine drawn past an editable field reads as
631
+ decoration rather than as sequence. */}
632
+ <View
633
+ style={{
634
+ width: 32,
635
+ height: 32,
636
+ borderRadius: 16,
637
+ alignItems: "center",
638
+ justifyContent: "center",
639
+ backgroundColor: withAlpha(accent, 0.1),
640
+ }}
641
+ >
642
+ <Icon name={ACTIVITY_ICON[a.kind]} size={16} color={accent} />
643
+ </View>
644
+
645
+ <View style={{ flex: 1, gap: 8 }}>
646
+ {/* THE BYLINE — over what, and when. Derived, so none of it is editable,
647
+ and none of it repeats the prose below.
648
+
649
+ NO DIRECTION. "From us over Note" is a category error — a note has no
650
+ counterparty to be from — and the shape that produces it is the tell:
651
+ direction was being stamped on every entry whether or not the medium
652
+ had two ends. On the media that do have two ends it is real but
653
+ already said twice over: its one ACTIONABLE consequence (we reached
654
+ out, nobody answered) is the amber `awaiting` mark on the glyph, and
655
+ the rest is history the gist states in words — "Sent the one-page
656
+ summary", "They want the integration layer built". Eight characters
657
+ of chrome down the whole column to restate the sentence beneath.
658
+
659
+ NO AUTHOR. It carried one for a while, in two shapes: a face for a
660
+ person, `WaveAvatar` for an automation. Both are gone, and the reason
661
+ the machine one was wrong is the reason the human one had to go with
662
+ it. A recording is MADE by the people in the meeting and merely
663
+ transcribed by a service, so "Recording bot" named the plumbing and
664
+ not the author — a fabricated byline on somebody else's conversation.
665
+ Once that shape is refused, showing an author on the entries that
666
+ happen to have one is worse than showing none: the reader learns that
667
+ the byline is present when we know it, which is a fact about our
668
+ pipeline. The record has an Owner, and an entry on a customer's
669
+ history does not restate it eight times.
670
+
671
+ What a machine actually TOUCHED is still visible where it changes how
672
+ you read something — the fold names an "AI summary", and a transcript
673
+ is labelled verbatim. That is attribution of the CONTENT, which is
674
+ the only kind that was ever load-bearing here.
675
+
676
+ The actions sit at the TOP RIGHT rather than a footer — a footer put
677
+ Edit a scroll away from the sentence it edits. The menu is a SIBLING
678
+ of the content, so it nests no button inside another. */}
679
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
680
+ <View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
681
+ <Text size="xs" color="muted">
682
+ {`${a.over}, ${a.when}`}
683
+ </Text>
684
+ {a.sourceUrl ? <Link size="xs" onPress={() => {}}>{a.sourceUrl}</Link> : null}
685
+ </View>
686
+ <ActionMenu
687
+ accessibilityLabel="Entry actions"
688
+ items={[
689
+ {
690
+ key: "attach",
691
+ label: "Attach files",
692
+ icon: "paperclip",
693
+ onPress: attach,
694
+ },
695
+ {
696
+ key: "delete",
697
+ label: "Delete",
698
+ icon: "trash",
699
+ danger: true,
700
+ onPress: () =>
701
+ Alert.alert(
702
+ "Delete this entry?",
703
+ "It leaves the customer's history and stops counting toward the activity total. This cannot be undone.",
704
+ [
705
+ { text: "Cancel", style: "cancel" },
706
+ { text: "Delete", style: "destructive", onPress: onDelete },
707
+ ],
708
+ ),
709
+ },
710
+ ]}
711
+ />
712
+ </View>
713
+
714
+ {/* THE WORDS A PERSON WROTE — resident, always editable in place. An
715
+ entry nobody has written up shows the field and invites the sentence;
716
+ one that has been written up simply reads. That empty case is the
717
+ common one on a feed more than one writer fills: an automation files
718
+ an entry the moment a recording lands, with no words in it at all. */}
719
+ {/* THE VARIANT FOLLOWS THE VALUE, because the two states are asking for
720
+ different things. Written up, this is CONTENT: `bare`, reading as
721
+ prose, editable where it sits. Empty, it is a REQUEST for content —
722
+ and bare grey placeholder text on an invisible box does not read as
723
+ somewhere you can type, which is exactly the complaint. `framed`
724
+ gives it the resting border every other empty field on the record
725
+ has, so it is recognisable as an input from across the page.
726
+
727
+ A button ("Add a summary") was the other candidate and loses on the
728
+ click: `InlineTextInput` deliberately has no `autoFocus` (mounted
729
+ permanently it would have every field on a record fight for focus on
730
+ load), so a revealed field cannot take the caret — press the button,
731
+ then click the field, then type. The framed box is one click, and it
732
+ spends its extra ink only on the entries that actually want words. */}
676
733
  <InlineTextInput
734
+ variant={a.gist ? "bare" : "framed"}
677
735
  value={a.gist ?? ""}
678
736
  onSave={(v) => onEdit({ gist: v.trim() || undefined })}
679
737
  placeholder="What came of it?"
680
738
  accessibilityLabel="What came of it"
681
- numberOfLines={2}
739
+ // ONE line is the floor, not two. `autoGrow` takes care of the ceiling,
740
+ // so a minimum of 2 bought nothing and cost an empty second line on
741
+ // every entry nobody had written up — which is most of them on a feed
742
+ // an automation also files into, and precisely what made a stack of
743
+ // entries read as a stack of form fields.
744
+ numberOfLines={1}
682
745
  autoGrow
683
746
  />
684
- ) : null}
747
+
748
+ {/* WHAT CAME WITH IT — SHOWN, not folded. These blocks ARE the entry;
749
+ a control that hides them makes the reader click to discover what the
750
+ entry already told them is there, on every entry, forever.
751
+
752
+ The fold was defended as "each is tall enough to bury the next
753
+ entry", which measurement did not support: an audio player is 54px,
754
+ a file tile row is 96. Hiding 54px behind a 20px disclosure buys 34px
755
+ and costs a click. Only two things here are genuinely unbounded, and
756
+ only ONE of them stays folded — see the transcript below.
757
+
758
+ A video is the honest counter-case at 358px, and it still shows: it
759
+ is the single most informative thing on a recorded call, the feed
760
+ already folds its own tail, and the byline and gist stay at the top
761
+ of every entry so the scan survives a tall one. */}
762
+ <View style={{ gap: 12, alignSelf: "stretch" }}>
763
+
685
764
 
686
765
  {/* HEADER — who it was between, and what it was called. A subject line
687
766
  never says who was on it, and on a call there is no subject at all, so
@@ -739,26 +818,16 @@ function ActivityBody({
739
818
  </View>
740
819
  ) : null}
741
820
 
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 ? (
821
+ {/* THE COUNTERPARTY'S OWN WORDS, on an email. It is a record of what THEY
822
+ sent, so it renders as markdown and is not ours to rewrite — the same
823
+ reason the transcript is not editable.
824
+
825
+ There is NO second prose field beside it. An entry used to offer a
826
+ gist AND a "note", two free-text boxes for one event with nothing to
827
+ tell a writer which to use; the gist auto-grows, so it already carries
828
+ whatever length someone wants. Two fields for one thought is a choice
829
+ the surface was making the reader make. */}
830
+ {a.kind === "email" && a.body ? (
762
831
  <Markdown variant="embedded">{a.body}</Markdown>
763
832
  ) : null}
764
833
 
@@ -786,90 +855,53 @@ function ActivityBody({
786
855
  </Inset>
787
856
  ) : null}
788
857
 
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>
858
+ {/* WHAT CAME WITH IT a GRID, the same as a posted chat message's
859
+ attachments and the same as this record's own Files section.
860
+
861
+ It was a row list for a while, on a rule I got wrong: "staged files are
862
+ tiles, filed files are rows". The rule held up only against the fee
863
+ ledger's "Supplier original", which is a labelled FIELD in a
864
+ `DetailRow` a different thing from the attachments on a message. The
865
+ two real precedents both disagreed with me: `chat_user_message` renders
866
+ a posted message's files as a `FileGrid`, and so does the record's own
867
+ Files section. An entry's files are evidence a scanned PO, a photo of
868
+ a seal recognised by looking, which is what a tile is for.
869
+
870
+ `FilesEditor`, not a bare `FileGrid`, because a filed attachment must be
871
+ HARD to lose. The grid's ✕ sits on every tile, one click from gone; the
872
+ editor's default view never draws one at all, and the only way to
873
+ remove is to open the file and do it from the gallery where you are
874
+ looking at the thing you are about to delete, and it still asks. Three
875
+ pieces I had hand-rolled on top of `FileGrid` come with it: the
876
+ confirmation, the full-screen gallery, and the press-to-open wiring.
877
+
878
+ NO BAR. `FilesEditor` renders its children as an action row and nothing
879
+ without them, so a per-entry Select/Remove toolbar is opt-in and a
880
+ feed of eight entries does not want eight toolbars. Select mode is
881
+ reachable only from that bar, so leaving it out removes the mode
882
+ entirely rather than stranding it. Adding stays on the entry's menu, so
883
+ there is still exactly one add path. */}
884
+ {files.length ? (
885
+ <FilesEditor
886
+ files={files}
887
+ itemSize={THUMBNAIL_SIZE}
888
+ onRemove={(id) => onEdit({ files: files.filter((x) => x.id !== id) })}
889
+ />
890
+ ) : null}
891
+
866
892
  </View>
867
- </View>
893
+ </View>
894
+ </FileDropTarget>
868
895
  );
869
896
  }
870
897
 
871
898
  /** The glyph per medium. A medium is a CATEGORY, so it rides the icon and the
872
899
  * supporting line — never a `Badge`, which this kit reserves for status. */
900
+ // What a touchpoint can carry, in ONE place: the entry's menu, a drop onto the
901
+ // entry and the composer's attach button all filter identically, because a file
902
+ // the drag accepts and the picker refuses is a difference nobody can see.
903
+ const ATTACH_ACCEPT = "application/pdf,image/*";
904
+
873
905
  const ACTIVITY_ICON: Record<ActivityKind, IconName> = {
874
906
  call: "phone",
875
907
  video: "monitor",
@@ -1834,6 +1866,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1834
1866
  // ── activity — the feed is state so a logged note appears at the top of it,
1835
1867
  // where it happened, rather than after a refetch.
1836
1868
  const [activity, setActivity] = useState<ActivityEntry[]>(ACTIVITY);
1869
+ // Files staged on the NEXT entry — the feed's own queue, not the comment
1870
+ // thread's. Sharing one would let a file picked for a touchpoint ride out on a
1871
+ // comment instead, which is a data error with no visible symptom: both
1872
+ // surfaces would look correct and the evidence would be filed against the
1873
+ // wrong thing. A product swaps these two lines for its upload queue.
1874
+ const [pendingActivityFiles, setPendingActivityFiles] = useState<DisplayFile[]>([]);
1875
+ const attachToActivity = () => {
1876
+ void pickFiles({ accept: ATTACH_ACCEPT, multiple: true }).then((chosen) => {
1877
+ if (chosen.length === 0) return;
1878
+ setPendingActivityFiles((prev) => [...prev, ...chosen.map(asDisplayFile)]);
1879
+ });
1880
+ };
1837
1881
  // A record's feed is not a register: you do not PAGE it, you fold the tail.
1838
1882
  // The recent entries are what the reader came for, and an unbounded feed makes
1839
1883
  // every other section on the record unreachable by scroll.
@@ -1842,7 +1886,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1842
1886
  // ONE entry at a time. A feed with several rows open in edit mode gives the
1843
1887
  // reader two half-finished sentences and no way to tell which one the next
1844
1888
  // keystroke lands in.
1845
- const [editingActivity, setEditingActivity] = useState<string | null>(null);
1846
1889
 
1847
1890
  // ── the record — seeded mid-flight so every state is visible
1848
1891
  const [stage, setStage] = useState<Stage>("sales");
@@ -2659,7 +2702,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2659
2702
  renderFiles={(files) => (
2660
2703
  <FileGrid
2661
2704
  files={files.map(commentFileToDisplay)}
2662
- itemSize={84}
2705
+ itemSize={THUMBNAIL_SIZE}
2663
2706
  onFilePress={(f) => setPreview({ files: [f], index: 0 })}
2664
2707
  />
2665
2708
  )}
@@ -2677,9 +2720,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2677
2720
  }}
2678
2721
  placeholder="Write a comment…"
2679
2722
  sendLabel="Send"
2680
- actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
2723
+ actionsButton={<IconButton size="lg" color="secondary" icon="paperclip" tooltip="Attach files" onPress={attachToComment} />}
2724
+ /* Same reason as the activity composer above: a comment that is purely
2725
+ an attachment is an ordinary thing to send, and the default
2726
+ block-on-empty-text refused it. */
2727
+ sendDisabled={pendingFiles.length > 0 ? false : undefined}
2728
+ /* Same surface as the activity composer above, and as chat. */
2681
2729
  files={pendingFiles.length > 0 ? (
2682
- <FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
2730
+ <FileGrid
2731
+ files={pendingFiles.map(commentFileToDisplay)}
2732
+ itemSize={THUMBNAIL_SIZE}
2733
+ onDisplayRemove={(id) => setPendingFiles((prev) => prev.filter((x) => x.id !== id))}
2734
+ />
2683
2735
  ) : undefined}
2684
2736
  />
2685
2737
  </>
@@ -2977,21 +3029,50 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2977
3029
  placeholder="Log a call, an email, a note…"
2978
3030
  accessibilityLabel="Log an activity"
2979
3031
  sendLabel="Log"
3032
+ /* A touchpoint is very often ABOUT a document — you sent the quote,
3033
+ they sent the PO back — and `ActivityEntry` has carried `files`
3034
+ all along; only the write path was missing, so the feed could
3035
+ display evidence it gave nobody a way to file. The comment
3036
+ composer below already wires exactly this, from the same three
3037
+ kit slots; this one simply did not. */
3038
+ actionsButton={<IconButton size="lg" color="secondary" icon="paperclip" tooltip="Attach files" onPress={attachToActivity} />}
3039
+ /* THE SAME COMPONENT AND SIZE AS CHAT — `FileGrid` at `THUMBNAIL_SIZE`,
3040
+ which is what the product's own composer passes. Two things were
3041
+ wrong with hand-rolling a row of `FileThumbnail` here: it
3042
+ re-implements a component that exists (so it silently lacks the
3043
+ overflow, selection and upload states `FileGrid` carries, and it
3044
+ teaches app authors to hand-roll a grid), and it sized the tiles
3045
+ with a magic 56 copied off a demo file while the kit EXPORTS the
3046
+ size a composer attachment is. A staged file is checked by LOOKING
3047
+ at it; 56px of a scanned PO shows nothing. Rows are for a filed
3048
+ set, which is what an entry's own list is. */
3049
+ files={pendingActivityFiles.length > 0 ? (
3050
+ <FileGrid
3051
+ files={pendingActivityFiles}
3052
+ itemSize={THUMBNAIL_SIZE}
3053
+ onDisplayRemove={(id) => setPendingActivityFiles((prev) => prev.filter((x) => x.id !== id))}
3054
+ />
3055
+ ) : undefined}
3056
+ /* STAGED FILES ARE CONTENT. The composer blocks send on empty text by
3057
+ default, which is right for a message and wrong the moment an
3058
+ attachment is the whole point — "here is the signed PO" needs no
3059
+ sentence. `undefined` keeps the default when nothing is staged. */
3060
+ sendDisabled={pendingActivityFiles.length > 0 ? false : undefined}
2980
3061
  onSend={(text) => {
2981
3062
  const t = text.trim();
2982
- if (!t) return;
3063
+ if (!t && pendingActivityFiles.length === 0) return;
2983
3064
  setActivity((prev) => [
2984
3065
  {
2985
3066
  key: `a-${prev.length + 1}-${t.length}`,
2986
3067
  kind: "note",
2987
- gist: t,
2988
- direction: "out",
3068
+ gist: t || undefined,
2989
3069
  over: "Note",
2990
3070
  when: "Just now",
2991
- by: { kind: "member", name: "Sarah Chen" },
3071
+ files: pendingActivityFiles.length > 0 ? pendingActivityFiles : undefined,
2992
3072
  },
2993
3073
  ...prev,
2994
3074
  ]);
3075
+ setPendingActivityFiles([]);
2995
3076
  }}
2996
3077
  />
2997
3078
 
@@ -3003,56 +3084,45 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3003
3084
  />
3004
3085
  ) : (
3005
3086
  <>
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
- />
3087
+ {/* AN ENTRY, NOT A LOG LINE — and that distinction is the whole
3088
+ reason this stopped being a `Timeline`.
3089
+
3090
+ `Timeline` puts its label INSIDE the row's press target. That
3091
+ is right for an event ("Stage changed to Won") whose text is
3092
+ derived and read-only. It is wrong here, because the gist is
3093
+ the one thing on an entry a person WROTE, and text inside a
3094
+ button can never be edited where it sits. Four repairs came out
3095
+ of working around that the verbs moved off the row, the label
3096
+ learned to un-clamp, the body's copy of the gist came out, and
3097
+ the Edit verb ended up a scroll away from the sentence it
3098
+ edits. They were never independent: each one was the
3099
+ arrangement pushing back.
3100
+
3101
+ So the gist is not a label. It is a RESIDENT field — always
3102
+ editable, in place, no mode and no Edit button, the same as
3103
+ every other authored value on this record. The old argument
3104
+ against a resident editor here ("the gist is already the row's
3105
+ label, so it renders the sentence twice") disappears the moment
3106
+ it stops being one.
3107
+
3108
+ What is left is the shape every comment UI converged on: a
3109
+ metadata header, the prose under it, the row's own actions at
3110
+ the TOP RIGHT beside the text they act on, and the heavy
3111
+ artifacts behind their own disclosure. */}
3112
+ <View style={{ gap: 20 }}>
3113
+ {(showAllActivity ? activity : activity.slice(0, ACTIVITY_FOLD)).map((a) => (
3114
+ <ActivityEntryRow
3115
+ key={a.key}
3116
+ a={a}
3117
+ onEdit={(patch) =>
3118
+ setActivity((prev) =>
3119
+ prev.map((e) => (e.key === a.key ? { ...e, ...patch } : e)),
3120
+ )
3121
+ }
3122
+ onDelete={() => setActivity((prev) => prev.filter((e) => e.key !== a.key))}
3123
+ />
3124
+ ))}
3125
+ </View>
3056
3126
  {/* The tail FOLDS rather than paging. A record's feed is read
3057
3127
  newest-first and the old entries are reference — but they are
3058
3128
  still on the record, so the count says how much is behind the
@@ -3486,8 +3556,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3486
3556
  {/* DOCUMENTS — the document desk: the register Table (search, Create
3487
3557
  documents, Add files) whose selection feeds the Use-AI fork below.
3488
3558
  No FileDropTarget of its OWN — the WHOLE-RECORD target above already
3489
- captures a drop/paste here (and everywhere else on the surface); this
3490
- section keeps only its explicit Add-files CTA and the affordance line. */}
3559
+ captures a drop/paste here; this section keeps only its explicit
3560
+ Add-files CTA and the affordance line.
3561
+
3562
+ The record-level target is the FALLBACK, not the only one. An
3563
+ activity entry nests its own, because a file dropped on a
3564
+ conversation belongs to that conversation — and `FileDropTarget`
3565
+ stops propagation, so the innermost region wins and this one never
3566
+ sees it. A section adds its own only when the drop MEANS something
3567
+ different there; "the same thing, closer" is not a reason. */}
3491
3568
  <View onLayout={nav.register("files")}>
3492
3569
  <Section>
3493
3570
  <SectionHeading>