@visns-studio/visns-components 6.21.0 → 6.23.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.
@@ -10,6 +10,7 @@ import {
10
10
  Archive,
11
11
  ArchiveRestore,
12
12
  ArrowLeft,
13
+ CheckSquare,
13
14
  ChevronUp,
14
15
  ExternalLink,
15
16
  FileText,
@@ -30,6 +31,7 @@ import useSmsLive, { defaultChannelFor } from './useSmsLive';
30
31
  import useDebouncedValue from '../vault/useDebouncedValue';
31
32
  import {
32
33
  MAX_SEGMENTS,
34
+ annotationsForMessage,
33
35
  clockTime,
34
36
  composerPlaceholder,
35
37
  counterLabel,
@@ -42,6 +44,7 @@ import {
42
44
  nextPlaceholder,
43
45
  normaliseNumberForDisplay,
44
46
  prependMessages,
47
+ resolveAnnotations,
45
48
  segmentCount,
46
49
  statusLabel,
47
50
  threadDisplayName,
@@ -83,6 +86,13 @@ let temporaryId = 0;
83
86
  * subscription, in which case the parent passes `subscribe={false}` and feeds
84
87
  * it events. Two subscriptions to one channel is not an error, but it is two
85
88
  * websocket authorisations and two copies of every message to reconcile.
89
+ *
90
+ * `messageSelection` and `messageAnnotations` are the two hooks a host has into
91
+ * the timeline. Both are opt-in and both are deliberately nameless about what
92
+ * the host does with them: this module knows about messages, and an application
93
+ * that wants to file them against something of its own (a task, a complaint, a
94
+ * file note) supplies the verb and the badges. Absent, the panel behaves
95
+ * exactly as it did before they existed.
86
96
  */
87
97
  const SmsThreadPanel = ({
88
98
  threadId,
@@ -107,6 +117,23 @@ const SmsThreadPanel = ({
107
117
  lines = null,
108
118
  canManage = false,
109
119
  clientUrl = (id) => `/clients/${id}`,
120
+ // `{label, onSelect}` — turns on a Select mode over the timeline. `label` is
121
+ // the verb printed on the confirm button ("Attach to task…"), `onSelect` is
122
+ // called as `onSelect(messages, thread)` and may return a promise; the mode
123
+ // closes when it settles.
124
+ //
125
+ // The thread is the SECOND argument, so a host that already knows which
126
+ // conversation it mounted (a client's own page) can ignore it. A host that
127
+ // does not — an inbox, where the pane chooses the thread — has no other way
128
+ // to know which one is open or which client it is linked to.
129
+ messageSelection = null,
130
+ // `{[messageId]: [{id, label, onClick}]}` — small chips under the matching
131
+ // bubble. The host owns what they say and what a click does.
132
+ //
133
+ // May also be a function `(thread) => map`, for the same reason: chips in an
134
+ // inbox depend on which conversation is open, and no host can hold one map
135
+ // for every thread in the practice.
136
+ messageAnnotations = null,
110
137
  onThreadChange,
111
138
  onMessage,
112
139
  onBack = null,
@@ -130,6 +157,10 @@ const SmsThreadPanel = ({
130
157
  const [linkOpen, setLinkOpen] = useState(false);
131
158
  const [simulateOpen, setSimulateOpen] = useState(false);
132
159
 
160
+ const [selecting, setSelecting] = useState(false);
161
+ const [selectedIds, setSelectedIds] = useState([]);
162
+ const [confirmingSelection, setConfirmingSelection] = useState(false);
163
+
133
164
  const mountedRef = useRef(true);
134
165
  const timelineRef = useRef(null);
135
166
  const composerRef = useRef(null);
@@ -445,6 +476,97 @@ const SmsThreadPanel = ({
445
476
  [enterSends, submit]
446
477
  );
447
478
 
479
+ /* ------------------------------------------------------------ selection */
480
+
481
+ const selectionEnabled =
482
+ Boolean(messageSelection) && typeof messageSelection.onSelect === 'function';
483
+
484
+ // Moving to another conversation abandons whatever was ticked in the last
485
+ // one: the selection is a list of ids from a timeline that is no longer on
486
+ // screen, and carrying it across would file one client's messages from
487
+ // another client's page.
488
+ useEffect(() => {
489
+ setSelecting(false);
490
+ setSelectedIds([]);
491
+ }, [threadId]);
492
+
493
+ // Withdrawing the prop has to close the mode with it, or a host that turns
494
+ // selection off mid-render leaves the bar stranded with no way out.
495
+ useEffect(() => {
496
+ if (!selectionEnabled) {
497
+ setSelecting(false);
498
+ setSelectedIds([]);
499
+ }
500
+ }, [selectionEnabled]);
501
+
502
+ const isSelected = useCallback(
503
+ (id) => selectedIds.some((one) => String(one) === String(id)),
504
+ [selectedIds]
505
+ );
506
+
507
+ const toggleSelected = useCallback((id) => {
508
+ setSelectedIds((prev) =>
509
+ prev.some((one) => String(one) === String(id))
510
+ ? prev.filter((one) => String(one) !== String(id))
511
+ : [...prev, id]
512
+ );
513
+ }, []);
514
+
515
+ const cancelSelection = useCallback(() => {
516
+ setSelecting(false);
517
+ setSelectedIds([]);
518
+ }, []);
519
+
520
+ const confirmSelection = useCallback(() => {
521
+ if (!selectionEnabled || selectedIds.length === 0 || confirmingSelection) return;
522
+
523
+ // The host is handed the message objects, not the ids: it should not
524
+ // have to hold a second copy of the timeline to know what it was given.
525
+ // Timeline order, not tick order — a conversation extract only reads
526
+ // correctly in the order it happened.
527
+ const chosen = messages.filter((one) => isSelected(one.id));
528
+
529
+ setConfirmingSelection(true);
530
+
531
+ Promise.resolve(messageSelection.onSelect(chosen, thread))
532
+ .then(() => {
533
+ if (!mountedRef.current) return;
534
+
535
+ setSelecting(false);
536
+ setSelectedIds([]);
537
+ })
538
+ .catch(() => {
539
+ // A failed hand-off leaves the ticks where they are so the user
540
+ // can try again; the host is the one that says what went wrong.
541
+ })
542
+ .then(() => {
543
+ if (mountedRef.current) setConfirmingSelection(false);
544
+ });
545
+ }, [
546
+ selectionEnabled,
547
+ selectedIds,
548
+ confirmingSelection,
549
+ messages,
550
+ isSelected,
551
+ messageSelection,
552
+ thread,
553
+ ]);
554
+
555
+ /**
556
+ * The chip map for the conversation on screen. A function form is called
557
+ * with the whole thread rather than its id, because what a host looks chips
558
+ * up by is usually the client on it.
559
+ */
560
+ const annotations = useMemo(
561
+ () => resolveAnnotations(messageAnnotations, thread),
562
+ [messageAnnotations, thread]
563
+ );
564
+
565
+ const annotationsFor = useCallback(
566
+ (id) => annotationsForMessage(annotations, id),
567
+ [annotations]
568
+ );
569
+
448
570
  /* -------------------------------------------------------------- actions */
449
571
 
450
572
  const patchThread = useCallback(
@@ -589,6 +711,44 @@ const SmsThreadPanel = ({
589
711
  const attachments = Array.isArray(message.attachments) ? message.attachments : [];
590
712
  // A failure is never silent, whatever else the run is doing.
591
713
  const showMeta = lastOfRun || failed || heldMessage;
714
+ const chips = annotationsFor(message.id);
715
+ // A message still on its way to the server has a temporary id, so there
716
+ // is nothing a host could file it against yet.
717
+ const tickable = selecting && !message.pending;
718
+ const ticked = tickable && isSelected(message.id);
719
+
720
+ const bubble = (
721
+ <div
722
+ className={[
723
+ styles.bubble,
724
+ outgoing ? styles.bubbleBodyOut : styles.bubbleBodyIn,
725
+ heldMessage ? styles.bubbleHeld : '',
726
+ failed ? styles.bubbleFailed : '',
727
+ message.pending ? styles.bubblePending : '',
728
+ ]
729
+ .filter(Boolean)
730
+ .join(' ')}
731
+ >
732
+ {message.body}
733
+
734
+ {attachments.length > 0 && (
735
+ <div className={styles.attachments}>
736
+ {attachments.map((file) => (
737
+ <a
738
+ key={file.id}
739
+ className={styles.attachment}
740
+ href={file.download_url}
741
+ target="_blank"
742
+ rel="noopener noreferrer"
743
+ >
744
+ <FileText size={12} strokeWidth={2} aria-hidden="true" />
745
+ <span>{file.name}</span>
746
+ </a>
747
+ ))}
748
+ </div>
749
+ )}
750
+ </div>
751
+ );
592
752
 
593
753
  return (
594
754
  <div
@@ -601,36 +761,52 @@ const SmsThreadPanel = ({
601
761
  .filter(Boolean)
602
762
  .join(' ')}
603
763
  >
604
- <div
605
- className={[
606
- styles.bubble,
607
- outgoing ? styles.bubbleBodyOut : styles.bubbleBodyIn,
608
- heldMessage ? styles.bubbleHeld : '',
609
- failed ? styles.bubbleFailed : '',
610
- message.pending ? styles.bubblePending : '',
611
- ]
612
- .filter(Boolean)
613
- .join(' ')}
614
- >
615
- {message.body}
616
-
617
- {attachments.length > 0 && (
618
- <div className={styles.attachments}>
619
- {attachments.map((file) => (
620
- <a
621
- key={file.id}
622
- className={styles.attachment}
623
- href={file.download_url}
624
- target="_blank"
625
- rel="noopener noreferrer"
764
+ {selecting ? (
765
+ // The whole row is the hit target while selecting: ticking
766
+ // a message by aiming at a 14px box is not how anybody
767
+ // picks three texts out of a conversation.
768
+ <label
769
+ className={[
770
+ styles.pickRow,
771
+ ticked ? styles.pickRowOn : '',
772
+ message.pending ? styles.pickRowOff : '',
773
+ ]
774
+ .filter(Boolean)
775
+ .join(' ')}
776
+ >
777
+ <input
778
+ type="checkbox"
779
+ className={styles.pickBox}
780
+ checked={ticked}
781
+ disabled={!tickable}
782
+ onChange={() => toggleSelected(message.id)}
783
+ />
784
+ {bubble}
785
+ </label>
786
+ ) : (
787
+ bubble
788
+ )}
789
+
790
+ {chips.length > 0 && (
791
+ <div className={styles.annotations}>
792
+ {chips.map((annotation) =>
793
+ typeof annotation.onClick === 'function' ? (
794
+ <button
795
+ key={annotation.id}
796
+ type="button"
797
+ className={`${styles.annotation} ${styles.annotationAction}`}
798
+ onClick={() => annotation.onClick(annotation)}
626
799
  >
627
- <FileText size={12} strokeWidth={2} aria-hidden="true" />
628
- <span>{file.name}</span>
629
- </a>
630
- ))}
631
- </div>
632
- )}
633
- </div>
800
+ {annotation.label}
801
+ </button>
802
+ ) : (
803
+ <span key={annotation.id} className={styles.annotation}>
804
+ {annotation.label}
805
+ </span>
806
+ )
807
+ )}
808
+ </div>
809
+ )}
634
810
 
635
811
  {showMeta && (
636
812
  <div
@@ -703,6 +879,22 @@ const SmsThreadPanel = ({
703
879
  </a>
704
880
  ) : null}
705
881
 
882
+ {selectionEnabled && (
883
+ <button
884
+ type="button"
885
+ className={`${styles.ghostButton} ${
886
+ selecting ? styles.ghostButtonOn : ''
887
+ }`.trim()}
888
+ aria-pressed={selecting}
889
+ onClick={() =>
890
+ selecting ? cancelSelection() : setSelecting(true)
891
+ }
892
+ >
893
+ <CheckSquare size={14} strokeWidth={2} aria-hidden="true" />
894
+ {selecting ? 'Done' : 'Select'}
895
+ </button>
896
+ )}
897
+
706
898
  <button
707
899
  type="button"
708
900
  className={styles.ghostButton}
@@ -797,6 +989,42 @@ const SmsThreadPanel = ({
797
989
  ))}
798
990
  </div>
799
991
 
992
+ {/*
993
+ * While selecting, the action bar stands where the composer does.
994
+ * Both at once would be a pane with two footers and two primary
995
+ * buttons, and picking messages out of a conversation is not
996
+ * something anybody does halfway through writing a reply.
997
+ */}
998
+ {selecting ? (
999
+ <div className={styles.selectBar}>
1000
+ <span className={styles.selectCount}>
1001
+ {selectedIds.length === 0
1002
+ ? 'Pick the messages you want'
1003
+ : `${selectedIds.length} selected`}
1004
+ </span>
1005
+
1006
+ <div className={styles.selectActions}>
1007
+ <button
1008
+ type="button"
1009
+ className={styles.ghostButton}
1010
+ onClick={cancelSelection}
1011
+ >
1012
+ Cancel
1013
+ </button>
1014
+ <button
1015
+ type="button"
1016
+ className={styles.primaryButton}
1017
+ onClick={confirmSelection}
1018
+ disabled={selectedIds.length === 0 || confirmingSelection}
1019
+ >
1020
+ {confirmingSelection && (
1021
+ <Loader2 size={14} className={styles.spin} aria-hidden="true" />
1022
+ )}
1023
+ {messageSelection.label || 'Select'} ({selectedIds.length})
1024
+ </button>
1025
+ </div>
1026
+ </div>
1027
+ ) : (
800
1028
  <div className={styles.composer}>
801
1029
  <div className={styles.composerBox}>
802
1030
  <textarea
@@ -905,6 +1133,7 @@ const SmsThreadPanel = ({
905
1133
  </div>
906
1134
  </div>
907
1135
  </div>
1136
+ )}
908
1137
 
909
1138
  <LinkClientModal
910
1139
  open={linkOpen}
@@ -1202,7 +1202,43 @@ export const describeRecipient = (input) => {
1202
1202
  };
1203
1203
  };
1204
1204
 
1205
+ /* -------------------------------------------------------------------------- *
1206
+ * Host annotations
1207
+ *
1208
+ * A host may hand the conversation pane chips to draw under particular
1209
+ * messages. Two shapes, because two kinds of host need it:
1210
+ *
1211
+ * - a MAP, `{[messageId]: [chip]}`, for a screen that already knows which
1212
+ * conversation it mounted — a client's own page;
1213
+ * - a FUNCTION of the open thread, for a screen that does not — an inbox,
1214
+ * where the pane chooses the thread and no host could hold one map for
1215
+ * every conversation in the practice.
1216
+ *
1217
+ * Both live here rather than inside the component so the shape has a test.
1218
+ * -------------------------------------------------------------------------- */
1219
+
1220
+ /** The chip map for one conversation, whichever shape the host passed. */
1221
+ export const resolveAnnotations = (annotations, thread) => {
1222
+ const resolved =
1223
+ typeof annotations === 'function' ? annotations(thread) : annotations;
1224
+
1225
+ return resolved && typeof resolved === 'object' ? resolved : null;
1226
+ };
1227
+
1228
+ /**
1229
+ * The chips for one message. Looked up by the id as given AND as a string: a
1230
+ * map parsed out of JSON has string keys, and the timeline's ids are numbers.
1231
+ */
1232
+ export const annotationsForMessage = (annotations, id) => {
1233
+ if (!annotations) return [];
1234
+
1235
+ const found = annotations[id] ?? annotations[String(id)];
1236
+
1237
+ return Array.isArray(found) ? found : [];
1238
+ };
1239
+
1205
1240
  export default {
1241
+ annotationsForMessage,
1206
1242
  badgeCount,
1207
1243
  clientRecipient,
1208
1244
  clockTime,
@@ -1229,6 +1265,7 @@ export default {
1229
1265
  prependMessages,
1230
1266
  RECIPIENT_HINTS,
1231
1267
  relativeTime,
1268
+ resolveAnnotations,
1232
1269
  segmentCount,
1233
1270
  smsHref,
1234
1271
  splitPersonName,
@@ -697,6 +697,16 @@
697
697
  width: 100%;
698
698
  font-size: 0.9rem;
699
699
  box-sizing: border-box;
700
+ // The input is type="search" (to sidestep host apps' global text-input
701
+ // rules), so strip WebKit's search decorations to keep it looking like
702
+ // the plain field it always was.
703
+ appearance: none;
704
+ -webkit-appearance: none;
705
+
706
+ &::-webkit-search-decoration,
707
+ &::-webkit-search-cancel-button {
708
+ -webkit-appearance: none;
709
+ }
700
710
 
701
711
  &:focus {
702
712
  outline: none;
@@ -1362,8 +1362,12 @@
1362
1362
  /* It stays pinned bottom-right — every detail page in every project is laid
1363
1363
  out around that — but it had no ground of its own, so on a short page the
1364
1364
  buttons floated over the page background with nothing behind them. A pill
1365
- with the same elevation as the panels gives them somewhere to sit. */
1366
- .polActions {
1365
+ with the same elevation as the panels gives them somewhere to sit.
1366
+
1367
+ ONLY when there is more than one of them: a tray around a single solid
1368
+ button reads as a white oval drawn around it, not as ground. A lone
1369
+ button carries its own elevation instead. */
1370
+ .polActions:has(> :nth-child(2)) {
1367
1371
  padding: 0.4rem;
1368
1372
  border: 1px solid var(--gd-line);
1369
1373
  border-radius: var(--radius-pill, 999px);
@@ -1374,6 +1378,12 @@
1374
1378
  0 16px 32px -14px color-mix(in srgb, var(--primary-color, #1b3933) 30%, transparent);
1375
1379
  }
1376
1380
 
1381
+ .polActions:not(:has(> :nth-child(2))) > * {
1382
+ box-shadow:
1383
+ 0 2px 4px color-mix(in srgb, var(--primary-color, #1b3933) 12%, transparent),
1384
+ 0 12px 24px -12px color-mix(in srgb, var(--primary-color, #1b3933) 35%, transparent);
1385
+ }
1386
+
1377
1387
  /* -- states --------------------------------------------------------------- */
1378
1388
 
1379
1389
  .noDataMessage {
@@ -234,6 +234,15 @@ $sms-mono: 'SFMono-Regular', 'Menlo', 'Consolas', monospace;
234
234
  }
235
235
  }
236
236
 
237
+ // A ghost button that is currently ON — the Select toggle while the timeline is
238
+ // in picking mode. Tinted rather than filled: it is a mode, not the pane's
239
+ // primary action.
240
+ .ghostButtonOn {
241
+ border-color: var(--s-primary);
242
+ background: var(--s-tint);
243
+ color: var(--s-primary);
244
+ }
245
+
237
246
  .iconButton {
238
247
  @include sms-reset;
239
248
  @include sms-focus;
@@ -1521,6 +1530,101 @@ $sms-mono: 'SFMono-Regular', 'Menlo', 'Consolas', monospace;
1521
1530
  font-weight: 600;
1522
1531
  }
1523
1532
 
1533
+ // --------------------------------------------------- picking and annotations
1534
+ //
1535
+ // Both are off unless the host asked for them: `.pickRow` only ever wraps a
1536
+ // bubble while the panel is in Select mode, and `.annotations` renders only
1537
+ // where the host supplied chips.
1538
+
1539
+ // The whole row is the hit target while picking. The checkbox sits on the
1540
+ // bubble's own side of the pane so the ticks line up down one margin rather
1541
+ // than zig-zagging with the conversation.
1542
+ .pickRow {
1543
+ display: flex;
1544
+ gap: 0.5rem;
1545
+ align-items: center;
1546
+ max-width: 100%;
1547
+ padding: 0.15rem 0.25rem;
1548
+ border: 1px solid transparent;
1549
+ border-radius: 14px;
1550
+ cursor: pointer;
1551
+
1552
+ &:hover {
1553
+ background: var(--s-hover);
1554
+ }
1555
+ }
1556
+
1557
+ .bubbleOut .pickRow {
1558
+ flex-direction: row-reverse;
1559
+ }
1560
+
1561
+ .pickRowOn {
1562
+ border-color: color-mix(in srgb, var(--s-primary) 45%, transparent);
1563
+ background: var(--s-tint);
1564
+ }
1565
+
1566
+ // A message that has not reached the server yet — nothing to file it against.
1567
+ .pickRowOff {
1568
+ opacity: 0.5;
1569
+ cursor: default;
1570
+
1571
+ &:hover {
1572
+ background: none;
1573
+ }
1574
+ }
1575
+
1576
+ .pickBox {
1577
+ flex-shrink: 0;
1578
+ width: 15px;
1579
+ height: 15px;
1580
+ margin: 0;
1581
+ accent-color: var(--s-primary);
1582
+ cursor: inherit;
1583
+ }
1584
+
1585
+ // Host-supplied chips under a bubble, on the bubble's own side.
1586
+ .annotations {
1587
+ display: flex;
1588
+ flex-wrap: wrap;
1589
+ gap: 0.25rem;
1590
+ max-width: min(60ch, 64%);
1591
+ margin-top: 0.25rem;
1592
+ padding: 0 0.25rem;
1593
+ }
1594
+
1595
+ .bubbleOut .annotations {
1596
+ justify-content: flex-end;
1597
+ }
1598
+
1599
+ .annotation {
1600
+ @include sms-reset;
1601
+
1602
+ display: inline-flex;
1603
+ gap: 0.25rem;
1604
+ align-items: center;
1605
+ max-width: 100%;
1606
+ padding: 0.1rem 0.5rem;
1607
+ border: 1px solid color-mix(in srgb, var(--s-primary) 30%, transparent);
1608
+ border-radius: 999px;
1609
+ background: var(--s-tint);
1610
+ color: var(--s-primary);
1611
+ font-size: 0.7rem;
1612
+ font-weight: 600;
1613
+ text-align: left;
1614
+ overflow-wrap: anywhere;
1615
+ }
1616
+
1617
+ .annotationAction {
1618
+ @include sms-focus;
1619
+
1620
+ cursor: pointer;
1621
+
1622
+ &:hover {
1623
+ border-color: var(--s-primary);
1624
+ background: color-mix(in srgb, var(--s-primary) 16%, var(--s-background));
1625
+ }
1626
+ }
1627
+
1524
1628
  .retryButton {
1525
1629
  @include sms-reset;
1526
1630
  @include sms-focus;
@@ -1599,6 +1703,33 @@ $sms-mono: 'SFMono-Regular', 'Menlo', 'Consolas', monospace;
1599
1703
  background: var(--s-surface);
1600
1704
  }
1601
1705
 
1706
+ // Stands exactly where the composer does while the timeline is being picked
1707
+ // over, so the pane keeps its height and its one footer.
1708
+ .selectBar {
1709
+ display: flex;
1710
+ flex-wrap: wrap;
1711
+ gap: 0.5rem 0.75rem;
1712
+ align-items: center;
1713
+ justify-content: space-between;
1714
+ box-sizing: border-box;
1715
+ padding: 0.6rem 0.7rem 0.7rem;
1716
+ border-top: 1px solid var(--s-line);
1717
+ background: var(--s-surface);
1718
+ }
1719
+
1720
+ .selectCount {
1721
+ color: var(--s-paragraph);
1722
+ font-size: 0.8rem;
1723
+ font-weight: 600;
1724
+ }
1725
+
1726
+ .selectActions {
1727
+ display: inline-flex;
1728
+ gap: 0.5rem;
1729
+ align-items: center;
1730
+ margin-left: auto;
1731
+ }
1732
+
1602
1733
  // One card: the text on top, every control in a bar along its bottom edge.
1603
1734
  // Buttons beside a textarea line up with nothing once the box grows; a bar
1604
1735
  // inside the same border always reads as part of the message being written.
@@ -41,6 +41,11 @@ $vault-mono: 'SFMono-Regular', 'Menlo', 'Consolas', monospace;
41
41
  --v-tint: color-mix(in srgb, var(--v-primary) 12%, var(--v-background));
42
42
  --v-ok: rgb(31, 122, 71);
43
43
  --v-bad: rgb(190, 62, 62);
44
+ // Between the two: something did not happen, and nothing is broken or at
45
+ // risk because of it. Its one user is "the link could not be emailed" —
46
+ // the link itself is fine and on screen right above the line, so painting
47
+ // that in --v-bad would read as "your credential is in trouble".
48
+ --v-warn: rgb(176, 106, 20);
44
49
  }
45
50
 
46
51
  // Undo anything a host may have imposed on a generic element. Applied to every
@@ -2312,6 +2317,47 @@ input.checkControl {
2312
2317
  }
2313
2318
  }
2314
2319
 
2320
+ // "Also emailed to dana@acme.example." Quiet, and under the URL rather than
2321
+ // instead of it: the copy-the-link flow is what actually happened, the email is
2322
+ // a convenience on top of it.
2323
+ .shareEmailed {
2324
+ display: flex;
2325
+ gap: 0.35rem;
2326
+ align-items: flex-start;
2327
+ margin: 0;
2328
+ color: var(--v-ok);
2329
+ font-size: 0.78rem;
2330
+ line-height: 1.45;
2331
+
2332
+ svg {
2333
+ flex-shrink: 0;
2334
+ margin-top: 0.15rem;
2335
+ }
2336
+ }
2337
+
2338
+ // The send failed. Louder than the line above, because the sender is about to
2339
+ // close a dialog believing the recipient has the link — but not red: the link
2340
+ // is on screen and copyable, and nothing about the credential is at risk.
2341
+ .shareEmailFailed {
2342
+ display: flex;
2343
+ gap: 0.35rem;
2344
+ align-items: flex-start;
2345
+ margin: 0;
2346
+ padding: 0.5rem 0.6rem;
2347
+ border: 1px solid color-mix(in srgb, var(--v-warn) 40%, transparent);
2348
+ border-radius: var(--v-btn-br);
2349
+ background: color-mix(in srgb, var(--v-warn) 10%, var(--v-background));
2350
+ color: var(--v-warn);
2351
+ font-size: 0.78rem;
2352
+ font-weight: 500;
2353
+ line-height: 1.45;
2354
+
2355
+ svg {
2356
+ flex-shrink: 0;
2357
+ margin-top: 0.15rem;
2358
+ }
2359
+ }
2360
+
2315
2361
  .shareList {
2316
2362
  display: flex;
2317
2363
  flex-direction: column;