@lovett/ui 0.0.11 → 0.2.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.
Files changed (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,1071 @@
1
+ /**
2
+ * Composer attachments — the rendered half (ADR-151 D1, D4, D5, D6).
3
+ *
4
+ * Four things live here, and they are together because they are one feature
5
+ * seen from four places: the tray of chips under the composer, the button and
6
+ * drop target that fill it, the GIF picker that fills it a different way, and
7
+ * the renderers a posted comment uses.
8
+ *
9
+ * ## Reserve the slot, always
10
+ *
11
+ * Every surface below declares its box before it has anything to put in it —
12
+ * the chip has a fixed height, the progress bar occupies its row whether or not
13
+ * it is moving, the thumbnail uses the declared `aspectRatio`, and a broken
14
+ * image becomes a placeholder inside the same box rather than a collapse. The
15
+ * layout is not allowed to depend on whether bytes arrived.
16
+ *
17
+ * ## Motion
18
+ *
19
+ * `prefers-reduced-motion` is not decoration here. An autoplaying wall of GIFs
20
+ * is the single most reduced-motion-hostile thing this product could ship
21
+ * (ADR-151 D4), so under that preference a GIF renders as a still frame with an
22
+ * explicit play control, and pressing play is a choice the person made. A
23
+ * Giphy pick stores an **mp4**, so its `<video>` can genuinely hold the first
24
+ * frame. An uploaded GIF stays `kind: 'image'`; its renderer snapshots the
25
+ * loaded `<img>` to a canvas once and shows that still until the person elects
26
+ * to swap the live image back in.
27
+ *
28
+ * ## "Powered by GIPHY"
29
+ *
30
+ * Rendered in the picker. `TODO(legal):` required by Giphy's terms; noted once
31
+ * here and in ADR-151 D4, not litigated.
32
+ *
33
+ * ## Why this is not `packages/ui/src/file-upload/*`
34
+ *
35
+ * That family already ships — `FileUploadButton`, `FileDropZone`,
36
+ * `FilePreviewGrid`, `useFileUpload` — and CLAUDE.md §2 is explicit that
37
+ * forking a primitive is a defect. This is a deliberate exception, and the
38
+ * reasons are specific rather than aesthetic:
39
+ *
40
+ * - **`useFileUpload` allows what ADR-151 D3 forbids.** Its accept list
41
+ * includes `text/html`, `application/javascript` and Office documents; D3
42
+ * enumerates nine formats and refuses everything else by name.
43
+ * - **It uploads directly.** It drives its own XHR to a destination; D1
44
+ * requires the two-step dance — bytes to the engine, then a server-verified
45
+ * ticket — and the tray's states are `uploading | ready | failed` against
46
+ * that ticket, not against a transfer.
47
+ * - **Its 20 MB cap is hardcoded** and disagrees with D3's 25 MB, and it has
48
+ * no per-comment total at all.
49
+ * - **It calls `toast` internally**, so a refusal is announced by the
50
+ * primitive rather than reported to the surface, and this composer needs
51
+ * the message inline on the chip.
52
+ * - Nothing in it has a concept of a pasted-text chip, an "Insert as text
53
+ * instead" undo, or a GIF.
54
+ *
55
+ * **What that fork costs, stated rather than discovered later:** `FileDropZone`
56
+ * recurses into dropped FOLDERS via `FileSystemEntry`; `AttachmentDropZone`
57
+ * takes `dataTransfer.files` only, so dropping a folder attaches nothing. If
58
+ * folder drop is wanted here, lift that traversal out of `file-upload/` into a
59
+ * shared helper rather than copying it a second time.
60
+ *
61
+ * Recorded in `docs/follow-ups/_pending/adr-151-attachments-open-edges.md` §8
62
+ * as well, because a fork that is only justified in a file header is a fork
63
+ * nobody finds.
64
+ */
65
+
66
+ import {
67
+ useCallback,
68
+ useEffect,
69
+ useId,
70
+ useRef,
71
+ useState,
72
+ type DragEvent,
73
+ type ReactNode,
74
+ } from 'react'
75
+ import { FileText, Image as ImageIcon, ImageOff, Paperclip, Play, Search, X } from 'lucide-react'
76
+ import { Button } from '../button'
77
+ import { Input } from '../input'
78
+ import { cn } from '../lib/utils'
79
+ import {
80
+ GIF_SEARCH_DEBOUNCE_MS,
81
+ useAttachmentUrl,
82
+ type ComposerAttachment,
83
+ } from './use-attachments'
84
+ import type { ThreadAttachment } from './types'
85
+
86
+ /* ── shared bits ────────────────────────────────────────────────────────── */
87
+
88
+ /**
89
+ * The same quiet icon button `actions.tsx` uses, for the same reason.
90
+ *
91
+ * Exported (module-level, NOT from the package barrel) because `link-preview.tsx`
92
+ * needs the identical treatment for the identical reason: a 12px dismiss cross
93
+ * sitting inside an already-bordered card. Sharing the constant is what keeps
94
+ * "pick one hover treatment and use it for every interactive surface" true;
95
+ * `actions.tsx` holds a second, older copy of this pair, and a FOURTH consumer
96
+ * should promote the three of them into one module rather than copy it again.
97
+ *
98
+ * CLAUDE.md §2 bans `ghost` as the DEFAULT lesser button — "buttons must read
99
+ * as buttons" — with the exception it names being "genuinely chrome-dense
100
+ * contexts … inline icon toolbars". A 12px remove-cross inside a chip is that
101
+ * exception: a bordered button inside a bordered chip is two borders 4px apart,
102
+ * and the chip already reads as a button-shaped thing. The primary action of
103
+ * this whole surface is the composer's send, and it stays the only filled one.
104
+ */
105
+ export const QUIET_ICON = cn(
106
+ 'inline-flex shrink-0 items-center justify-center border-0 bg-transparent',
107
+ 'text-[rgb(var(--text-tertiary))]',
108
+ 'transition-[background-color,color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
109
+ 'motion-reduce:transition-none',
110
+ 'hover:bg-[rgb(var(--surface-overlay))] hover:text-[rgb(var(--foreground))]',
111
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
112
+ 'disabled:cursor-default disabled:opacity-60',
113
+ )
114
+
115
+ /** The 24px hit-area floor from the interface standards §6, met by padding. */
116
+ export const QUIET_ICON_BOX = {
117
+ minBlockSize: 'var(--space-6)',
118
+ minInlineSize: 'var(--space-6)',
119
+ borderRadius: 'var(--radius-xs)',
120
+ } as const
121
+
122
+ /**
123
+ * "2,500 characters" — grouped, because the whole reason this chip exists is
124
+ * that the number is large, and an ungrouped five-digit run is the one shape a
125
+ * reader cannot size at a glance.
126
+ */
127
+ export function formatCharacterCount(count: number): string {
128
+ const n = Math.max(0, Math.round(count))
129
+ return `${n.toLocaleString('en-US')} ${n === 1 ? 'character' : 'characters'}`
130
+ }
131
+
132
+ export function formatAttachmentSize(bytes: number): string {
133
+ if (!Number.isFinite(bytes) || bytes <= 0) return ''
134
+ if (bytes < 1024) return `${String(bytes)} B`
135
+ if (bytes < 1024 * 1024) return `${String(Math.round(bytes / 1024))} KB`
136
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
137
+ }
138
+
139
+ /**
140
+ * Does this viewer want less motion?
141
+ *
142
+ * Read as state and subscribed to, not read once: the preference can change
143
+ * while the page is open, and a GIF that keeps playing after someone turns
144
+ * reduced motion on has ignored them at the moment they asked.
145
+ */
146
+ export function usePrefersReducedMotion(): boolean {
147
+ const [reduced, setReduced] = useState(false)
148
+ useEffect(() => {
149
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
150
+ const query = window.matchMedia('(prefers-reduced-motion: reduce)')
151
+ setReduced(query.matches)
152
+ const onChange = (event: MediaQueryListEvent) => {
153
+ setReduced(event.matches)
154
+ }
155
+ query.addEventListener('change', onChange)
156
+ return () => {
157
+ query.removeEventListener('change', onChange)
158
+ }
159
+ }, [])
160
+ return reduced
161
+ }
162
+
163
+ /* ── the tray (D1, D5) ──────────────────────────────────────────────────── */
164
+
165
+ export interface AttachmentTrayProps {
166
+ readonly attachments: readonly ComposerAttachment[]
167
+ readonly onRemove: (localId: string) => void
168
+ /**
169
+ * D5's undo. Given only for pasted-text chips; the action does not render on
170
+ * anything else, because there is nothing to restore.
171
+ */
172
+ readonly onInsertAsText?: ((localId: string) => void) | undefined
173
+ readonly className?: string | undefined
174
+ }
175
+
176
+ /**
177
+ * The chips under the composer.
178
+ *
179
+ * Nothing here is a pill: chips are lightly-rounded bars, per the data-display
180
+ * recipe. The row reserves its own height so adding the first attachment does
181
+ * not shove the editor upward, and the progress bar keeps its track whether or
182
+ * not it is moving.
183
+ */
184
+ export function AttachmentTray({
185
+ attachments,
186
+ onRemove,
187
+ onInsertAsText,
188
+ className,
189
+ }: AttachmentTrayProps) {
190
+ if (attachments.length === 0) return null
191
+ return (
192
+ <ul
193
+ className={cn('flex list-none flex-wrap gap-[var(--space-2)] p-0', className)}
194
+ style={{ marginBlockStart: 'var(--space-2)' }}
195
+ >
196
+ {attachments.map((attachment) => (
197
+ <AttachmentChip
198
+ key={attachment.localId}
199
+ attachment={attachment}
200
+ onRemove={onRemove}
201
+ {...(onInsertAsText === undefined ? {} : { onInsertAsText })}
202
+ />
203
+ ))}
204
+ </ul>
205
+ )
206
+ }
207
+
208
+ function AttachmentChip({
209
+ attachment,
210
+ onRemove,
211
+ onInsertAsText,
212
+ }: {
213
+ attachment: ComposerAttachment
214
+ onRemove: (localId: string) => void
215
+ onInsertAsText?: ((localId: string) => void) | undefined
216
+ }) {
217
+ const uploading = attachment.state === 'uploading'
218
+ const failed = attachment.state === 'failed'
219
+ const restorable = onInsertAsText !== undefined && attachment.pastedText !== undefined
220
+
221
+ return (
222
+ <li
223
+ className={cn(
224
+ 'flex min-w-0 items-center gap-[var(--space-2)] border px-[var(--space-2)] py-[var(--space-1)]',
225
+ 'text-[12px]',
226
+ )}
227
+ style={{
228
+ borderRadius: 'var(--radius-sm)',
229
+ borderColor: failed ? 'rgb(var(--destructive))' : 'rgb(var(--border))',
230
+ // `--muted`, not `--surface-overlay*`. CLAUDE.md §2 names chips as
231
+ // exactly what the flat opaque grey is for; the translucent overlay
232
+ // tints wash out when they stack on another layer, and a chip inside a
233
+ // composer inside a card is two layers deep already.
234
+ background: 'rgb(var(--muted))',
235
+ // The chip's height cannot depend on whether it holds a thumbnail, a
236
+ // progress bar or an error, or the composer would jump on every state
237
+ // change. One declared minimum, three contents.
238
+ minBlockSize: 'var(--ctrl-md)',
239
+ maxInlineSize: 'calc(var(--space-12) * 5)',
240
+ }}
241
+ >
242
+ <AttachmentChipThumb attachment={attachment} />
243
+
244
+ <span className="flex min-w-0 flex-col">
245
+ <span className="truncate" style={{ color: 'rgb(var(--foreground))' }}>
246
+ {attachment.name}
247
+ </span>
248
+ <span
249
+ className="flex items-center gap-[var(--space-2)] tabular-nums"
250
+ style={{
251
+ color: failed ? 'rgb(var(--destructive))' : 'rgb(var(--text-tertiary))',
252
+ // Reserved so the second line exists before there is anything on it.
253
+ minBlockSize: 'var(--space-4)',
254
+ }}
255
+ >
256
+ {failed ? (
257
+ <span className="truncate">{attachment.error ?? 'Upload failed'}</span>
258
+ ) : uploading ? (
259
+ <AttachmentProgress value={attachment.progress} label={attachment.name} />
260
+ ) : attachment.charCount === undefined ? (
261
+ <span>{formatAttachmentSize(attachment.sizeBytes)}</span>
262
+ ) : (
263
+ // D5, verbatim: "the character count shown on the chip". A byte
264
+ // size would be the wrong unit here — the person pasted characters,
265
+ // and for anything non-ASCII the two numbers differ.
266
+ <span>{formatCharacterCount(attachment.charCount)}</span>
267
+ )}
268
+ </span>
269
+ </span>
270
+
271
+ {restorable ? (
272
+ <Button
273
+ type="button"
274
+ variant="secondary"
275
+ size="sm"
276
+ onClick={() => {
277
+ onInsertAsText?.(attachment.localId)
278
+ }}
279
+ >
280
+ Insert as text instead
281
+ </Button>
282
+ ) : null}
283
+
284
+ <button
285
+ type="button"
286
+ aria-label={uploading ? `Cancel ${attachment.name}` : `Remove ${attachment.name}`}
287
+ className={QUIET_ICON}
288
+ style={QUIET_ICON_BOX}
289
+ onClick={() => {
290
+ onRemove(attachment.localId)
291
+ }}
292
+ >
293
+ <X size={12} strokeWidth={1.75} aria-hidden="true" />
294
+ </button>
295
+ </li>
296
+ )
297
+ }
298
+
299
+ /** A square thumbnail slot that exists whether or not there is an image in it. */
300
+ function AttachmentChipThumb({ attachment }: { attachment: ComposerAttachment }) {
301
+ const visual = attachment.kind === 'image' || attachment.kind === 'gif'
302
+ return (
303
+ <span
304
+ className="flex shrink-0 items-center justify-center overflow-hidden border"
305
+ style={{
306
+ inlineSize: 'var(--space-6)',
307
+ blockSize: 'var(--space-6)',
308
+ borderRadius: 'var(--radius-xs)',
309
+ borderColor: 'rgb(var(--border))',
310
+ background: 'rgb(var(--muted))',
311
+ color: 'rgb(var(--text-tertiary))',
312
+ }}
313
+ >
314
+ {visual && attachment.previewUrl !== undefined ? (
315
+ <img src={attachment.previewUrl} alt="" className="h-full w-full object-cover" />
316
+ ) : visual ? (
317
+ <ImageIcon size={12} strokeWidth={1.5} aria-hidden="true" />
318
+ ) : (
319
+ <FileText size={12} strokeWidth={1.5} aria-hidden="true" />
320
+ )}
321
+ </span>
322
+ )
323
+ }
324
+
325
+ /**
326
+ * A determinate bar in a track that is always there.
327
+ *
328
+ * `role="progressbar"` with the real values, so a screen reader gets the number
329
+ * rather than a decorative div. The transition is on `inline-size` alone and
330
+ * drops out under reduced motion — this is a data change, and D-principles say
331
+ * data settles with ease-out and never springs.
332
+ */
333
+ function AttachmentProgress({ value, label }: { value: number; label: string }) {
334
+ const percent = Math.round(Math.max(0, Math.min(1, value)) * 100)
335
+ return (
336
+ <span
337
+ role="progressbar"
338
+ aria-label={`Uploading ${label}`}
339
+ aria-valuemin={0}
340
+ aria-valuemax={100}
341
+ aria-valuenow={percent}
342
+ className="block overflow-hidden"
343
+ style={{
344
+ inlineSize: 'calc(var(--space-12) * 2)',
345
+ blockSize: 'var(--space-1)',
346
+ borderRadius: 'var(--radius-full)',
347
+ background: 'rgb(var(--muted))',
348
+ }}
349
+ >
350
+ <span
351
+ className="block transition-[inline-size] duration-[var(--dur-base)] ease-[var(--ease-out)] motion-reduce:transition-none"
352
+ style={{
353
+ inlineSize: `${String(percent)}%`,
354
+ blockSize: '100%',
355
+ background: 'rgb(var(--accent))',
356
+ }}
357
+ />
358
+ </span>
359
+ )
360
+ }
361
+
362
+ /* ── the button + the drop target (D1) ──────────────────────────────────── */
363
+
364
+ export interface AttachmentButtonProps {
365
+ readonly onFiles: (files: FileList) => void
366
+ /** `accept` for the file input. Mirrors the server's extension allowlist. */
367
+ readonly accept?: string | undefined
368
+ readonly disabled?: boolean | undefined
369
+ readonly label?: string | undefined
370
+ }
371
+
372
+ /** The paperclip. A real `<input type="file">` behind a labelled button. */
373
+ export function AttachmentButton({
374
+ onFiles,
375
+ accept,
376
+ disabled = false,
377
+ label = 'Attach a file',
378
+ }: AttachmentButtonProps) {
379
+ const inputRef = useRef<HTMLInputElement>(null)
380
+ const inputId = useId()
381
+ return (
382
+ <>
383
+ <input
384
+ ref={inputRef}
385
+ id={inputId}
386
+ type="file"
387
+ multiple
388
+ hidden
389
+ {...(accept === undefined ? {} : { accept })}
390
+ onChange={(event) => {
391
+ const { files } = event.currentTarget
392
+ if (files !== null && files.length > 0) onFiles(files)
393
+ // Reset so re-choosing the SAME file fires `change` again.
394
+ event.currentTarget.value = ''
395
+ }}
396
+ />
397
+ <button
398
+ type="button"
399
+ aria-label={label}
400
+ className={QUIET_ICON}
401
+ style={QUIET_ICON_BOX}
402
+ disabled={disabled}
403
+ onClick={() => {
404
+ inputRef.current?.click()
405
+ }}
406
+ >
407
+ <Paperclip size={14} strokeWidth={1.75} aria-hidden="true" />
408
+ </button>
409
+ </>
410
+ )
411
+ }
412
+
413
+ export interface AttachmentDropZoneProps {
414
+ readonly onFiles: (files: FileList) => void
415
+ readonly disabled?: boolean | undefined
416
+ readonly children: ReactNode
417
+ readonly className?: string | undefined
418
+ }
419
+
420
+ /**
421
+ * Wrap the composer to accept a drop.
422
+ *
423
+ * The overlay is `visibility`-toggled inside a slot that is always present, so
424
+ * a drag does not resize the composer — same reserve-the-slot rule as
425
+ * everything else here. The drag counter exists because `dragleave` fires when
426
+ * the pointer crosses a CHILD boundary, and a naive boolean flickers the
427
+ * overlay every time the cursor passes over the toolbar.
428
+ */
429
+ export function AttachmentDropZone({
430
+ onFiles,
431
+ disabled = false,
432
+ children,
433
+ className,
434
+ }: AttachmentDropZoneProps) {
435
+ const depth = useRef(0)
436
+ const [over, setOver] = useState(false)
437
+
438
+ const onDragEnter = useCallback(
439
+ (event: DragEvent<HTMLDivElement>) => {
440
+ if (disabled || !Array.from(event.dataTransfer.types).includes('Files')) return
441
+ depth.current += 1
442
+ setOver(true)
443
+ },
444
+ [disabled],
445
+ )
446
+
447
+ const onDragLeave = useCallback(() => {
448
+ depth.current = Math.max(0, depth.current - 1)
449
+ if (depth.current === 0) setOver(false)
450
+ }, [])
451
+
452
+ return (
453
+ <div
454
+ className={cn('relative', className)}
455
+ onDragEnter={onDragEnter}
456
+ onDragOver={(event) => {
457
+ if (!disabled && Array.from(event.dataTransfer.types).includes('Files')) {
458
+ event.preventDefault()
459
+ }
460
+ }}
461
+ onDragLeave={onDragLeave}
462
+ onDrop={(event) => {
463
+ depth.current = 0
464
+ setOver(false)
465
+ if (disabled) return
466
+ const { files } = event.dataTransfer
467
+ if (files.length === 0) return
468
+ event.preventDefault()
469
+ onFiles(files)
470
+ }}
471
+ >
472
+ {children}
473
+ <div
474
+ aria-hidden="true"
475
+ className="pointer-events-none absolute inset-0 flex items-center justify-center border border-dashed transition-opacity duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none"
476
+ style={{
477
+ visibility: over ? 'visible' : 'hidden',
478
+ opacity: over ? 1 : 0,
479
+ borderRadius: 'var(--radius-md)',
480
+ borderColor: 'rgb(var(--accent))',
481
+ background: 'rgb(var(--accent-subtle))',
482
+ color: 'rgb(var(--accent-ink))',
483
+ }}
484
+ >
485
+ Drop to attach
486
+ </div>
487
+ </div>
488
+ )
489
+ }
490
+
491
+ /* ── the GIF picker (D4) ────────────────────────────────────────────────── */
492
+
493
+ export interface GifPickerResult {
494
+ readonly id: string
495
+ readonly title: string
496
+ readonly previewUrl: string
497
+ readonly stillUrl: string
498
+ readonly width: number
499
+ readonly height: number
500
+ }
501
+
502
+ export interface GifPickerProps {
503
+ /** Injected by the host — the primitive owns no fetch client (ADR-147 D5). */
504
+ readonly search: (query: string) => Promise<readonly GifPickerResult[]>
505
+ readonly onPick: (gif: GifPickerResult) => void
506
+ readonly className?: string | undefined
507
+ }
508
+
509
+ /**
510
+ * Type-to-search, debounced, with the still frame under reduced motion.
511
+ *
512
+ * The debounce is `GIF_SEARCH_DEBOUNCE_MS` and it is load-bearing rather than
513
+ * polish: Giphy's free tier is 100 requests/hour and an undebounced picker
514
+ * spends it in a minute.
515
+ *
516
+ * The grid reserves its own height so results appearing does not shove the
517
+ * composer, and every tile declares its aspect ratio so the grid does not
518
+ * reflow as renditions load at different sizes.
519
+ */
520
+ export function GifPicker({ search, onPick, className }: GifPickerProps) {
521
+ const [query, setQuery] = useState('')
522
+ const [results, setResults] = useState<readonly GifPickerResult[]>([])
523
+ const [state, setState] = useState<'idle' | 'searching' | 'error'>('idle')
524
+ const reduced = usePrefersReducedMotion()
525
+ const listId = useId()
526
+
527
+ /**
528
+ * `search` is read through a ref and is NOT a dependency — the same hazard
529
+ * `useAttachmentUrl` documents. A host writing
530
+ * `search={(q) => api.searchGifs(brandId, q)}` hands over a new identity every
531
+ * render; with that in the dependency array this effect would re-run, call
532
+ * `setState('searching')`, re-render, and re-run forever. The query is what
533
+ * identifies a search, and it is the only thing that should restart one.
534
+ */
535
+ const searchRef = useRef(search)
536
+ searchRef.current = search
537
+
538
+ useEffect(() => {
539
+ const trimmed = query.trim()
540
+ if (trimmed.length === 0) {
541
+ setResults([])
542
+ setState('idle')
543
+ return
544
+ }
545
+ let live = true
546
+ setState('searching')
547
+ const timer = setTimeout(() => {
548
+ void searchRef
549
+ .current(trimmed)
550
+ .then((next) => {
551
+ if (!live) return
552
+ setResults(next)
553
+ setState('idle')
554
+ })
555
+ .catch(() => {
556
+ if (!live) return
557
+ setResults([])
558
+ setState('error')
559
+ })
560
+ }, GIF_SEARCH_DEBOUNCE_MS)
561
+ return () => {
562
+ live = false
563
+ clearTimeout(timer)
564
+ }
565
+ }, [query])
566
+
567
+ return (
568
+ <div className={cn('flex flex-col gap-[var(--space-3)]', className)}>
569
+ <Input
570
+ value={query}
571
+ onChange={(event) => {
572
+ setQuery(event.currentTarget.value)
573
+ }}
574
+ placeholder="Search GIFs"
575
+ aria-label="Search GIFs"
576
+ aria-controls={listId}
577
+ leadingAffix={<Search size={14} strokeWidth={1.75} aria-hidden="true" />}
578
+ />
579
+
580
+ <div
581
+ id={listId}
582
+ className="grid grid-cols-3 gap-[var(--space-2)] overflow-y-auto"
583
+ style={{
584
+ // Declared, so the panel is the same size empty, searching and full.
585
+ blockSize: 'calc(var(--space-12) * 6)',
586
+ }}
587
+ >
588
+ {results.map((gif) => (
589
+ <button
590
+ key={gif.id}
591
+ type="button"
592
+ onClick={() => {
593
+ onPick(gif)
594
+ }}
595
+ className="block overflow-hidden border transition-[border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:border-[rgb(var(--border-strong))] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)] motion-reduce:transition-none"
596
+ style={{
597
+ borderRadius: 'var(--radius-sm)',
598
+ borderColor: 'rgb(var(--border))',
599
+ background: 'rgb(var(--muted))',
600
+ aspectRatio: gif.height > 0 ? String(gif.width / gif.height) : '1',
601
+ }}
602
+ >
603
+ {reduced ? (
604
+ <img src={gif.stillUrl} alt={gif.title} className="h-full w-full object-cover" />
605
+ ) : (
606
+ <video
607
+ src={gif.previewUrl}
608
+ poster={gif.stillUrl}
609
+ autoPlay
610
+ loop
611
+ muted
612
+ playsInline
613
+ aria-label={gif.title}
614
+ className="h-full w-full object-cover"
615
+ />
616
+ )}
617
+ </button>
618
+ ))}
619
+ {results.length === 0 ? (
620
+ <p
621
+ className="col-span-3 self-center text-center text-[12px]"
622
+ style={{ color: 'rgb(var(--text-tertiary))' }}
623
+ >
624
+ {state === 'error'
625
+ ? "GIF search isn't available right now."
626
+ : state === 'searching'
627
+ ? 'Searching…'
628
+ : query.trim().length === 0
629
+ ? 'Type to search.'
630
+ : 'No GIFs matched.'}
631
+ </p>
632
+ ) : null}
633
+ </div>
634
+
635
+ {/* TODO(legal): "Powered by GIPHY" attribution is required by Giphy's
636
+ terms of service. Noted here and in ADR-151 D4. */}
637
+ <p
638
+ className="text-[11px] uppercase"
639
+ style={{ color: 'rgb(var(--text-tertiary))', letterSpacing: '0.06em' }}
640
+ >
641
+ Powered by GIPHY
642
+ </p>
643
+ </div>
644
+ )
645
+ }
646
+
647
+ /* ── posted-comment rendering (D6) ──────────────────────────────────────── */
648
+
649
+ export interface AttachmentGifProps {
650
+ readonly attachment: ThreadAttachment
651
+ /** Resolves the short-lived URL. Omit and the placeholder renders instead. */
652
+ readonly resolveUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
653
+ readonly className?: string | undefined
654
+ }
655
+
656
+ /**
657
+ * A GIF in a posted comment, under `prefers-reduced-motion`.
658
+ *
659
+ * The stored rendition is an mp4, so "still frame with a play control" is a
660
+ * `<video>` that has not been told to play: it paints its poster frame and sits
661
+ * there. Pressing play is an explicit choice, and the control disappears once
662
+ * it is playing because at that point the video's own affordances take over.
663
+ *
664
+ * Without the preference it autoplays muted and looping, which is what a GIF
665
+ * is. With it, nothing moves until asked.
666
+ */
667
+ export function AttachmentGif({ attachment, resolveUrl, className }: AttachmentGifProps) {
668
+ const reduced = usePrefersReducedMotion()
669
+ const { url, failed, loading } = useAttachmentUrl(attachment.id, resolveUrl)
670
+ const [playing, setPlaying] = useState(false)
671
+ const videoRef = useRef<HTMLVideoElement>(null)
672
+
673
+ // The preference can flip while the comment is on screen. Honouring it only
674
+ // at mount would leave a GIF looping at exactly the moment someone asked for
675
+ // less motion.
676
+ useEffect(() => {
677
+ if (reduced) setPlaying(false)
678
+ }, [reduced])
679
+
680
+ const shouldPlay = !reduced || playing
681
+
682
+ if (url === null || failed) {
683
+ return (
684
+ <AttachmentPlaceholder
685
+ attachment={attachment}
686
+ state={loading && !failed ? 'loading' : 'broken'}
687
+ className={className}
688
+ />
689
+ )
690
+ }
691
+
692
+ return (
693
+ <span
694
+ className={cn('relative block overflow-hidden border', className)}
695
+ style={{
696
+ aspectRatio: String(attachment.aspectRatio ?? 16 / 10),
697
+ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
698
+ borderColor: 'rgb(var(--border))',
699
+ background: 'rgb(var(--surface-inset))',
700
+ inlineSize: 'calc(var(--space-12) * 3)',
701
+ minInlineSize: 'calc(var(--space-12) * 3)',
702
+ }}
703
+ >
704
+ <video
705
+ ref={videoRef}
706
+ src={url}
707
+ loop
708
+ muted
709
+ playsInline
710
+ autoPlay={shouldPlay}
711
+ aria-label={attachment.name}
712
+ className="h-full w-full object-cover"
713
+ />
714
+ {shouldPlay ? null : (
715
+ <button
716
+ type="button"
717
+ aria-label={`Play ${attachment.name}`}
718
+ onClick={() => {
719
+ setPlaying(true)
720
+ void videoRef.current?.play()
721
+ }}
722
+ className="absolute inset-0 flex items-center justify-center focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]"
723
+ style={{ background: 'rgb(var(--media-scrim-mid))', color: 'rgb(var(--media-on-scrim))' }}
724
+ >
725
+ <Play size={20} strokeWidth={1.75} aria-hidden="true" />
726
+ </button>
727
+ )}
728
+ </span>
729
+ )
730
+ }
731
+
732
+ /**
733
+ * The box an image keeps when it has nothing to show.
734
+ *
735
+ * D6: "a broken image is a placeholder with a small icon, never a collapsed
736
+ * box". The dimensions come from the DECLARED aspect ratio, which is why this
737
+ * is the same size as the image would have been.
738
+ */
739
+ export function AttachmentPlaceholder({
740
+ attachment,
741
+ state = 'broken',
742
+ className,
743
+ }: {
744
+ attachment: ThreadAttachment
745
+ /**
746
+ * `loading` while the URL is still being resolved, `broken` once it is known
747
+ * not to be coming. Same BOX either way — the layout must not depend on which
748
+ * — but not the same picture: the broken icon is a statement about the file,
749
+ * and saying it while the answer is still in flight is saying something
750
+ * untrue about someone's own comment.
751
+ */
752
+ state?: 'loading' | 'broken'
753
+ className?: string | undefined
754
+ }) {
755
+ return (
756
+ <span
757
+ className={cn('flex items-center justify-center overflow-hidden border', className)}
758
+ style={{
759
+ aspectRatio: String(attachment.aspectRatio ?? 16 / 10),
760
+ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
761
+ borderColor: 'rgb(var(--border))',
762
+ background: 'rgb(var(--surface-inset))',
763
+ color: 'rgb(var(--text-tertiary))',
764
+ inlineSize: 'calc(var(--space-12) * 3)',
765
+ minInlineSize: 'calc(var(--space-12) * 3)',
766
+ }}
767
+ aria-label={state === 'loading' ? `Loading ${attachment.name}` : undefined}
768
+ >
769
+ {state === 'broken' ? <ImageOff size={14} strokeWidth={1.5} aria-hidden="true" /> : null}
770
+ </span>
771
+ )
772
+ }
773
+
774
+ export interface AttachmentImageProps {
775
+ readonly attachment: ThreadAttachment
776
+ readonly onOpen?: ((attachment: ThreadAttachment) => void) | undefined
777
+ /** Resolves the short-lived URL. Omit and the placeholder renders instead. */
778
+ readonly resolveUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
779
+ readonly className?: string | undefined
780
+ }
781
+
782
+ /**
783
+ * A posted image with a stable reserved box and a real reduced-motion path for
784
+ * uploaded GIFs.
785
+ *
786
+ * Uploaded GIFs intentionally remain `kind: 'image'`: unlike a Giphy mp4, the
787
+ * stored file cannot be paused. Its MIME type lets this renderer capture the
788
+ * loaded image into a canvas once. Under reduced motion the live image is
789
+ * hidden behind that still; the labelled play button swaps it back only after
790
+ * an explicit choice.
791
+ */
792
+ export function AttachmentImage({
793
+ attachment,
794
+ onOpen,
795
+ resolveUrl,
796
+ className,
797
+ }: AttachmentImageProps) {
798
+ const reduced = usePrefersReducedMotion()
799
+ const [broken, setBroken] = useState(false)
800
+ const [playing, setPlaying] = useState(false)
801
+ const [capturedSrc, setCapturedSrc] = useState<string | null>(null)
802
+ const imageRef = useRef<HTMLImageElement>(null)
803
+ const canvasRef = useRef<HTMLCanvasElement>(null)
804
+ const capturedSrcRef = useRef<string | null>(null)
805
+
806
+ // Two ways a picture can be absent, and they are NOT the same: `url` on the
807
+ // attachment (a host that already has one) versus a lazy resolve. Both end at
808
+ // the same placeholder, which is why the box is declared above either.
809
+ const resolved = useAttachmentUrl(attachment.id, attachment.url === undefined ? resolveUrl : undefined)
810
+ const src = attachment.url ?? resolved.url ?? undefined
811
+ const uploadedGif = attachment.mime?.toLowerCase() === 'image/gif'
812
+
813
+ const captureStill = useCallback(
814
+ (image: HTMLImageElement) => {
815
+ if (!uploadedGif || src === undefined || capturedSrcRef.current === src) return
816
+ const canvas = canvasRef.current
817
+ const width = image.naturalWidth
818
+ const height = image.naturalHeight
819
+ if (canvas === null || width <= 0 || height <= 0) return
820
+ canvas.width = width
821
+ canvas.height = height
822
+ const context = canvas.getContext('2d')
823
+ if (context === null) return
824
+ context.drawImage(image, 0, 0, width, height)
825
+ capturedSrcRef.current = src
826
+ setCapturedSrc(src)
827
+ },
828
+ [src, uploadedGif],
829
+ )
830
+
831
+ // The preference can flip while the comment is mounted. Capture normally
832
+ // happens on load; the complete check also covers a cached image whose load
833
+ // event preceded the preference change.
834
+ useEffect(() => {
835
+ if (!reduced) return
836
+ setPlaying(false)
837
+ const image = imageRef.current
838
+ if (image?.complete === true) captureStill(image)
839
+ }, [captureStill, reduced])
840
+
841
+ const missing = broken || resolved.failed || src === undefined
842
+ // Still resolving is NOT the same as broken. Both keep the reserved box; only
843
+ // one of them says the file is gone.
844
+ const pending = resolved.loading && !broken && !resolved.failed
845
+ const interactive = onOpen !== undefined && !missing
846
+ const showStill = uploadedGif && reduced && !playing && !missing
847
+
848
+ const body = pending ? null : missing ? (
849
+ <span
850
+ className="flex h-full w-full items-center justify-center"
851
+ style={{ color: 'rgb(var(--text-tertiary))' }}
852
+ >
853
+ <ImageOff size={14} strokeWidth={1.5} aria-hidden="true" />
854
+ </span>
855
+ ) : (
856
+ <span className="relative block h-full w-full">
857
+ <img
858
+ ref={imageRef}
859
+ src={src}
860
+ alt={attachment.name}
861
+ loading="lazy"
862
+ className="h-full w-full object-cover"
863
+ style={{ visibility: showStill ? 'hidden' : 'visible' }}
864
+ onLoad={(event) => {
865
+ captureStill(event.currentTarget)
866
+ }}
867
+ onError={() => {
868
+ setBroken(true)
869
+ }}
870
+ />
871
+ {uploadedGif ? (
872
+ <canvas
873
+ ref={canvasRef}
874
+ aria-hidden="true"
875
+ hidden={!showStill || capturedSrc !== src}
876
+ className="absolute inset-0 h-full w-full object-cover"
877
+ />
878
+ ) : null}
879
+ </span>
880
+ )
881
+
882
+ return (
883
+ <span
884
+ className={cn(
885
+ 'relative block overflow-hidden border',
886
+ 'transition-[border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none',
887
+ interactive && !showStill && 'hover:border-[rgb(var(--border-strong))]',
888
+ (interactive || showStill) &&
889
+ 'focus-within:outline-none focus-within:[box-shadow:var(--ring-focus)]',
890
+ className,
891
+ )}
892
+ style={{
893
+ // Declared up front so the row cannot reflow when the bytes land.
894
+ aspectRatio: String(attachment.aspectRatio ?? 16 / 10),
895
+ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
896
+ borderColor: 'rgb(var(--border))',
897
+ background: 'rgb(var(--surface-inset))',
898
+ inlineSize: 'calc(var(--space-12) * 3)',
899
+ minInlineSize: 'calc(var(--space-12) * 3)',
900
+ }}
901
+ >
902
+ {interactive ? (
903
+ <button
904
+ type="button"
905
+ aria-label={`Open ${attachment.name}`}
906
+ aria-hidden={showStill ? true : undefined}
907
+ disabled={showStill}
908
+ onClick={() => {
909
+ onOpen?.(attachment)
910
+ }}
911
+ className="block h-full w-full focus-visible:outline-none"
912
+ >
913
+ {body}
914
+ </button>
915
+ ) : (
916
+ body
917
+ )}
918
+ {showStill ? (
919
+ <button
920
+ type="button"
921
+ aria-label={`Play ${attachment.name}`}
922
+ onClick={() => {
923
+ setPlaying(true)
924
+ }}
925
+ className="absolute inset-0 flex items-center justify-center focus-visible:outline-none"
926
+ style={{ background: 'rgb(var(--media-scrim-mid))', color: 'rgb(var(--media-on-scrim))' }}
927
+ >
928
+ <Play size={20} strokeWidth={1.75} aria-hidden="true" />
929
+ </button>
930
+ ) : null}
931
+ </span>
932
+ )
933
+ }
934
+
935
+ /* ── the lightbox (D6) ──────────────────────────────────────────────────── */
936
+
937
+ export interface AttachmentLightboxProps {
938
+ readonly attachment: ThreadAttachment | null
939
+ readonly onClose: () => void
940
+ readonly resolveUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
941
+ }
942
+
943
+ /**
944
+ * Full-size view of one attachment.
945
+ *
946
+ * Deliberately small: a scrim, the image, a close button, and `Escape`. It is
947
+ * not a gallery — ADR-151 says "click opens a lightbox", and a carousel with
948
+ * keyboard paging is a different feature with a different accessibility
949
+ * surface.
950
+ *
951
+ * Focus moves to the close button on open, because otherwise `Escape` is the
952
+ * only way out for a keyboard and the focus ring is somewhere behind the scrim.
953
+ */
954
+ export function AttachmentLightbox({ attachment, onClose, resolveUrl }: AttachmentLightboxProps) {
955
+ const closeRef = useRef<HTMLButtonElement>(null)
956
+ const id = attachment?.id ?? ''
957
+ const { url, failed } = useAttachmentUrl(id, attachment === null ? undefined : resolveUrl)
958
+
959
+ const surfaceRef = useRef<HTMLDivElement>(null)
960
+
961
+ useEffect(() => {
962
+ if (attachment === null) return
963
+ closeRef.current?.focus()
964
+
965
+ /**
966
+ * `aria-modal="true"` asserts that nothing behind the scrim is reachable.
967
+ * Without a trap that assertion is false — Tab walks straight out into a
968
+ * page a sighted user can see is covered and a screen-reader user has been
969
+ * told is not there. Focus is therefore wrapped rather than merely placed.
970
+ *
971
+ * The dialog holds two focusables at most (the close button, and a video's
972
+ * controls), so a full focus-trap library would be more machinery than the
973
+ * problem: collect what is focusable at the moment Tab is pressed, and
974
+ * wrap at the ends.
975
+ */
976
+ const onKey = (event: KeyboardEvent) => {
977
+ if (event.key === 'Escape') {
978
+ onClose()
979
+ return
980
+ }
981
+ if (event.key !== 'Tab') return
982
+ const root = surfaceRef.current
983
+ if (root === null) return
984
+ const focusable = Array.from(
985
+ root.querySelectorAll<HTMLElement>(
986
+ 'a[href], button:not([disabled]), video[controls], [tabindex]:not([tabindex="-1"])',
987
+ ),
988
+ )
989
+ if (focusable.length === 0) {
990
+ event.preventDefault()
991
+ return
992
+ }
993
+ const first = focusable[0]
994
+ const last = focusable[focusable.length - 1]
995
+ const active = document.activeElement
996
+ if (event.shiftKey && (active === first || !root.contains(active))) {
997
+ event.preventDefault()
998
+ last?.focus()
999
+ } else if (!event.shiftKey && (active === last || !root.contains(active))) {
1000
+ event.preventDefault()
1001
+ first?.focus()
1002
+ }
1003
+ }
1004
+ document.addEventListener('keydown', onKey)
1005
+ return () => {
1006
+ document.removeEventListener('keydown', onKey)
1007
+ }
1008
+ }, [attachment, onClose])
1009
+
1010
+ if (attachment === null) return null
1011
+
1012
+ return (
1013
+ <div
1014
+ role="dialog"
1015
+ aria-modal="true"
1016
+ aria-label={attachment.name}
1017
+ className="fixed inset-0 z-50 flex items-center justify-center p-[var(--space-6)]"
1018
+ style={{ background: 'rgb(var(--media-scrim-strong))' }}
1019
+ onClick={onClose}
1020
+ >
1021
+ <div
1022
+ ref={surfaceRef}
1023
+ className="relative flex max-h-full max-w-full items-center justify-center"
1024
+ onClick={(event) => {
1025
+ event.stopPropagation()
1026
+ }}
1027
+ >
1028
+ {url === null || failed ? (
1029
+ <p style={{ color: 'rgb(var(--media-on-scrim))' }}>
1030
+ {failed ? "This file isn't available." : 'Loading…'}
1031
+ </p>
1032
+ ) : attachment.kind === 'gif' ? (
1033
+ <video
1034
+ src={url}
1035
+ controls
1036
+ loop
1037
+ muted
1038
+ playsInline
1039
+ aria-label={attachment.name}
1040
+ className="max-h-full max-w-full"
1041
+ />
1042
+ ) : (
1043
+ <img src={url} alt={attachment.name} className="max-h-full max-w-full object-contain" />
1044
+ )}
1045
+ <span className="absolute" style={{ insetBlockStart: 0, insetInlineEnd: 0 }}>
1046
+ <button
1047
+ ref={closeRef}
1048
+ type="button"
1049
+ aria-label="Close"
1050
+ className={QUIET_ICON}
1051
+ style={{ ...QUIET_ICON_BOX, color: 'rgb(var(--media-on-scrim))' }}
1052
+ onClick={onClose}
1053
+ >
1054
+ <X size={14} strokeWidth={1.75} aria-hidden="true" />
1055
+ </button>
1056
+ </span>
1057
+ </div>
1058
+ </div>
1059
+ )
1060
+ }
1061
+
1062
+ /**
1063
+ * The `accept` string for the file input, mirroring the extension allowlist the
1064
+ * server enforces (ADR-151 D3). Exported so a host does not re-type the list
1065
+ * and drift from it.
1066
+ *
1067
+ * It is a filter, never a check: the worker reads the MAGIC NUMBER, so a
1068
+ * renamed file sails past `accept` and is refused there. What this buys is that
1069
+ * the file chooser shows the right files.
1070
+ */
1071
+ export const ATTACHMENT_ACCEPT = '.png,.jpg,.jpeg,.webp,.gif,.pdf,.txt,.md,.csv,.json'