@lovett/ui 0.0.11 → 0.1.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 (141) 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 +5402 -71
  4. package/dist/index.js +21992 -11618
  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 +208 -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__/sparkline.test.tsx +368 -0
  27. package/src/__tests__/stat-layer.test.tsx +271 -0
  28. package/src/__tests__/stat-strip.test.tsx +175 -0
  29. package/src/__tests__/status.test.tsx +293 -0
  30. package/src/__tests__/tabs.test.tsx +303 -0
  31. package/src/__tests__/token-shape.test.ts +132 -2
  32. package/src/avatar.tsx +352 -0
  33. package/src/bar-chart.tsx +1214 -0
  34. package/src/board.tsx +658 -0
  35. package/src/chart-frame.tsx +960 -0
  36. package/src/chart-legend.tsx +304 -0
  37. package/src/chart-tooltip.tsx +267 -0
  38. package/src/code-block.tsx +62 -8
  39. package/src/delta-chip.tsx +263 -0
  40. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  41. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  42. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  43. package/src/detail/__tests__/field-row.test.tsx +357 -0
  44. package/src/detail/activity-pane.tsx +507 -0
  45. package/src/detail/calendar.tsx +355 -0
  46. package/src/detail/detail-divider.tsx +261 -0
  47. package/src/detail/detail-header.tsx +287 -0
  48. package/src/detail/detail-menu.tsx +254 -0
  49. package/src/detail/detail-surface.tsx +1110 -0
  50. package/src/detail/field-list.tsx +196 -0
  51. package/src/detail/field-row.tsx +1131 -0
  52. package/src/detail/index.ts +58 -0
  53. package/src/detail/segmented-choice.tsx +94 -0
  54. package/src/detail/types.ts +129 -0
  55. package/src/display-popover.tsx +487 -0
  56. package/src/display-store.tsx +242 -0
  57. package/src/donut-chart.tsx +988 -0
  58. package/src/dropdown-menu.tsx +290 -19
  59. package/src/filter-core/EXPORTS.md +85 -0
  60. package/src/filter-core/__tests__/columns.test.ts +159 -0
  61. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  62. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  63. package/src/filter-core/__tests__/operators.test.ts +235 -0
  64. package/src/filter-core/__tests__/state.test.ts +268 -0
  65. package/src/filter-core/__tests__/url.test.ts +350 -0
  66. package/src/filter-core/columns.ts +134 -0
  67. package/src/filter-core/date-utils.ts +38 -0
  68. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  69. package/src/filter-core/faceting.ts +120 -0
  70. package/src/filter-core/filter-fns.ts +335 -0
  71. package/src/filter-core/index.ts +105 -0
  72. package/src/filter-core/operators.ts +433 -0
  73. package/src/filter-core/state.ts +280 -0
  74. package/src/filter-core/types.ts +247 -0
  75. package/src/filter-core/url.ts +261 -0
  76. package/src/filter-dropdown.tsx +12 -0
  77. package/src/filter-menu.tsx +649 -0
  78. package/src/floating-drawer.tsx +19 -1
  79. package/src/gauge-ring.tsx +435 -0
  80. package/src/hue.ts +52 -0
  81. package/src/index.ts +296 -0
  82. package/src/kbd.tsx +27 -4
  83. package/src/lib/chart.ts +866 -0
  84. package/src/lib/focus.ts +43 -1
  85. package/src/lib/layer-stack.ts +30 -3
  86. package/src/lib/remark-underline.ts +443 -0
  87. package/src/lib/series.ts +169 -0
  88. package/src/line-chart.tsx +1176 -0
  89. package/src/markdown.tsx +26 -7
  90. package/src/modal.tsx +42 -18
  91. package/src/progress-ledger.tsx +304 -0
  92. package/src/ranked-bars.tsx +386 -0
  93. package/src/segmented-pill.tsx +32 -9
  94. package/src/sortable.tsx +315 -1
  95. package/src/sparkline.tsx +416 -0
  96. package/src/stat-card.tsx +376 -0
  97. package/src/stat-strip.tsx +327 -0
  98. package/src/status.tsx +215 -0
  99. package/src/styles.css +1570 -0
  100. package/src/tabs.tsx +206 -25
  101. package/src/task-card.tsx +610 -0
  102. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  103. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  104. package/src/thread/__tests__/emoji.test.ts +187 -0
  105. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  106. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  107. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  108. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  109. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  110. package/src/thread/__tests__/relative-time.test.ts +71 -0
  111. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  112. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  113. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  114. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  115. package/src/thread/__tests__/thread.test.tsx +662 -0
  116. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  117. package/src/thread/actions.tsx +196 -0
  118. package/src/thread/attachments.tsx +1071 -0
  119. package/src/thread/comment-body.tsx +148 -0
  120. package/src/thread/comment-tree.ts +182 -0
  121. package/src/thread/comment.tsx +915 -0
  122. package/src/thread/composer-footer.tsx +125 -0
  123. package/src/thread/composer.tsx +319 -0
  124. package/src/thread/emoji.ts +283 -0
  125. package/src/thread/index.ts +153 -0
  126. package/src/thread/link-preview.tsx +341 -0
  127. package/src/thread/markdown-format.ts +155 -0
  128. package/src/thread/markdown-spec.ts +754 -0
  129. package/src/thread/rail.tsx +372 -0
  130. package/src/thread/reactions.tsx +415 -0
  131. package/src/thread/relative-time.tsx +94 -0
  132. package/src/thread/rich-composer-impl.tsx +1601 -0
  133. package/src/thread/rich-composer.tsx +195 -0
  134. package/src/thread/scroll-caret.ts +37 -0
  135. package/src/thread/suggestion-list.tsx +182 -0
  136. package/src/thread/thread.tsx +718 -0
  137. package/src/thread/types.ts +218 -0
  138. package/src/thread/use-attachments.ts +598 -0
  139. package/src/thread/use-now.ts +73 -0
  140. package/src/thread/use-thread.ts +316 -0
  141. package/src/tokens.css +112 -0
@@ -0,0 +1,718 @@
1
+ /**
2
+ * Thread — the styled default skin over `useThread`.
3
+ *
4
+ * ADR-147 T3. This is ONE presentation of the hook, not the only one: a
5
+ * consumer that wants different chrome calls `useThread` and draws its own.
6
+ * Everything below the hook boundary is layout and tokens.
7
+ *
8
+ * DELIBERATELY NOT A DIALOG. The reference was a modal, and the port
9
+ * correction it needed was "never `if (!open) return null`, because that kills
10
+ * the open/close animation". The cleanest way to not regress that rule is to
11
+ * not own the dialog at all: the thread is a panel, its host decides whether
12
+ * it lives in a drawer, a modal or a column. Inside this file the same rule is
13
+ * applied where it does bite — the reply composer and the reaction picker stay
14
+ * mounted and are driven by `data-open`.
15
+ *
16
+ * ONE CARD PER ROOT THREAD. This is the structural decision the rest of the
17
+ * layout follows from. The first build gave every comment its own card-like
18
+ * object, which made a conversation read as a stack of unrelated notices and
19
+ * made depth expensive — a reply had to earn a whole new box. Now a root
20
+ * comment AND its entire descendant tree share one card, sibling roots are
21
+ * separate cards, and depth inside a card costs an indent and a hairline.
22
+ * A thread is the unit people actually think in; a comment is not.
23
+ *
24
+ * SURFACES follow CLAUDE.md's data-display recipe, and the recipe is what
25
+ * forces the panel to step DOWN rather than the cards to step up. The panel is
26
+ * the frame (`data-framed` → `--surface-frame`: 247 light / 21 dark) and each
27
+ * thread card is `--surface-card` (255 / 27) with a border and a shadow — the
28
+ * same board-column-to-card relationship the rest of the system uses, opaque in
29
+ * both directions. A card tinted onto the panel with `--surface-overlay*` is
30
+ * the washed-out defect; grey STRUCTURE inside the card (the date rule, the
31
+ * depth rails, the composer border) is required and is not a candidate for
32
+ * "cleanup".
33
+ *
34
+ * RADII are computed, not eyeballed (interface standards §1). The panel is
35
+ * `--radius-xl` (20) with `--space-3` (12) of padding, so a thread card is
36
+ * 20 − 12 = 8 = `--radius-sm`. The card then re-declares the same property one
37
+ * rung down for its own contents. `.ds-thread` in styles.css owns both values.
38
+ */
39
+
40
+ import type { CSSProperties, ReactElement, ReactNode } from 'react'
41
+ import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
42
+ import { MessageSquare } from 'lucide-react'
43
+ import { Avatar, AvatarGroup, asAvatarTint } from '../avatar'
44
+ import Card from '../card'
45
+ import { cn } from '../lib/utils'
46
+ import { CommentItem, type ThreadRenderContext } from './comment'
47
+ import { ThreadComposer } from './composer'
48
+ import {
49
+ RichThreadComposer,
50
+ type RichComposerAttachmentProps,
51
+ type RichThreadComposerProps,
52
+ } from './rich-composer'
53
+ import { DEFAULT_INDENT_CLAMP_DEPTH } from './rail'
54
+ import { useThread, type UseThreadOptions } from './use-thread'
55
+ import type { MentionTarget } from './comment-body'
56
+ import type {
57
+ CommentDeliveryState,
58
+ CommentNode,
59
+ MentionSuggestionItem,
60
+ ThreadAttachment,
61
+ ThreadAuthor,
62
+ ThreadReactionKey,
63
+ ThreadSort,
64
+ ThreadVote,
65
+ } from './types'
66
+
67
+ /** React accepts CSS custom properties in `style`; the DOM typings do not declare them. */
68
+ type StyleWithVars = CSSProperties & Record<`--${string}`, string>
69
+
70
+ // `--thread-inner-radius` is NOT set here. It is declared once by `.ds-thread`
71
+ // in styles.css, which the `<section>` below always carries — duplicating it
72
+ // inline is exactly how the declared value and the effective one drift apart.
73
+ // Every consumer still reads it as `var(--thread-inner-radius, var(--radius-sm))`,
74
+ // so a host that forgot the stylesheet degrades to the right corner anyway.
75
+ const PANEL_STYLE: StyleWithVars = {
76
+ borderRadius: 'var(--radius-xl)',
77
+ padding: 'var(--space-3)',
78
+ }
79
+
80
+ /**
81
+ * A thread card is the one place the concentric chain steps down a rung, and
82
+ * it does it by re-declaring the property its descendants already read rather
83
+ * than by introducing a second one. Everything inside — the reply composer,
84
+ * an attachment thumbnail, the failure notice — keeps asking exactly one
85
+ * question and gets the answer that is right for where it is mounted.
86
+ */
87
+ const CARD_STYLE: StyleWithVars = {
88
+ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
89
+ '--thread-inner-radius': 'var(--thread-card-inner-radius, var(--radius-xs))',
90
+ // No block-start padding: the first comment's own `--thread-row-gap` supplies
91
+ // it, so the card reads as an even `--space-3` inset all round while every
92
+ // comment box in the tree — first or not — keeps an identical top edge. That
93
+ // identity is what lets one derived `--thread-avatar-center` place every
94
+ // elbow in the tree.
95
+ padding: '0 var(--space-3) var(--space-3)',
96
+ }
97
+
98
+ const SORTS: readonly { readonly id: ThreadSort; readonly label: string }[] = [
99
+ { id: 'top', label: 'Top' },
100
+ { id: 'new', label: 'Newest' },
101
+ ]
102
+
103
+ function SortPills({
104
+ value,
105
+ onChange,
106
+ }: {
107
+ value: ThreadSort
108
+ onChange: (next: ThreadSort) => void
109
+ }) {
110
+ return (
111
+ <div
112
+ // A selected state must step UP to an opaque surface, never be an alpha
113
+ // tint on a translucent track (CLAUDE.md §2): the track is the recessed
114
+ // opaque `--surface-inset`, the active pill is `--surface-card` with an
115
+ // accent border. Two alpha layers composited is how a "raised" pill ends
116
+ // up rendering darker than the well it sits in.
117
+ className="inline-flex gap-[var(--space-1)] border p-[var(--space-1)]"
118
+ role="group"
119
+ aria-label="Sort comments"
120
+ style={{
121
+ borderRadius: 'var(--radius-sm)',
122
+ borderColor: 'rgb(var(--border))',
123
+ background: 'rgb(var(--surface-inset))',
124
+ }}
125
+ >
126
+ {SORTS.map((sort) => {
127
+ const active = sort.id === value
128
+ return (
129
+ <button
130
+ key={sort.id}
131
+ type="button"
132
+ aria-pressed={active}
133
+ onClick={() => {
134
+ onChange(sort.id)
135
+ }}
136
+ className={cn(
137
+ 'inline-flex h-[24px] items-center px-[var(--space-2)] text-[12px] font-semibold',
138
+ 'transition-[background-color,color,border-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
139
+ 'motion-reduce:transition-none',
140
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
141
+ active
142
+ ? 'border border-[rgb(var(--accent))] bg-[rgb(var(--surface-card))] text-[rgb(var(--accent-ink))] [box-shadow:var(--shadow-sm)]'
143
+ : 'border border-transparent text-[rgb(var(--text-tertiary))] hover:text-[rgb(var(--foreground))]',
144
+ )}
145
+ style={{ borderRadius: 'var(--radius-xs)' }}
146
+ >
147
+ {sort.label}
148
+ </button>
149
+ )
150
+ })}
151
+ </div>
152
+ )
153
+ }
154
+
155
+ const DAY = 86_400_000
156
+
157
+ /** Local midnight for an epoch value — the boundary a person means by "day". */
158
+ function startOfDay(value: number): number {
159
+ const date = new Date(value)
160
+ date.setHours(0, 0, 0, 0)
161
+ return date.getTime()
162
+ }
163
+
164
+ /**
165
+ * "Today" / "Yesterday" / a written date. Derived from the INJECTED `now`, so
166
+ * a frozen clock produces a frozen label and the separators are as assertable
167
+ * as the timestamps beside them (D16).
168
+ */
169
+ function dayLabel(value: number, now: number, locale?: string): string {
170
+ const days = Math.round((startOfDay(now) - startOfDay(value)) / DAY)
171
+ if (days <= 0) return 'Today'
172
+ if (days === 1) return 'Yesterday'
173
+ const date = new Date(value)
174
+ const sameYear = date.getFullYear() === new Date(now).getFullYear()
175
+ return date.toLocaleDateString(locale, {
176
+ day: 'numeric',
177
+ month: 'long',
178
+ ...(sameYear ? {} : { year: 'numeric' }),
179
+ ...(days < 7 ? { weekday: 'long' } : {}),
180
+ })
181
+ }
182
+
183
+ /**
184
+ * A date separator, sticky to the top of the scroll region.
185
+ *
186
+ * Grouping is by RUN, not by bucket: a separator is emitted whenever a root's
187
+ * day differs from the previous root's. Under `new` that is the obvious
188
+ * chronological grouping; under `top` a day can legitimately appear more than
189
+ * once, and showing that honestly is better than either suppressing the
190
+ * separators or lying about the order by re-bucketing a ranked list into
191
+ * calendar order.
192
+ *
193
+ * It carries the PANEL's surface because a sticky element with no background
194
+ * smears the cards scrolling under it. `--surface-frame` is the panel in the
195
+ * `panel` variant; a `bare` host that sits on a different surface overrides it
196
+ * the same way it overrides the panel's other assumptions.
197
+ */
198
+ function DateSeparator({ label }: { label: string }) {
199
+ return (
200
+ <div
201
+ className="sticky top-0 z-[1] flex items-center gap-[var(--space-3)]"
202
+ style={{
203
+ paddingBlock: 'var(--space-2)',
204
+ background: 'rgb(var(--surface-frame))',
205
+ }}
206
+ >
207
+ <span className="h-px flex-1" style={{ background: 'rgb(var(--border))' }} />
208
+ <span
209
+ className="text-[12px] font-medium"
210
+ style={{ color: 'rgb(var(--text-tertiary))' }}
211
+ >
212
+ {label}
213
+ </span>
214
+ <span className="h-px flex-1" style={{ background: 'rgb(var(--border))' }} />
215
+ </div>
216
+ )
217
+ }
218
+
219
+ export interface ThreadParticipant {
220
+ readonly id: string
221
+ readonly name: string
222
+ readonly avatarUrl?: string | undefined
223
+ readonly tint?: string | undefined
224
+ }
225
+
226
+ export interface ThreadProps extends UseThreadOptions, RichComposerAttachmentProps {
227
+ readonly onReact?: ((commentId: string, key: ThreadReactionKey) => void) | undefined
228
+ readonly onVote?: ((commentId: string, next: ThreadVote) => void) | undefined
229
+ readonly onRetry?: ((commentId: string) => void) | undefined
230
+ readonly onSelectAuthor?: ((author: ThreadAuthor) => void) | undefined
231
+ readonly onSelectMention?: ((target: MentionTarget) => void) | undefined
232
+ readonly onOpenAttachment?: ((attachment: ThreadAttachment) => void) | undefined
233
+ /**
234
+ * Resolve one attachment's short-lived URL (ADR-151 D6). The wire carries no
235
+ * URL — the storage key never leaves the worker — so the renderer asks for
236
+ * the bytes lazily, after the box is already reserved from the declared
237
+ * aspect ratio.
238
+ */
239
+ readonly resolveAttachmentUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
240
+ /**
241
+ * Hide one link preview on one comment (ADR-150 D7). Absent and no card
242
+ * renders a dismiss control — the affordance follows the wiring rather than
243
+ * appearing and doing nothing.
244
+ *
245
+ * The host owns the optimistic removal and the invalidate-on-error, in the
246
+ * same pattern as the existing mutations. `canHide` on the row is a rendering
247
+ * convenience; the server is the gate.
248
+ */
249
+ readonly onHideLinkPreview?: ((commentId: string, urlHash: string) => void) | undefined
250
+ /**
251
+ * The byline overflow menu. An item whose callback is absent is NOT RENDERED
252
+ * — never rendered disabled, because a permanently greyed row teaches people
253
+ * the surface is broken rather than that they lack a permission.
254
+ *
255
+ * `onCopyText` is the odd one out by design: the body is already in hand, so
256
+ * "Copy text" appears whenever there is a body and a clipboard to write to,
257
+ * performs the write itself, and uses this callback only to report the
258
+ * outcome — `ok: false` covers a denied permission or an unfocused document,
259
+ * which a host will usually want to surface as a toast.
260
+ */
261
+ readonly onCopyLink?: ((commentId: string) => void) | undefined
262
+ readonly onCopyText?: ((commentId: string, ok: boolean) => void) | undefined
263
+ readonly onEdit?: ((commentId: string) => void) | undefined
264
+ readonly onDelete?: ((commentId: string) => void) | undefined
265
+ readonly onContinueThread?: ((commentId: string) => void) | undefined
266
+ /** In flight. Disables the composers and shows "Sending…". */
267
+ readonly sending?: boolean | undefined
268
+ /**
269
+ * Nesting depth past which a level stops ADDING indent. Nesting itself stays
270
+ * uncapped — this only stops a ~380px task drawer running out of column
271
+ * around depth 9. Past it the rail is drawn as a stacking hairline border
272
+ * and collapse moves to the footer control; nothing is hidden and nothing
273
+ * becomes unreachable. Default 6 (≈144px of indent at `--space-6`).
274
+ */
275
+ readonly indentClampDepth?: number | undefined
276
+ /** Rendered above the sort bar. A title, a resolve control, whatever the host needs. */
277
+ readonly header?: ReactNode | undefined
278
+ /** Faces on the thread, shown beside the count. `total` may exceed the list. */
279
+ readonly participants?: readonly ThreadParticipant[] | undefined
280
+ readonly participantTotal?: number | undefined
281
+ /** The current user's avatar, beside the root composer. */
282
+ readonly composerAvatar?: ReactNode | undefined
283
+ readonly composerPlaceholder?: string | undefined
284
+ readonly composerMaxLength?: number | undefined
285
+ /** Day separators between runs of root threads. */
286
+ readonly dateSeparators?: boolean | undefined
287
+ readonly emptyState?: ReactNode | undefined
288
+ readonly locale?: string | undefined
289
+ /** `panel` paints the opaque frame the cards sit on; `bare` inherits the host's. */
290
+ readonly variant?: 'panel' | 'bare' | undefined
291
+ /**
292
+ * Draw the pinned ROOT composer. Default true.
293
+ *
294
+ * `false` is for a host that pins a composer of its own BELOW the thread and
295
+ * needs exactly one on screen — the task detail's activity pane, whose
296
+ * composer stays put across Everything / Comments / Activity, so it cannot
297
+ * be the one this component owns. Replies are unaffected: they are still
298
+ * gated on `onSubmit`, so turning the root composer off does not turn
299
+ * replying off with it.
300
+ */
301
+ readonly rootComposer?: boolean | undefined
302
+ /**
303
+ * Which composer the root composer IS (ADR-148 D5). `'rich'` mounts the
304
+ * TipTap surface behind one lazy boundary — the chunk is requested when a
305
+ * composer mounts, never by rendering the list — and falls back to the plain
306
+ * textarea if the chunk cannot load (D6) or the draft holds syntax outside
307
+ * the subset (D3 rule 2). `'plain'` is the default until the owner flips it
308
+ * after the eyeball pass. Both satisfy the same `value`/`onChange` seam, so
309
+ * nothing else here can tell them apart.
310
+ */
311
+ /**
312
+ * Which composer to draw. Uncontrolled by default: this is the INITIAL
313
+ * value and the footer's switcher changes it from there, so the switcher
314
+ * works with no wiring. Pass `onComposerChange` to control it instead.
315
+ */
316
+ /**
317
+ * The `@` picker's options, for the root composer and every reply
318
+ * (ADR-149 D6). The host filters a list it already has; the picker is a
319
+ * convenience and never an authorization gate, because the server re-checks
320
+ * every mention it extracts from `body_md` at write time.
321
+ *
322
+ * Absent — the default — `@` is ordinary text and no popup opens. It reaches
323
+ * the rich composer only; the plain textarea has no suggestion layer, by
324
+ * design (ADR-149 D2).
325
+ */
326
+ readonly mentionItems?: ((query: string) => readonly MentionSuggestionItem[]) | undefined
327
+ readonly composer?: 'plain' | 'rich' | undefined
328
+ readonly onComposerChange?: ((composer: 'plain' | 'rich') => void) | undefined
329
+ readonly className?: string | undefined
330
+ }
331
+
332
+ export function Thread({
333
+ onReact,
334
+ onVote,
335
+ onRetry,
336
+ onSelectAuthor,
337
+ onSelectMention,
338
+ onOpenAttachment,
339
+ resolveAttachmentUrl,
340
+ onHideLinkPreview,
341
+ onCopyLink,
342
+ onCopyText,
343
+ onEdit,
344
+ onDelete,
345
+ onContinueThread,
346
+ sending = false,
347
+ indentClampDepth = DEFAULT_INDENT_CLAMP_DEPTH,
348
+ header,
349
+ participants,
350
+ participantTotal,
351
+ composerAvatar,
352
+ composerPlaceholder = 'Add a comment…',
353
+ composerMaxLength,
354
+ dateSeparators = true,
355
+ emptyState,
356
+ locale,
357
+ variant = 'panel',
358
+ rootComposer = true,
359
+ mentionItems,
360
+ uploadAttachment,
361
+ attachGif,
362
+ searchGifs,
363
+ attachmentAccept,
364
+ attachmentMaxPerComment,
365
+ attachmentMaxFileBytes,
366
+ attachmentMaxTotalBytes,
367
+ composer = 'plain',
368
+ onComposerChange,
369
+ className,
370
+ ...threadOptions
371
+ }: ThreadProps) {
372
+ const thread = useThread(threadOptions)
373
+
374
+ /**
375
+ * THE live region's text, and the only state that feeds it.
376
+ *
377
+ * `seq` exists because a live region speaks when its text CHANGES, not when
378
+ * it is written: collapsing two different comments that happen to have the
379
+ * same number of replies produces byte-identical text and the second one
380
+ * would be silent. The rendered string alternates a trailing NBSP on the
381
+ * sequence, which every screen reader trims out of the spoken output but the
382
+ * DOM counts as a change. Cheaper and more reliable than the two-region
383
+ * swap, and it keeps ONE region rather than two.
384
+ */
385
+ const [announcement, setAnnouncement] = useState<{
386
+ readonly text: string
387
+ readonly seq: number
388
+ }>({ text: '', seq: 0 })
389
+
390
+ const announce = useCallback((message: string) => {
391
+ setAnnouncement((prev) => ({ text: message, seq: prev.seq + 1 }))
392
+ }, [])
393
+
394
+ const announcementText =
395
+ announcement.text === ''
396
+ ? ''
397
+ : announcement.seq % 2 === 0
398
+ ? `${announcement.text}\u{00A0}`
399
+ : announcement.text
400
+
401
+ const comments = threadOptions.comments
402
+ const seenStates = useRef<ReadonlyMap<string, CommentDeliveryState> | null>(null)
403
+
404
+ /**
405
+ * Content that changes with NO user action behind it has to announce itself.
406
+ *
407
+ * Two things do that here and neither used to say anything, while the one
408
+ * thing that already spoke was the failure (`role="alert"` on a failed send,
409
+ * comment.tsx): an optimistic comment landing, and the poll bringing in
410
+ * replies the viewer did not write. Success and other people's words were
411
+ * silent; only failure had a voice.
412
+ *
413
+ * The two are told apart by DELIVERY STATE rather than by an author id,
414
+ * which this primitive deliberately does not have: an optimistic send enters
415
+ * the list as `pending`, so an id that appears already `sent` arrived from
416
+ * the server and belongs to somebody else. `failed` -> `sent` counts as a
417
+ * landing too, because a successful retry is the same event.
418
+ */
419
+ useEffect(() => {
420
+ const next = new Map<string, CommentDeliveryState>()
421
+ for (const comment of comments) next.set(comment.id, comment.state ?? 'sent')
422
+ const previous = seenStates.current
423
+ seenStates.current = next
424
+ // FIRST RUN SEEDS THE BASELINE AND SAYS NOTHING. Announcing here would
425
+ // make every single page load read the whole comment count aloud, which is
426
+ // the documented way this pattern gets turned off by the people it is for.
427
+ if (previous === null) return
428
+ let landed = 0
429
+ let arrived = 0
430
+ for (const [id, state] of next) {
431
+ const before = previous.get(id)
432
+ if (before === undefined) {
433
+ if (state === 'sent') arrived += 1
434
+ } else if (before !== 'sent' && state === 'sent') {
435
+ landed += 1
436
+ }
437
+ }
438
+ const parts: string[] = []
439
+ if (landed > 0) {
440
+ parts.push(landed === 1 ? 'Comment posted' : `${String(landed)} comments posted`)
441
+ }
442
+ if (arrived > 0) {
443
+ parts.push(arrived === 1 ? '1 new comment' : `${String(arrived)} new comments`)
444
+ }
445
+ if (parts.length > 0) announce(parts.join('. '))
446
+ }, [announce, comments])
447
+
448
+ // Uncontrolled unless the host asks to own it. `composer` seeds the state and
449
+ // keeps it in sync when a controlling host changes it; without a callback the
450
+ // switcher just works.
451
+ const [composerMode, setComposerMode] = useState<'plain' | 'rich'>(composer)
452
+ useEffect(() => {
453
+ setComposerMode(composer)
454
+ }, [composer])
455
+ const activeComposer = onComposerChange === undefined ? composerMode : composer
456
+ const changeComposer = useCallback(
457
+ (next: 'plain' | 'rich') => {
458
+ setComposerMode(next)
459
+ onComposerChange?.(next)
460
+ },
461
+ [onComposerChange],
462
+ )
463
+ const ctx: ThreadRenderContext = {
464
+ composer: activeComposer,
465
+ onComposerChange: changeComposer,
466
+ thread,
467
+ sending,
468
+ indentClampDepth,
469
+ announce,
470
+ ...(locale === undefined ? {} : { locale }),
471
+ ...(composerAvatar === undefined ? {} : { replyAvatar: composerAvatar }),
472
+ ...(onReact === undefined ? {} : { onReact }),
473
+ ...(onVote === undefined ? {} : { onVote }),
474
+ ...(onRetry === undefined ? {} : { onRetry }),
475
+ ...(onSelectAuthor === undefined ? {} : { onSelectAuthor }),
476
+ ...(onSelectMention === undefined ? {} : { onSelectMention }),
477
+ ...(onOpenAttachment === undefined ? {} : { onOpenAttachment }),
478
+ ...(resolveAttachmentUrl === undefined ? {} : { resolveAttachmentUrl }),
479
+ ...(onHideLinkPreview === undefined ? {} : { onHideLinkPreview }),
480
+ ...(onCopyLink === undefined ? {} : { onCopyLink }),
481
+ ...(onCopyText === undefined ? {} : { onCopyText }),
482
+ ...(onEdit === undefined ? {} : { onEdit }),
483
+ ...(onDelete === undefined ? {} : { onDelete }),
484
+ ...(onContinueThread === undefined ? {} : { onContinueThread }),
485
+ ...(composerMaxLength === undefined ? {} : { composerMaxLength }),
486
+ ...(mentionItems === undefined ? {} : { mentionItems }),
487
+ ...(uploadAttachment === undefined ? {} : { uploadAttachment }),
488
+ ...(attachGif === undefined ? {} : { attachGif }),
489
+ ...(searchGifs === undefined ? {} : { searchGifs }),
490
+ ...(attachmentAccept === undefined ? {} : { attachmentAccept }),
491
+ ...(attachmentMaxPerComment === undefined ? {} : { attachmentMaxPerComment }),
492
+ ...(attachmentMaxFileBytes === undefined ? {} : { attachmentMaxFileBytes }),
493
+ ...(attachmentMaxTotalBytes === undefined ? {} : { attachmentMaxTotalBytes }),
494
+ }
495
+
496
+ const panel = variant === 'panel'
497
+ // Annotated, not inferred — see the same note in comment.tsx: the plain
498
+ // composer satisfies the rich composer's (wider) props and ignores the seam.
499
+ const Composer: (props: RichThreadComposerProps) => ReactElement =
500
+ activeComposer === 'rich' ? RichThreadComposer : ThreadComposer
501
+
502
+ // Derived up front rather than by mutating a cursor inside the map: render
503
+ // stays pure, and the run boundaries are one readable expression instead of
504
+ // a side effect hidden in a JSX callback.
505
+ const separatorFor: readonly (string | null)[] = thread.roots.map((node, index) => {
506
+ if (!dateSeparators) return null
507
+ const previous = thread.roots[index - 1]
508
+ if (previous !== undefined && startOfDay(previous.createdAt) === startOfDay(node.createdAt)) {
509
+ return null
510
+ }
511
+ return dayLabel(node.createdAt, thread.now, locale)
512
+ })
513
+
514
+ /**
515
+ * Roots grouped into day RUNS, one `<ul>` per run.
516
+ *
517
+ * The list markup is not cosmetic: a thread IS a list and its nesting is the
518
+ * whole information structure, so without `<ul>`/`<li>` a screen reader gets
519
+ * no item count, no boundaries and no depth. A `<ul>` may contain only
520
+ * `<li>`, which leaves the day separator somewhere to go, and the answer is
521
+ * NOT inside the item. A sticky element is clipped to its own containing
522
+ * block, so a separator moved into a list item would unstick the moment that
523
+ * one comment scrolled past, and the rest of its run would sit under no
524
+ * heading at all. One list per run keeps the separator a sibling of the list
525
+ * — exactly where it is today — and the geometry byte-identical: the run
526
+ * `<ul>` is a zero-margin, zero-padding flex column carrying the SAME
527
+ * `--space-2` gap the outer column had, so every box keeps its old edges.
528
+ */
529
+ const runs: {
530
+ readonly key: string
531
+ readonly separator: string | null
532
+ readonly nodes: CommentNode[]
533
+ }[] = []
534
+ thread.roots.forEach((node: CommentNode, index: number) => {
535
+ const separator = separatorFor[index] ?? null
536
+ const current = runs[runs.length - 1]
537
+ if (separator !== null || current === undefined) {
538
+ runs.push({ key: node.id, separator, nodes: [node] })
539
+ } else {
540
+ current.nodes.push(node)
541
+ }
542
+ })
543
+
544
+ return (
545
+ <section
546
+ aria-label="Comments"
547
+ className={cn('ds-thread flex min-h-0 flex-col', panel && 'ds-card-surface', className)}
548
+ {...(panel ? { 'data-framed': 'true' } : {})}
549
+ style={panel ? PANEL_STYLE : undefined}
550
+ >
551
+ {/* THE polite live region, and it is rendered EMPTY here on mount and
552
+ kept in the DOM for the life of the thread. A live region injected
553
+ into the document at the moment it has something to say is the
554
+ documented way to get an announcement that never arrives — the AT has
555
+ to be observing the node before the text lands. */}
556
+ <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
557
+ {announcementText}
558
+ </div>
559
+
560
+ {header === undefined ? null : (
561
+ <div style={{ marginBlockEnd: 'var(--space-3)' }}>{header}</div>
562
+ )}
563
+
564
+ {/* Sticky sort bar. The rule under it is required structure, not
565
+ decoration — it is what separates the header from the list when the
566
+ list scrolls under it. */}
567
+ <div
568
+ className="flex items-center gap-[var(--space-3)] border-b"
569
+ style={{
570
+ borderColor: 'rgb(var(--border))',
571
+ paddingBlockEnd: 'var(--space-2)',
572
+ }}
573
+ >
574
+ <span
575
+ className="inline-flex items-center gap-[var(--space-1)] text-[13px] font-semibold"
576
+ style={{ color: 'rgb(var(--foreground))' }}
577
+ >
578
+ <MessageSquare size={12} strokeWidth={2} aria-hidden="true" />
579
+ Comments
580
+ {/* Reserved slot: 12 → 13 must not move the sort pills. */}
581
+ <span
582
+ className="text-right tabular-nums"
583
+ style={{ minInlineSize: '2.5ch', color: 'rgb(var(--text-tertiary))' }}
584
+ >
585
+ {thread.totalCount}
586
+ </span>
587
+ </span>
588
+
589
+ {participants === undefined || participants.length === 0 ? null : (
590
+ <AvatarGroup
591
+ max={4}
592
+ total={participantTotal ?? participants.length}
593
+ className="ds-avatar-on-frame"
594
+ >
595
+ {participants.map((participant) => {
596
+ const tint = asAvatarTint(participant.tint)
597
+ return (
598
+ <Avatar
599
+ key={participant.id}
600
+ name={participant.name}
601
+ size="sm"
602
+ ring
603
+ seed={participant.id}
604
+ src={participant.avatarUrl ?? null}
605
+ {...(tint === undefined ? {} : { tint })}
606
+ />
607
+ )
608
+ })}
609
+ </AvatarGroup>
610
+ )}
611
+
612
+ <div className="ms-auto">
613
+ <SortPills value={thread.sort} onChange={thread.setSort} />
614
+ </div>
615
+ </div>
616
+
617
+ <div className={cn(
618
+ 'ds-thread__scroll min-h-0 flex-1 overflow-y-auto',
619
+ // Scroll stops here. Without containment a wheel gesture that hits
620
+ // the end of this list chains to PageShell's content region and the
621
+ // page moves under the thread.
622
+ 'overscroll-contain',
623
+ )}>
624
+ {thread.roots.length === 0 ? (
625
+ <div
626
+ className="text-[13px]"
627
+ style={{ color: 'rgb(var(--text-tertiary))', paddingBlock: 'var(--space-4)' }}
628
+ >
629
+ {emptyState ?? 'No comments yet. Start the conversation.'}
630
+ </div>
631
+ ) : (
632
+ <div className="flex flex-col gap-[var(--space-2)]">
633
+ {runs.map((run) => (
634
+ <Fragment key={run.key}>
635
+ {run.separator === null ? null : <DateSeparator label={run.separator} />}
636
+ {/* `role="list"` is restated because `list-style: none` drops
637
+ the implicit list role in Safari/VoiceOver, which would
638
+ hand back exactly the structure this markup exists to
639
+ give. `m-0 p-0` removes the UA list indent, which is the
640
+ only geometry a bare `<ul>` would have changed. */}
641
+ <ul
642
+ role="list"
643
+ className="m-0 flex list-none flex-col gap-[var(--space-2)] p-0"
644
+ >
645
+ {run.nodes.map((node) => (
646
+ // The `<li>` is the list item; the card is its chrome. The
647
+ // root CommentItem stays a `<div>` (`listItem={false}`)
648
+ // because the card has to sit BETWEEN the item and the
649
+ // comment — the comment's own block-start padding is what
650
+ // supplies the card's top inset.
651
+ <li key={node.id}>
652
+ {/* One card, the whole thread. `<Card>` rather than a
653
+ forked `<div>` with a background token — the
654
+ primitive owns the opaque surface, the border and the
655
+ shadow (CLAUDE.md §2). */}
656
+ <Card
657
+ // `h-full` + `overflow-hidden` are Card's defaults for
658
+ // a grid cell. Here the card is a flow item whose
659
+ // height is its content, and clipping would eat the
660
+ // focus ring on the rail hairline sitting at its
661
+ // inline-start edge.
662
+ className="h-auto overflow-visible"
663
+ style={CARD_STYLE}
664
+ >
665
+ <CommentItem
666
+ node={node}
667
+ ctx={ctx}
668
+ renderDepth={0}
669
+ listItem={false}
670
+ />
671
+ </Card>
672
+ </li>
673
+ ))}
674
+ </ul>
675
+ </Fragment>
676
+ ))}
677
+ </div>
678
+ )}
679
+ </div>
680
+
681
+ {/* Pinned composer — a long thread stays replyable without scrolling
682
+ back to the top. */}
683
+ {thread.canReply && rootComposer ? (
684
+ <div
685
+ className="shrink-0 border-t"
686
+ style={{
687
+ borderColor: 'rgb(var(--border))',
688
+ paddingBlockStart: 'var(--space-3)',
689
+ marginBlockStart: 'var(--space-3)',
690
+ }}
691
+ >
692
+ <Composer
693
+ mode={activeComposer}
694
+ onModeChange={changeComposer}
695
+ label="Add a comment"
696
+ placeholder={composerPlaceholder}
697
+ value={thread.draft}
698
+ onChange={thread.setDraft}
699
+ onSubmit={thread.submit}
700
+ sending={sending}
701
+ {...(composerAvatar === undefined ? {} : { avatar: composerAvatar })}
702
+ {...(composerMaxLength === undefined ? {} : { maxLength: composerMaxLength })}
703
+ {...(mentionItems === undefined ? {} : { mentionItems })}
704
+ {...(uploadAttachment === undefined ? {} : { uploadAttachment })}
705
+ {...(attachGif === undefined ? {} : { attachGif })}
706
+ {...(searchGifs === undefined ? {} : { searchGifs })}
707
+ {...(attachmentAccept === undefined ? {} : { attachmentAccept })}
708
+ {...(attachmentMaxPerComment === undefined
709
+ ? {}
710
+ : { attachmentMaxPerComment })}
711
+ {...(attachmentMaxFileBytes === undefined ? {} : { attachmentMaxFileBytes })}
712
+ {...(attachmentMaxTotalBytes === undefined ? {} : { attachmentMaxTotalBytes })}
713
+ />
714
+ </div>
715
+ ) : null}
716
+ </section>
717
+ )
718
+ }