@lovett/ui 0.0.10 → 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 (158) 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 +6064 -80
  4. package/dist/index.js +21561 -9696
  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 +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -0,0 +1,915 @@
1
+ /**
2
+ * CommentItem — one comment and, recursively, everything under it.
3
+ *
4
+ * LAYOUT. Two columns and three rows, which is what lets the rail be a
5
+ * connector instead of a margin rule (see rail.tsx for the geometry):
6
+ *
7
+ * ┌───────────────┬──────────────────────────────┐
8
+ * │ avatar │ byline · body · action row │ row 1
9
+ * │ stem + circle │ │
10
+ * ├───────────────┴──────────────────────────────┤
11
+ * │ (reply composer, hung at --thread-content-x) │ row 2
12
+ * ├──────────────────────────────────────────────┤
13
+ * │ replies, each indenting itself by --thread- │ row 3
14
+ * │ indent and drawing its own connector segment │
15
+ * └──────────────────────────────────────────────┘
16
+ *
17
+ * The frame around that grid is a `<li>` (`listItem`, the default), because a
18
+ * thread is a LIST and its nesting is the whole information structure. It costs
19
+ * no geometry: the grid is a CHILD of the frame, not the frame itself, so the
20
+ * element swap is block-for-block — a `list-item` box with `list-style: none`
21
+ * lays out exactly as the `<div>` did, and the padding that positions every
22
+ * connector stays on the same box. `display: contents` was never a candidate;
23
+ * it strips the list semantics in some AT and Safari versions, which is the
24
+ * entire thing being bought here. A ROOT passes `listItem={false}`: its `<li>`
25
+ * lives outside its `<Card>` in thread.tsx, since the comment's own block-start
26
+ * padding is what supplies the card's top inset.
27
+ *
28
+ * The avatar column occupies row 1 only, so it stretches to exactly the height
29
+ * of the comment's own content. That single fact is what pins the collapse
30
+ * circle level with the action row without anything measuring anything — and
31
+ * it keeps the circle there when a reply composer opens underneath, because the
32
+ * composer is in row 2.
33
+ *
34
+ * HIERARCHY IS TYPE AND SPACE, NOT BOXES. `↩ Reply` and the `•••` overflow sit
35
+ * at the inline-end of the byline, and they are QUIET — muted, borderless, no
36
+ * fill until hover. That combination is the point: an earlier pass put a
37
+ * BORDERED Reply there and the button competed with the name for the same line,
38
+ * which is a boxes-over-type failure rather than a placement one. Stripping the
39
+ * box resolves it, and it serves the product goal too (owner, 2026-09-06):
40
+ * Reply should be slightly out of the way, so people reach for the main comment
41
+ * box instead of threading every remark. See actions.tsx for why this is a
42
+ * deliberate, documented exception to CLAUDE.md §2's ghost-button rule.
43
+ *
44
+ * The row below the body is therefore ENGAGEMENT only — emoji reactions or
45
+ * votes, plus the in-flight notice. One purpose per row.
46
+ *
47
+ * SPACING. `--thread-row-gap` (12) separates comments; `--space-1` (4)
48
+ * separates the groups inside one. Four times the intra-group step, so grouping
49
+ * is carried by space alone and there is not a divider anywhere in the tree.
50
+ * That the gap is a comment's OWN block-start padding rather than a container
51
+ * gap is load-bearing: sibling boxes have to be flush for their connector
52
+ * segments to concatenate into one line.
53
+ *
54
+ * Corrections to the original port, all still in force:
55
+ * 1. Classes + public tokens, never inline `tok()` styles reaching internals.
56
+ * 2. Lucide icons, never the text glyphs the reference used.
57
+ * 3. Engagement state is props/callbacks, never local `useState`.
58
+ * 4. Nothing that animates open/closed is conditionally rendered.
59
+ */
60
+
61
+ import { useState } from 'react'
62
+ import type { CSSProperties, ReactElement, ReactNode } from 'react'
63
+ import {
64
+ ChevronRight,
65
+ CornerDownRight,
66
+ FileText,
67
+ RotateCw,
68
+ TriangleAlert,
69
+ } from 'lucide-react'
70
+ import { Avatar, asAvatarTint } from '../avatar'
71
+ import { Button } from '../button'
72
+ import { cn } from '../lib/utils'
73
+ import { CommentActions } from './actions'
74
+ import { CommentBody, type MentionTarget } from './comment-body'
75
+ import { ThreadComposer } from './composer'
76
+ import {
77
+ RichThreadComposer,
78
+ type RichComposerAttachmentProps,
79
+ type RichThreadComposerProps,
80
+ } from './rich-composer'
81
+ import { CommentVote, ReactionBar } from './reactions'
82
+ import { RelativeTime } from './relative-time'
83
+ import { countDescendants } from './comment-tree'
84
+ import { AttachmentGif, AttachmentImage } from './attachments'
85
+ import { LinkPreview } from './link-preview'
86
+ import {
87
+ CollapsedReplies,
88
+ DEFAULT_INDENT_CLAMP_DEPTH,
89
+ LineIcon,
90
+ ThreadConnector,
91
+ ThreadRail,
92
+ ThreadTrunkSegment,
93
+ } from './rail'
94
+ import type { UseThreadResult } from './use-thread'
95
+ import type {
96
+ CommentNode,
97
+ MentionSuggestionItem,
98
+ ThreadAttachment,
99
+ ThreadAuthor,
100
+ ThreadLinkPreview,
101
+ ThreadReactionKey,
102
+ ThreadVote,
103
+ } from './types'
104
+
105
+ /**
106
+ * Everything the recursive renderer needs, assembled once at the root.
107
+ * One object rather than fourteen props threaded through every level — the
108
+ * reference passed four and had already started to sprawl.
109
+ */
110
+ export interface ThreadRenderContext extends RichComposerAttachmentProps {
111
+ readonly thread: UseThreadResult
112
+ readonly locale?: string | undefined
113
+ readonly sending?: boolean | undefined
114
+ readonly replyAvatar?: ReactNode | undefined
115
+ /**
116
+ * Levels deeper than this stop ADDING indent. Purely presentational, which is
117
+ * why it lives here and not in the hook: nesting itself is uncapped, and the
118
+ * stored depth is never touched.
119
+ */
120
+ readonly indentClampDepth?: number | undefined
121
+ /**
122
+ * Writes to the ONE polite live region `<Thread>` owns. Collapse and expand
123
+ * go through it because `aria-expanded` reports that a state changed and
124
+ * says nothing about what happened to the eight comments that just left the
125
+ * page. Absent for a consumer driving `CommentItem` itself; the calls are
126
+ * optional and the tree renders identically without it.
127
+ */
128
+ readonly announce?: ((message: string) => void) | undefined
129
+ /**
130
+ * The `@` picker's options (ADR-149 D6). Absent, the popup never opens and
131
+ * `@` is ordinary text — which is what every host that has not wired a
132
+ * directory yet should get.
133
+ *
134
+ * A CONVENIENCE, never a permission boundary: the server parses `body_md` at
135
+ * write time and re-checks every mention it extracts, whether this offered
136
+ * it or someone typed it by hand.
137
+ */
138
+ readonly mentionItems?: ((query: string) => readonly MentionSuggestionItem[]) | undefined
139
+ readonly onReact?: ((commentId: string, key: ThreadReactionKey) => void) | undefined
140
+ readonly onVote?: ((commentId: string, next: ThreadVote) => void) | undefined
141
+ readonly onRetry?: ((commentId: string) => void) | undefined
142
+ readonly onSelectAuthor?: ((author: ThreadAuthor) => void) | undefined
143
+ readonly onSelectMention?: ((target: MentionTarget) => void) | undefined
144
+ readonly onOpenAttachment?: ((attachment: ThreadAttachment) => void) | undefined
145
+ /**
146
+ * Resolve one attachment's short-lived URL (ADR-151 D6).
147
+ *
148
+ * The wire carries no URL — the storage key never leaves the worker — so the
149
+ * renderer is handed the SHAPE of the slot and asks for the bytes lazily.
150
+ * Omit it and every attachment renders as its placeholder, at the size it
151
+ * would have been: the layout never depends on whether this is supplied, or
152
+ * on whether it succeeded.
153
+ */
154
+ readonly resolveAttachmentUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
155
+ /**
156
+ * Hide one link preview on one comment (ADR-150 D7).
157
+ *
158
+ * Absent and no card renders a dismiss control at all, which is the right
159
+ * default for a host that has not wired the mutation: an affordance that
160
+ * silently does nothing is worse than no affordance. The card also needs
161
+ * `canHide` — the host's optimistic removal and error invalidation live in
162
+ * the host, exactly as they do for reactions.
163
+ */
164
+ readonly onHideLinkPreview?: ((commentId: string, urlHash: string) => void) | undefined
165
+ /**
166
+ * The byline overflow menu. Each item is rendered only when its callback
167
+ * exists — an absent capability is an absent item, never a disabled one,
168
+ * because a permanently greyed row teaches people the surface is broken.
169
+ * `onCopyText` is the exception in shape: the body is already in hand, so the
170
+ * clipboard write happens here and the callback only reports the outcome.
171
+ */
172
+ readonly onCopyLink?: ((commentId: string) => void) | undefined
173
+ readonly onCopyText?: ((commentId: string, ok: boolean) => void) | undefined
174
+ readonly onEdit?: ((commentId: string) => void) | undefined
175
+ readonly onDelete?: ((commentId: string) => void) | undefined
176
+ /**
177
+ * Called instead of expanding in place when a FINITE `maxDepth` is hit — a
178
+ * consumer that has a dedicated permalink view wants to navigate there.
179
+ * Unreachable by default, because the default depth is uncapped.
180
+ */
181
+ readonly onContinueThread?: ((commentId: string) => void) | undefined
182
+ readonly composerMaxLength?: number | undefined
183
+ /**
184
+ * Which composer replies use (ADR-148 D5). Mirrors `ThreadProps.composer`.
185
+ * Threaded through the context rather than read from a store so a consumer
186
+ * driving `CommentItem` directly gets the plain composer unless it asks.
187
+ */
188
+ readonly composer?: 'plain' | 'rich' | undefined
189
+ /** Lets a REPLY composer's switcher change the whole thread's mode. */
190
+ readonly onComposerChange?: ((composer: 'plain' | 'rich') => void) | undefined
191
+ }
192
+
193
+ /** The two-column frame. Declared once so every level is byte-identical. */
194
+ const GRID_STYLE: CSSProperties = {
195
+ display: 'grid',
196
+ gridTemplateColumns: 'var(--thread-avatar) minmax(0, 1fr)',
197
+ columnGap: 'var(--thread-gap)',
198
+ }
199
+
200
+ /**
201
+ * ADR-147 D5/D6 — the display fallback lives in the component, never in the
202
+ * data. `users.name` / `given_name` / `family_name` are nullable by standing
203
+ * policy precisely so nothing fabricates one at write time; that policy only
204
+ * holds if the read side degrades honestly instead of crashing.
205
+ */
206
+ function authorLabel(author: ThreadAuthor | null | undefined): string {
207
+ if (author == null) return 'Deleted user'
208
+ const name = author.name?.trim()
209
+ if (name !== undefined && name !== '') return name
210
+ const email = author.email?.trim()
211
+ if (email !== undefined && email !== '') return email
212
+ return 'Unknown member'
213
+ }
214
+
215
+ /** "1 reply" / "8 replies" — the live region's wording, in one place. */
216
+ function replyCountLabel(count: number): string {
217
+ return `${String(count)} ${count === 1 ? 'reply' : 'replies'}`
218
+ }
219
+
220
+ function formatBytes(bytes: number): string {
221
+ if (!Number.isFinite(bytes) || bytes < 0) return ''
222
+ if (bytes < 1024) return `${String(bytes)} B`
223
+ if (bytes < 1024 * 1024) return `${String(Math.round(bytes / 1024))} KB`
224
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
225
+ }
226
+
227
+ function Attachments({
228
+ attachments,
229
+ onOpen,
230
+ resolveUrl,
231
+ }: {
232
+ attachments: readonly ThreadAttachment[]
233
+ onOpen?: ((attachment: ThreadAttachment) => void) | undefined
234
+ resolveUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
235
+ }) {
236
+ // Three buckets, not two, since ADR-151 D4: a Giphy mp4 renders through
237
+ // `AttachmentGif`; an uploaded image/gif remains in `images`, where
238
+ // `AttachmentImage` gives it the equivalent still-frame treatment.
239
+ const images = attachments.filter((a) => a.kind === 'image')
240
+ const gifs = attachments.filter((a) => a.kind === 'gif')
241
+ const files = attachments.filter((a) => a.kind === 'file')
242
+ return (
243
+ <div
244
+ className="flex flex-col gap-[var(--space-2)]"
245
+ style={{ marginBlockStart: 'var(--space-2)' }}
246
+ >
247
+ {images.length === 0 && gifs.length === 0 ? null : (
248
+ <div className="flex flex-wrap gap-[var(--space-2)]">
249
+ {images.map((attachment) => (
250
+ <AttachmentImage
251
+ key={attachment.id}
252
+ attachment={attachment}
253
+ onOpen={onOpen}
254
+ resolveUrl={resolveUrl}
255
+ />
256
+ ))}
257
+ {gifs.map((attachment) => (
258
+ <AttachmentGif key={attachment.id} attachment={attachment} resolveUrl={resolveUrl} />
259
+ ))}
260
+ </div>
261
+ )}
262
+ {files.map((attachment) => (
263
+ <button
264
+ key={attachment.id}
265
+ type="button"
266
+ disabled={onOpen === undefined}
267
+ onClick={() => {
268
+ onOpen?.(attachment)
269
+ }}
270
+ className={cn(
271
+ // Optical padding: 8 inline against 4 block, because the line box
272
+ // already carries space the glyphs do not fill.
273
+ 'inline-flex max-w-full items-center gap-[var(--space-2)] self-start border px-[var(--space-2)] py-[var(--space-1)] text-[12px]',
274
+ 'border-[rgb(var(--border))] bg-[rgb(var(--surface-inset))] text-[rgb(var(--text-secondary))]',
275
+ 'transition-[background-color,border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none',
276
+ 'enabled:hover:border-[rgb(var(--border-strong))] enabled:hover:bg-[rgb(var(--surface-hover))]',
277
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
278
+ 'disabled:cursor-default',
279
+ )}
280
+ style={{ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))' }}
281
+ >
282
+ <LineIcon>
283
+ <FileText size={12} strokeWidth={1.5} aria-hidden="true" />
284
+ </LineIcon>
285
+ <span className="truncate">{attachment.name}</span>
286
+ {attachment.sizeBytes === undefined ? null : (
287
+ <span className="tabular-nums" style={{ color: 'rgb(var(--text-tertiary))' }}>
288
+ {formatBytes(attachment.sizeBytes)}
289
+ </span>
290
+ )}
291
+ </button>
292
+ ))}
293
+ </div>
294
+ )
295
+ }
296
+
297
+ /**
298
+ * Every visible card on one comment (ADR-150 D8).
299
+ *
300
+ * There is ONE of these, reached by the one recursive `CommentItem`, so a root
301
+ * and a reply are rendered by the same code by construction rather than by
302
+ * two call sites agreeing. Ordering comes from the worker (`sort_order`,
303
+ * document order) and is not re-sorted here.
304
+ */
305
+ function LinkPreviews({
306
+ commentId,
307
+ previews,
308
+ onHide,
309
+ }: {
310
+ commentId: string
311
+ previews: readonly ThreadLinkPreview[]
312
+ onHide?: ((commentId: string, urlHash: string) => void) | undefined
313
+ }) {
314
+ return (
315
+ <div
316
+ className="flex flex-col gap-[var(--space-2)]"
317
+ style={{ marginBlockStart: 'var(--space-2)' }}
318
+ >
319
+ {previews.map((preview) => (
320
+ <LinkPreview
321
+ key={preview.urlHash}
322
+ preview={preview}
323
+ {...(onHide === undefined
324
+ ? {}
325
+ : {
326
+ onHide: () => {
327
+ onHide(commentId, preview.urlHash)
328
+ },
329
+ })}
330
+ />
331
+ ))}
332
+ </div>
333
+ )
334
+ }
335
+
336
+ export interface CommentItemProps {
337
+ readonly node: CommentNode
338
+ readonly ctx: ThreadRenderContext
339
+ /**
340
+ * RENDER depth. Only two things read it: the (uncapped by default)
341
+ * `maxDepth`, and the indent clamp. `node.depth` is the true stored depth.
342
+ */
343
+ readonly renderDepth: number
344
+ /**
345
+ * Last of its sibling run. The last reply TERMINATES the connector at its own
346
+ * avatar; every other one carries the line on to the next. Roots are always
347
+ * last — a root has no rail above it to carry.
348
+ */
349
+ readonly isLast?: boolean | undefined
350
+ /**
351
+ * Render the frame as an `<li>`. Defaults to true, because the recursive
352
+ * case is the common one and a comment is a list item: every reply is a
353
+ * direct child of its parent's replies `<ul>`. Pass false wherever the
354
+ * `<li>` is supplied by the caller instead — `<Thread>` does exactly that
355
+ * for a ROOT, whose `<li>` has to sit OUTSIDE its `<Card>` (the comment's own
356
+ * block-start padding is what supplies the card's top inset). A custom skin
357
+ * built on `useThread` owns its own root list and should do the same.
358
+ */
359
+ readonly listItem?: boolean | undefined
360
+ }
361
+
362
+ export function CommentItem({
363
+ node,
364
+ ctx,
365
+ renderDepth,
366
+ isLast = true,
367
+ listItem = true,
368
+ }: CommentItemProps) {
369
+ const { thread } = ctx
370
+ // "Continue this thread" expands the subtree in place when a consumer set a
371
+ // finite maxDepth and has nowhere to navigate to. Local, and correctly lost
372
+ // on unmount: it is a view preference, not content.
373
+ const [continued, setContinued] = useState(false)
374
+
375
+ const collapsed = thread.isCollapsed(node.id)
376
+ const deleted = node.deletedAt != null
377
+ const state = node.state ?? 'sent'
378
+ const label = deleted ? '[deleted]' : authorLabel(node.author)
379
+ const descendants = countDescendants(node)
380
+ const atCap = renderDepth >= thread.maxDepth && !continued
381
+ const { visible, hidden } = thread.visibleReplies(node)
382
+ const hasChildren = node.replies.length > 0
383
+ const replyOpen = thread.isReplyingTo(node.id)
384
+ // ADR-148 D5: reply composers are rich only after their first open. Every
385
+ // comment keeps its reply composer MOUNTED (correction 4 — nothing that
386
+ // animates is conditionally rendered), so without this latch a 24-comment
387
+ // thread would mount 24 ProseMirror editors before anyone replied. Once
388
+ // opened it stays rich, so the closing animation never swaps content
389
+ // mid-flight (the collapse rule: content keeps its size while closing).
390
+ const [replyEverOpened, setReplyEverOpened] = useState(false)
391
+ if (replyOpen && !replyEverOpened) setReplyEverOpened(true)
392
+ // Annotated rather than inferred so the ADR-149/151 seams can be passed to the
393
+ // union: `RichThreadComposerProps` extends `ThreadComposerProps`, so the
394
+ // plain composer satisfies this signature and simply never receives the
395
+ // rich-only capabilities.
396
+ const ReplyComposer: (props: RichThreadComposerProps) => ReactElement =
397
+ ctx.composer === 'rich' && replyEverOpened ? RichThreadComposer : ThreadComposer
398
+ // Narrow once, so the byline never needs a cast to satisfy the callback.
399
+ const author = node.author ?? null
400
+ // `avatar_color` is a nullable TEXT column, so it is parsed at this boundary
401
+ // rather than trusted as a domain value (D6).
402
+ const tint = asAvatarTint(author?.tint)
403
+ const canEngage = !deleted && state === 'sent'
404
+ const indentClampDepth = ctx.indentClampDepth ?? DEFAULT_INDENT_CLAMP_DEPTH
405
+
406
+ // A reply draws the segment of the connector that spans its own box.
407
+ const connected = renderDepth > 0
408
+ // Past the clamp the indent stops growing, so there is no gutter to curve in
409
+ // and the line drops straight onto the child's avatar instead.
410
+ const clamped = renderDepth > indentClampDepth
411
+ // The rail only exists where it leads somewhere. A leaf comment costs no
412
+ // chrome at all, which is most of why depth got cheap.
413
+ const railed = hasChildren && !atCap
414
+ // Deliberately WITHOUT the count. The summary chip below a collapsed subtree
415
+ // names the count ("Show 8 replies to Priya Nair"); the circle names the
416
+ // toggle. Two controls that do the same thing may share a target, but they
417
+ // must not share an accessible name, or a screen-reader user hears the same
418
+ // control twice and cannot tell which one they are on.
419
+ const collapseLabel = `${collapsed ? 'Show' : 'Collapse'} replies to ${label}`
420
+ // ENGAGEMENT only — Reply lives in the byline now. One purpose per row.
421
+ const showActions = state === 'pending' || (canEngage && thread.engagement !== 'none')
422
+
423
+ // The frame element. A `list-item` box with `list-style: none` (set by the
424
+ // `<ul>` and inherited) is laid out exactly as a block, so this swap moves
425
+ // nothing: same padding, same containing block for the connectors.
426
+ const Frame = listItem ? 'li' : 'div'
427
+
428
+ return (
429
+ <Frame
430
+ className="relative"
431
+ data-comment-id={node.id}
432
+ style={{
433
+ // The comment's OWN top padding, never a container gap — sibling boxes
434
+ // must be flush for the connector segments to join.
435
+ paddingBlockStart: 'var(--thread-row-gap)',
436
+ ...(connected && !clamped ? { paddingInlineStart: 'var(--thread-indent)' } : {}),
437
+ }}
438
+ >
439
+ {connected ? <ThreadConnector clamped={clamped} isLast={isLast} /> : null}
440
+
441
+ <div style={GRID_STYLE}>
442
+ {/* AVATAR COLUMN — row 1 only, so it stretches to the height of this
443
+ comment's own content and the circle lands on the action row. */}
444
+ <div className="flex flex-col items-center" style={{ gridColumn: 1, gridRow: 1 }}>
445
+ <Avatar
446
+ // `decorative` because the name is rendered right beside it —
447
+ // otherwise a screen reader announces the same person twice.
448
+ decorative
449
+ name={author?.name ?? author?.email ?? null}
450
+ size="md"
451
+ src={author?.avatarUrl ?? null}
452
+ {...(deleted || author === null ? { initial: '?' } : {})}
453
+ {...(tint === undefined ? {} : { tint })}
454
+ {...(author?.id == null ? {} : { seed: author.id })}
455
+ />
456
+ {railed ? (
457
+ <ThreadRail
458
+ collapsed={collapsed}
459
+ label={collapseLabel}
460
+ onToggle={() => {
461
+ thread.toggleCollapse(node.id)
462
+ // `collapsed` is the state BEFORE the toggle, so the message
463
+ // names the state being entered.
464
+ ctx.announce?.(
465
+ `${collapsed ? 'Expanded' : 'Collapsed'} ${replyCountLabel(descendants)}`,
466
+ )
467
+ }}
468
+ />
469
+ ) : null}
470
+ </div>
471
+
472
+ {/* CONTENT COLUMN — row 1. Identity, then the body, then one action
473
+ row. Nothing here is a box; the hierarchy is weight and space. */}
474
+ <div className="min-w-0" style={{ gridColumn: 2, gridRow: 1 }}>
475
+ <div
476
+ data-thread-byline
477
+ // Deliberately NOT `flex-wrap`. A wrapping byline drops the action
478
+ // cluster onto a second line the moment a name gets long, which is
479
+ // the reflow this row must never do; the identity group absorbs the
480
+ // pressure by truncating instead.
481
+ className="flex items-center gap-[var(--space-2)]"
482
+ // Optically centres the name against the face beside it. The floor
483
+ // is the avatar PLUS the trailing gap, because `border-box` counts
484
+ // padding into `min-block-size`: without the addition the content
485
+ // box would be 28 and the text would centre 2px above the avatar.
486
+ style={{
487
+ minBlockSize: 'calc(var(--thread-avatar) + var(--space-1))',
488
+ paddingBlockEnd: 'var(--space-1)',
489
+ }}
490
+ >
491
+ <div className="flex min-w-0 flex-1 items-center gap-[var(--space-2)]">
492
+ {ctx.onSelectAuthor !== undefined && !deleted && author !== null ? (
493
+ <button
494
+ type="button"
495
+ onClick={() => {
496
+ ctx.onSelectAuthor?.(author)
497
+ }}
498
+ className={cn(
499
+ 'min-w-0 truncate text-[14px] font-semibold text-[rgb(var(--foreground))]',
500
+ 'underline-offset-2 hover:underline',
501
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
502
+ )}
503
+ style={{ borderRadius: 'var(--radius-xs)' }}
504
+ >
505
+ {label}
506
+ </button>
507
+ ) : (
508
+ <span
509
+ className="min-w-0 truncate text-[14px] font-semibold"
510
+ style={{
511
+ color: deleted ? 'rgb(var(--text-tertiary))' : 'rgb(var(--foreground))',
512
+ }}
513
+ >
514
+ {label}
515
+ </span>
516
+ )}
517
+ {author?.role == null || deleted ? null : (
518
+ <span
519
+ className="shrink-0 text-[10.5px] font-semibold tracking-[0.04em] uppercase"
520
+ style={{ color: 'rgb(var(--text-tertiary))' }}
521
+ >
522
+ {author.role}
523
+ </span>
524
+ )}
525
+ <span
526
+ className="flex shrink-0 items-center gap-[var(--space-1)] text-[12px]"
527
+ style={{ color: 'rgb(var(--text-tertiary))' }}
528
+ >
529
+ <RelativeTime
530
+ value={node.createdAt}
531
+ now={thread.now}
532
+ {...(ctx.locale === undefined ? {} : { locale: ctx.locale })}
533
+ />
534
+ {node.editedAt == null ? null : <span>· edited</span>}
535
+ </span>
536
+ </div>
537
+
538
+ <CommentActions
539
+ label={label}
540
+ replyOpen={replyOpen}
541
+ {...(canEngage && thread.canReply
542
+ ? {
543
+ onReplyToggle: () => {
544
+ if (replyOpen) thread.cancelReply()
545
+ else thread.startReply(node.id)
546
+ },
547
+ }
548
+ : {})}
549
+ {...(deleted || node.bodyMd === '' ? {} : { copyText: node.bodyMd })}
550
+ {...(ctx.onCopyText === undefined
551
+ ? {}
552
+ : {
553
+ onCopyText: (ok: boolean) => {
554
+ ctx.onCopyText?.(node.id, ok)
555
+ },
556
+ })}
557
+ {...(ctx.onCopyLink === undefined
558
+ ? {}
559
+ : {
560
+ onCopyLink: () => {
561
+ ctx.onCopyLink?.(node.id)
562
+ },
563
+ })}
564
+ {...(ctx.onEdit === undefined || deleted
565
+ ? {}
566
+ : {
567
+ onEdit: () => {
568
+ ctx.onEdit?.(node.id)
569
+ },
570
+ })}
571
+ {...(ctx.onDelete === undefined || deleted
572
+ ? {}
573
+ : {
574
+ onDelete: () => {
575
+ ctx.onDelete?.(node.id)
576
+ },
577
+ })}
578
+ />
579
+ </div>
580
+
581
+ {node.orphanedParentId === null ? null : (
582
+ // ADR-147 D4: this reply named a parent that is NOT in this list.
583
+ // It is shown at top level because dropping it would be worse,
584
+ // but it is labelled, because silently promoting it would claim
585
+ // it was a new remark rather than an answer to something.
586
+ <p
587
+ className="flex items-start gap-[var(--space-1)] text-[11.5px] italic"
588
+ style={{
589
+ color: 'rgb(var(--text-tertiary))',
590
+ marginBlockEnd: 'var(--space-1)',
591
+ }}
592
+ >
593
+ <LineIcon>
594
+ <CornerDownRight size={10} strokeWidth={1.5} aria-hidden="true" />
595
+ </LineIcon>
596
+ In reply to a comment that isn&rsquo;t shown here
597
+ </p>
598
+ )}
599
+
600
+ {deleted ? (
601
+ <p className="text-[13px] italic" style={{ color: 'rgb(var(--text-tertiary))' }}>
602
+ [deleted]
603
+ </p>
604
+ ) : (
605
+ <CommentBody
606
+ bodyMd={node.bodyMd}
607
+ {...(ctx.onSelectMention === undefined
608
+ ? {}
609
+ : { onSelectMention: ctx.onSelectMention })}
610
+ />
611
+ )}
612
+
613
+ {node.attachments === undefined || node.attachments.length === 0 || deleted ? null : (
614
+ <Attachments
615
+ attachments={node.attachments}
616
+ {...(ctx.onOpenAttachment === undefined ? {} : { onOpen: ctx.onOpenAttachment })}
617
+ {...(ctx.resolveAttachmentUrl === undefined
618
+ ? {}
619
+ : { resolveUrl: ctx.resolveAttachmentUrl })}
620
+ />
621
+ )}
622
+
623
+ {/* Link cards sit BELOW the person's own attachments: what they
624
+ deliberately attached outranks what a scanner found in their
625
+ prose. A tombstone shows none — the worker already returns none
626
+ for one (ADR-150 D8), and this is the second lock on it, because
627
+ "[deleted]" followed by three intact cards would leak the body
628
+ the tombstone exists to remove. */}
629
+ {node.linkPreviews === undefined || node.linkPreviews.length === 0 || deleted ? null : (
630
+ <LinkPreviews
631
+ commentId={node.id}
632
+ previews={node.linkPreviews}
633
+ {...(ctx.onHideLinkPreview === undefined
634
+ ? {}
635
+ : { onHide: ctx.onHideLinkPreview })}
636
+ />
637
+ )}
638
+
639
+ {/* THE engagement row. Emoji reactions (or votes) and the in-flight
640
+ notice share one line — the "+" used to sit on a line of its own
641
+ with nothing beside it, which read as an orphan rather than a
642
+ control. `--space-1` from the body above it, a quarter of the gap
643
+ to the next comment, so the grouping is unambiguous without a
644
+ rule. */}
645
+ {showActions ? (
646
+ <div
647
+ className="flex flex-wrap items-center gap-[var(--space-2)]"
648
+ style={{
649
+ marginBlockStart: 'var(--space-1)',
650
+ minBlockSize: 'var(--thread-action-h, var(--space-8))',
651
+ }}
652
+ >
653
+ {canEngage && thread.engagement === 'reactions' ? (
654
+ <ReactionBar
655
+ reactions={node.reactions ?? []}
656
+ {...(ctx.onReact === undefined
657
+ ? {}
658
+ : {
659
+ onReact: (key: ThreadReactionKey) => {
660
+ ctx.onReact?.(node.id, key)
661
+ },
662
+ })}
663
+ />
664
+ ) : null}
665
+
666
+ {canEngage && thread.engagement === 'votes' ? (
667
+ <CommentVote
668
+ score={node.score ?? 0}
669
+ viewerVote={node.viewerVote ?? 0}
670
+ {...(ctx.onVote === undefined
671
+ ? {}
672
+ : {
673
+ onVote: (next: ThreadVote) => {
674
+ ctx.onVote?.(node.id, next)
675
+ },
676
+ })}
677
+ />
678
+ ) : null}
679
+
680
+ {state === 'pending' ? (
681
+ <span
682
+ className="text-[11.5px] font-semibold"
683
+ style={{ color: 'rgb(var(--text-tertiary))' }}
684
+ >
685
+ Sending…
686
+ </span>
687
+ ) : null}
688
+ </div>
689
+ ) : null}
690
+
691
+ {state === 'failed' ? (
692
+ // role="alert": a send that failed after the person already
693
+ // looked away has to announce itself rather than wait to be
694
+ // noticed. ADR-147 D17.
695
+ <div
696
+ role="alert"
697
+ // TWO alignment axes, and they want opposite things — which is why
698
+ // this is two rows rather than one.
699
+ //
700
+ // OUTER is `items-center`: the message and the retry are siblings
701
+ // of unequal height (the button is taller than a line of 12px
702
+ // text), so aligning them to the start left the message riding
703
+ // ~2px above the button's centre line. Centring is what makes the
704
+ // two read as one row.
705
+ //
706
+ // INNER stays `items-start` so the icon keeps its `1lh`
707
+ // first-line alignment when the message wraps. Centring there too
708
+ // would drop the glyph to the middle of a three-line block, which
709
+ // is the defect the LineIcon wrapper exists to prevent.
710
+ className="flex flex-wrap items-center gap-[var(--space-2)] border px-[var(--space-2)] py-[var(--space-1)] text-[12px]"
711
+ style={{
712
+ marginBlockStart: 'var(--space-2)',
713
+ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
714
+ borderColor: 'rgb(var(--destructive))',
715
+ background: 'rgb(var(--destructive-bg))',
716
+ color: 'rgb(var(--destructive-ink))',
717
+ }}
718
+ >
719
+ <span className="flex min-w-0 flex-1 items-start gap-[var(--space-2)]">
720
+ <LineIcon>
721
+ <TriangleAlert size={12} strokeWidth={1.75} aria-hidden="true" />
722
+ </LineIcon>
723
+ <span className="min-w-0 flex-1">
724
+ {node.errorText ?? 'This comment did not send.'}
725
+ </span>
726
+ </span>
727
+ {ctx.onRetry === undefined ? null : (
728
+ <Button
729
+ variant="secondary"
730
+ size="sm"
731
+ leadingIcon={<RotateCw size={10} strokeWidth={1.75} aria-hidden="true" />}
732
+ onClick={() => {
733
+ ctx.onRetry?.(node.id)
734
+ }}
735
+ >
736
+ Try again
737
+ </Button>
738
+ )}
739
+ </div>
740
+ ) : null}
741
+
742
+ {hasChildren && atCap ? (
743
+ <Button
744
+ variant="secondary"
745
+ size="sm"
746
+ className="mt-[var(--space-2)]"
747
+ trailingIcon={<ChevronRight size={12} strokeWidth={1.75} aria-hidden="true" />}
748
+ onClick={() => {
749
+ if (ctx.onContinueThread !== undefined) ctx.onContinueThread(node.id)
750
+ else setContinued(true)
751
+ }}
752
+ >
753
+ Continue this thread
754
+ </Button>
755
+ ) : null}
756
+ </div>
757
+
758
+ {/* ROW 2 — the reply composer, hung on the same alignment edge as the
759
+ body. It sits BELOW the avatar column rather than inside it so that
760
+ opening it cannot drag the collapse circle off the action row; the
761
+ trunk segment keeps the line unbroken across it for as long as it is
762
+ open, and is zero-height while it is closed. */}
763
+ <div
764
+ className="relative"
765
+ style={{
766
+ gridColumn: '1 / -1',
767
+ gridRow: 2,
768
+ paddingInlineStart: 'var(--thread-content-x)',
769
+ }}
770
+ >
771
+ {railed && !collapsed ? <ThreadTrunkSegment /> : null}
772
+ {/* Mounted, driven by data-open — correction 4. Unmounting this would
773
+ make the open/close transition unobservable and would also throw
774
+ away whatever was typed on a stray re-render. `grid-rows` from
775
+ 0fr to 1fr animates the CONTAINER: the content keeps its natural
776
+ size throughout and is clipped, so nothing reflows mid-collapse. */}
777
+ <div
778
+ data-open={replyOpen}
779
+ // Every control inside is `disabled` while closed, so nothing is
780
+ // focusable and hiding it from the a11y tree is honest rather
781
+ // than a focus trap. It stays MOUNTED — correction 4.
782
+ aria-hidden={!replyOpen}
783
+ className={cn(
784
+ 'grid grid-rows-[0fr] opacity-0 data-[open=true]:grid-rows-[1fr] data-[open=true]:opacity-100',
785
+ 'transition-[grid-template-rows,opacity] duration-[var(--dur-base)] ease-[var(--ease-out)]',
786
+ 'motion-reduce:transition-none',
787
+ )}
788
+ >
789
+ <div className="overflow-hidden">
790
+ <div style={{ paddingBlockStart: 'var(--space-2)' }}>
791
+ <ReplyComposer
792
+ {...(ctx.composer === undefined ? {} : { mode: ctx.composer })}
793
+ {...(ctx.onComposerChange === undefined
794
+ ? {}
795
+ : { onModeChange: ctx.onComposerChange })}
796
+ label={`Reply to ${label}`}
797
+ placeholder={`Reply to ${label}…`}
798
+ submitLabel="Reply"
799
+ replyingTo={label}
800
+ value={thread.replyDraft}
801
+ onChange={thread.setReplyDraft}
802
+ onSubmit={thread.submitReply}
803
+ onCancel={thread.cancelReply}
804
+ rows={2}
805
+ toolbar={false}
806
+ disabled={!replyOpen}
807
+ {...(ctx.sending === undefined ? {} : { sending: ctx.sending })}
808
+ {...(ctx.composerMaxLength === undefined
809
+ ? {}
810
+ : { maxLength: ctx.composerMaxLength })}
811
+ {...(ctx.replyAvatar === undefined ? {} : { avatar: ctx.replyAvatar })}
812
+ {...(ctx.mentionItems === undefined ? {} : { mentionItems: ctx.mentionItems })}
813
+ {...(ctx.uploadAttachment === undefined
814
+ ? {}
815
+ : { uploadAttachment: ctx.uploadAttachment })}
816
+ {...(ctx.attachGif === undefined ? {} : { attachGif: ctx.attachGif })}
817
+ {...(ctx.searchGifs === undefined ? {} : { searchGifs: ctx.searchGifs })}
818
+ {...(ctx.attachmentAccept === undefined
819
+ ? {}
820
+ : { attachmentAccept: ctx.attachmentAccept })}
821
+ {...(ctx.attachmentMaxPerComment === undefined
822
+ ? {}
823
+ : { attachmentMaxPerComment: ctx.attachmentMaxPerComment })}
824
+ {...(ctx.attachmentMaxFileBytes === undefined
825
+ ? {}
826
+ : { attachmentMaxFileBytes: ctx.attachmentMaxFileBytes })}
827
+ {...(ctx.attachmentMaxTotalBytes === undefined
828
+ ? {}
829
+ : { attachmentMaxTotalBytes: ctx.attachmentMaxTotalBytes })}
830
+ />
831
+ </div>
832
+ </div>
833
+ </div>
834
+ </div>
835
+
836
+ {/* ROW 3 — the replies. Collapsed, the subtree becomes a summary and the
837
+ rail simply terminates at the circle, exactly as the reference's
838
+ does. No container gap: every reply's own block-start padding is the
839
+ separation, so the connector segments stay flush. */}
840
+ {!hasChildren || atCap ? null : (
841
+ <div style={{ gridColumn: '1 / -1', gridRow: 3 }}>
842
+ {collapsed ? (
843
+ <div
844
+ style={{
845
+ paddingBlockStart: 'var(--thread-row-gap)',
846
+ paddingInlineStart: 'var(--thread-indent)',
847
+ }}
848
+ >
849
+ <CollapsedReplies
850
+ node={node}
851
+ count={descendants}
852
+ now={thread.now}
853
+ parentLabel={label}
854
+ {...(ctx.locale === undefined ? {} : { locale: ctx.locale })}
855
+ onExpand={() => {
856
+ thread.setCollapsed(node.id, false)
857
+ ctx.announce?.(`Expanded ${replyCountLabel(descendants)}`)
858
+ }}
859
+ />
860
+ </div>
861
+ ) : (
862
+ <>
863
+ {/* One `<ul>` per NESTING LEVEL — the depth is the structure,
864
+ so a reader gets it from the markup rather than from an
865
+ indent they cannot see. Zero margin and zero padding, so
866
+ the list contributes no box of its own: the replies keep
867
+ the flush top edges their connector segments need.
868
+ `role="list"` is restated because `list-style: none` drops
869
+ the implicit role in Safari/VoiceOver.
870
+
871
+ "Show N more" stays OUTSIDE the list. It is a control, not
872
+ a reply, and inside it would inflate the announced item
873
+ count past the number of comments actually there. */}
874
+ <ul role="list" className="m-0 list-none p-0">
875
+ {visible.map((reply, index) => (
876
+ <CommentItem
877
+ key={reply.id}
878
+ node={reply}
879
+ ctx={ctx}
880
+ // The line stops at the last reply's avatar — unless a
881
+ // "Show N more" follows, in which case it carries on to it.
882
+ isLast={index === visible.length - 1 && hidden === 0}
883
+ // `continued` restarts the render budget for this subtree;
884
+ // the STORED depth is untouched (D3 — capping display loses
885
+ // nothing, capping storage loses data).
886
+ renderDepth={continued ? 0 : renderDepth + 1}
887
+ />
888
+ ))}
889
+ </ul>
890
+ {hidden > 0 ? (
891
+ <div
892
+ style={{
893
+ paddingBlockStart: 'var(--thread-row-gap)',
894
+ paddingInlineStart: 'var(--thread-indent)',
895
+ }}
896
+ >
897
+ <Button
898
+ variant="secondary"
899
+ size="sm"
900
+ onClick={() => {
901
+ thread.expandReplies(node.id)
902
+ }}
903
+ >
904
+ Show {hidden} more {hidden === 1 ? 'reply' : 'replies'}
905
+ </Button>
906
+ </div>
907
+ ) : null}
908
+ </>
909
+ )}
910
+ </div>
911
+ )}
912
+ </div>
913
+ </Frame>
914
+ )
915
+ }