@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,967 @@
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
+ /**
216
+ * THE tombstone strings — two acts, and a deliberate word for each.
217
+ *
218
+ * A comment becomes a tombstone two ways now (FU-0031, FU-0032): its author
219
+ * deleted it, or somebody else removed it. `[deleted]` was written when only
220
+ * the first existed, and it is read by the person whose comment is gone —
221
+ * telling them they did it themselves when they did not is the whole defect.
222
+ *
223
+ * REMOVED, not deleted. The two verbs carry the entire distinction, so each
224
+ * one is reserved for exactly one act: you delete your own, somebody removes
225
+ * yours. Any pair that shared a verb would need a qualifier to be read at all.
226
+ *
227
+ * "AN ADMIN", not "a workspace admin" and not "a moderator". The ONE thing
228
+ * `moderated` licenses the render to claim is that the author did not do it —
229
+ * that is the whole content of the flag, and how a host derives it is the
230
+ * host's business, not this module's. (An earlier draft asserted it comes from
231
+ * `deleted_by <> author_id`. That is one consumer's wire and appears nowhere in
232
+ * this repository; stating another product's schema as fact here is exactly the
233
+ * coupling this module's entity-agnostic claim rules out.) Naming a ROLE rather
234
+ * than a person keeps the claim true — nothing obliges a host to send an actor,
235
+ * and a string with a slot for one is a string somebody eventually fills.
236
+ * "Workspace" is one host's noun and this module is entity-agnostic by its own
237
+ * opening claim (types.ts); "moderator" is a forum's word for a role that
238
+ * exists in neither consumer. "Admin" is what both of them actually call the
239
+ * person who can do this, which is the test interface copy actually has to
240
+ * pass.
241
+ *
242
+ * TWO STRINGS PER ACT, because they land in two different slots. The byline is
243
+ * the AUTHOR position: a sentence there reads as an attribution, and "removed
244
+ * by an admin" sitting where a name goes says the admin wrote it. It also
245
+ * feeds two accessible names — "Collapse replies to X" and "More actions for
246
+ * X's comment" — which a sentence makes unusable. So the byline takes the
247
+ * short form and the body, which is the interface speaking in its own voice,
248
+ * takes the sentence.
249
+ *
250
+ * The brackets and the lower case are unchanged from `[deleted]`: that form is
251
+ * what marks a line as the system talking rather than a person, and a
252
+ * tombstone that stopped looking like one would be a worse regression than the
253
+ * one being fixed.
254
+ */
255
+ const TOMBSTONE: Readonly<
256
+ Record<'deleted' | 'moderated', { readonly byline: string; readonly body: string }>
257
+ > = {
258
+ deleted: { byline: '[deleted]', body: '[deleted]' },
259
+ moderated: { byline: '[removed]', body: '[removed by an admin]' },
260
+ }
261
+
262
+ /** "1 reply" / "8 replies" — the live region's wording, in one place. */
263
+ function replyCountLabel(count: number): string {
264
+ return `${String(count)} ${count === 1 ? 'reply' : 'replies'}`
265
+ }
266
+
267
+ function formatBytes(bytes: number): string {
268
+ if (!Number.isFinite(bytes) || bytes < 0) return ''
269
+ if (bytes < 1024) return `${String(bytes)} B`
270
+ if (bytes < 1024 * 1024) return `${String(Math.round(bytes / 1024))} KB`
271
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
272
+ }
273
+
274
+ function Attachments({
275
+ attachments,
276
+ onOpen,
277
+ resolveUrl,
278
+ }: {
279
+ attachments: readonly ThreadAttachment[]
280
+ onOpen?: ((attachment: ThreadAttachment) => void) | undefined
281
+ resolveUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
282
+ }) {
283
+ // Three buckets, not two, since ADR-151 D4: a Giphy mp4 renders through
284
+ // `AttachmentGif`; an uploaded image/gif remains in `images`, where
285
+ // `AttachmentImage` gives it the equivalent still-frame treatment.
286
+ const images = attachments.filter((a) => a.kind === 'image')
287
+ const gifs = attachments.filter((a) => a.kind === 'gif')
288
+ const files = attachments.filter((a) => a.kind === 'file')
289
+ return (
290
+ <div
291
+ className="flex flex-col gap-[var(--space-2)]"
292
+ style={{ marginBlockStart: 'var(--space-2)' }}
293
+ >
294
+ {images.length === 0 && gifs.length === 0 ? null : (
295
+ <div className="flex flex-wrap gap-[var(--space-2)]">
296
+ {images.map((attachment) => (
297
+ <AttachmentImage
298
+ key={attachment.id}
299
+ attachment={attachment}
300
+ onOpen={onOpen}
301
+ resolveUrl={resolveUrl}
302
+ />
303
+ ))}
304
+ {gifs.map((attachment) => (
305
+ <AttachmentGif key={attachment.id} attachment={attachment} resolveUrl={resolveUrl} />
306
+ ))}
307
+ </div>
308
+ )}
309
+ {files.map((attachment) => (
310
+ <button
311
+ key={attachment.id}
312
+ type="button"
313
+ disabled={onOpen === undefined}
314
+ onClick={() => {
315
+ onOpen?.(attachment)
316
+ }}
317
+ className={cn(
318
+ // Optical padding: 8 inline against 4 block, because the line box
319
+ // already carries space the glyphs do not fill.
320
+ 'inline-flex max-w-full items-center gap-[var(--space-2)] self-start border px-[var(--space-2)] py-[var(--space-1)] text-[12px]',
321
+ 'border-[rgb(var(--border))] bg-[rgb(var(--surface-inset))] text-[rgb(var(--text-secondary))]',
322
+ 'transition-[background-color,border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none',
323
+ 'enabled:hover:border-[rgb(var(--border-strong))] enabled:hover:bg-[rgb(var(--surface-hover))]',
324
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
325
+ 'disabled:cursor-default',
326
+ )}
327
+ style={{ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))' }}
328
+ >
329
+ <LineIcon>
330
+ <FileText size={12} strokeWidth={1.5} aria-hidden="true" />
331
+ </LineIcon>
332
+ <span className="truncate">{attachment.name}</span>
333
+ {attachment.sizeBytes === undefined ? null : (
334
+ <span className="tabular-nums" style={{ color: 'rgb(var(--text-tertiary))' }}>
335
+ {formatBytes(attachment.sizeBytes)}
336
+ </span>
337
+ )}
338
+ </button>
339
+ ))}
340
+ </div>
341
+ )
342
+ }
343
+
344
+ /**
345
+ * Every visible card on one comment (ADR-150 D8).
346
+ *
347
+ * There is ONE of these, reached by the one recursive `CommentItem`, so a root
348
+ * and a reply are rendered by the same code by construction rather than by
349
+ * two call sites agreeing. Ordering comes from the worker (`sort_order`,
350
+ * document order) and is not re-sorted here.
351
+ */
352
+ function LinkPreviews({
353
+ commentId,
354
+ previews,
355
+ onHide,
356
+ }: {
357
+ commentId: string
358
+ previews: readonly ThreadLinkPreview[]
359
+ onHide?: ((commentId: string, urlHash: string) => void) | undefined
360
+ }) {
361
+ return (
362
+ <div
363
+ className="flex flex-col gap-[var(--space-2)]"
364
+ style={{ marginBlockStart: 'var(--space-2)' }}
365
+ >
366
+ {previews.map((preview) => (
367
+ <LinkPreview
368
+ key={preview.urlHash}
369
+ preview={preview}
370
+ {...(onHide === undefined
371
+ ? {}
372
+ : {
373
+ onHide: () => {
374
+ onHide(commentId, preview.urlHash)
375
+ },
376
+ })}
377
+ />
378
+ ))}
379
+ </div>
380
+ )
381
+ }
382
+
383
+ export interface CommentItemProps {
384
+ readonly node: CommentNode
385
+ readonly ctx: ThreadRenderContext
386
+ /**
387
+ * RENDER depth. Only two things read it: the (uncapped by default)
388
+ * `maxDepth`, and the indent clamp. `node.depth` is the true stored depth.
389
+ */
390
+ readonly renderDepth: number
391
+ /**
392
+ * Last of its sibling run. The last reply TERMINATES the connector at its own
393
+ * avatar; every other one carries the line on to the next. Roots are always
394
+ * last — a root has no rail above it to carry.
395
+ */
396
+ readonly isLast?: boolean | undefined
397
+ /**
398
+ * Render the frame as an `<li>`. Defaults to true, because the recursive
399
+ * case is the common one and a comment is a list item: every reply is a
400
+ * direct child of its parent's replies `<ul>`. Pass false wherever the
401
+ * `<li>` is supplied by the caller instead — `<Thread>` does exactly that
402
+ * for a ROOT, whose `<li>` has to sit OUTSIDE its `<Card>` (the comment's own
403
+ * block-start padding is what supplies the card's top inset). A custom skin
404
+ * built on `useThread` owns its own root list and should do the same.
405
+ */
406
+ readonly listItem?: boolean | undefined
407
+ }
408
+
409
+ export function CommentItem({
410
+ node,
411
+ ctx,
412
+ renderDepth,
413
+ isLast = true,
414
+ listItem = true,
415
+ }: CommentItemProps) {
416
+ const { thread } = ctx
417
+ // "Continue this thread" expands the subtree in place when a consumer set a
418
+ // finite maxDepth and has nowhere to navigate to. Local, and correctly lost
419
+ // on unmount: it is a view preference, not content.
420
+ const [continued, setContinued] = useState(false)
421
+
422
+ const collapsed = thread.isCollapsed(node.id)
423
+ const deleted = node.deletedAt != null
424
+ const state = node.state ?? 'sent'
425
+ // `moderated` says nothing about a live comment — nobody has removed one —
426
+ // so it is read HERE, under `deleted`, and nowhere else in the render. A
427
+ // comment carrying the flag without a `deletedAt` is not a tombstone and
428
+ // does not become one.
429
+ const tombstone = deleted ? TOMBSTONE[node.moderated === true ? 'moderated' : 'deleted'] : null
430
+ const label = tombstone === null ? authorLabel(node.author) : tombstone.byline
431
+ const descendants = countDescendants(node)
432
+ const atCap = renderDepth >= thread.maxDepth && !continued
433
+ const { visible, hidden } = thread.visibleReplies(node)
434
+ const hasChildren = node.replies.length > 0
435
+ const replyOpen = thread.isReplyingTo(node.id)
436
+ // ADR-148 D5: reply composers are rich only after their first open. Every
437
+ // comment keeps its reply composer MOUNTED (correction 4 — nothing that
438
+ // animates is conditionally rendered), so without this latch a 24-comment
439
+ // thread would mount 24 ProseMirror editors before anyone replied. Once
440
+ // opened it stays rich, so the closing animation never swaps content
441
+ // mid-flight (the collapse rule: content keeps its size while closing).
442
+ const [replyEverOpened, setReplyEverOpened] = useState(false)
443
+ if (replyOpen && !replyEverOpened) setReplyEverOpened(true)
444
+ // Annotated rather than inferred so the ADR-149/151 seams can be passed to the
445
+ // union: `RichThreadComposerProps` extends `ThreadComposerProps`, so the
446
+ // plain composer satisfies this signature and simply never receives the
447
+ // rich-only capabilities.
448
+ const ReplyComposer: (props: RichThreadComposerProps) => ReactElement =
449
+ ctx.composer === 'rich' && replyEverOpened ? RichThreadComposer : ThreadComposer
450
+ // Narrow once, so the byline never needs a cast to satisfy the callback.
451
+ const author = node.author ?? null
452
+ // `avatar_color` is a nullable TEXT column, so it is parsed at this boundary
453
+ // rather than trusted as a domain value (D6).
454
+ const tint = asAvatarTint(author?.tint)
455
+ const canEngage = !deleted && state === 'sent'
456
+ const indentClampDepth = ctx.indentClampDepth ?? DEFAULT_INDENT_CLAMP_DEPTH
457
+
458
+ // A reply draws the segment of the connector that spans its own box.
459
+ const connected = renderDepth > 0
460
+ // Past the clamp the indent stops growing, so there is no gutter to curve in
461
+ // and the line drops straight onto the child's avatar instead.
462
+ const clamped = renderDepth > indentClampDepth
463
+ // The rail only exists where it leads somewhere. A leaf comment costs no
464
+ // chrome at all, which is most of why depth got cheap.
465
+ const railed = hasChildren && !atCap
466
+ // Deliberately WITHOUT the count. The summary chip below a collapsed subtree
467
+ // names the count ("Show 8 replies to Priya Nair"); the circle names the
468
+ // toggle. Two controls that do the same thing may share a target, but they
469
+ // must not share an accessible name, or a screen-reader user hears the same
470
+ // control twice and cannot tell which one they are on.
471
+ const collapseLabel = `${collapsed ? 'Show' : 'Collapse'} replies to ${label}`
472
+ // ENGAGEMENT only — Reply lives in the byline now. One purpose per row.
473
+ const showActions = state === 'pending' || (canEngage && thread.engagement !== 'none')
474
+
475
+ // The frame element. A `list-item` box with `list-style: none` (set by the
476
+ // `<ul>` and inherited) is laid out exactly as a block, so this swap moves
477
+ // nothing: same padding, same containing block for the connectors.
478
+ const Frame = listItem ? 'li' : 'div'
479
+
480
+ return (
481
+ <Frame
482
+ className="relative"
483
+ data-comment-id={node.id}
484
+ style={{
485
+ // The comment's OWN top padding, never a container gap — sibling boxes
486
+ // must be flush for the connector segments to join.
487
+ paddingBlockStart: 'var(--thread-row-gap)',
488
+ ...(connected && !clamped ? { paddingInlineStart: 'var(--thread-indent)' } : {}),
489
+ }}
490
+ >
491
+ {connected ? <ThreadConnector clamped={clamped} isLast={isLast} /> : null}
492
+
493
+ <div style={GRID_STYLE}>
494
+ {/* AVATAR COLUMN — row 1 only, so it stretches to the height of this
495
+ comment's own content and the circle lands on the action row. */}
496
+ <div className="flex flex-col items-center" style={{ gridColumn: 1, gridRow: 1 }}>
497
+ <Avatar
498
+ // `decorative` because the name is rendered right beside it —
499
+ // otherwise a screen reader announces the same person twice.
500
+ decorative
501
+ name={author?.name ?? author?.email ?? null}
502
+ size="md"
503
+ src={author?.avatarUrl ?? null}
504
+ {...(deleted || author === null ? { initial: '?' } : {})}
505
+ {...(tint === undefined ? {} : { tint })}
506
+ {...(author?.id == null ? {} : { seed: author.id })}
507
+ />
508
+ {railed ? (
509
+ <ThreadRail
510
+ collapsed={collapsed}
511
+ label={collapseLabel}
512
+ onToggle={() => {
513
+ thread.toggleCollapse(node.id)
514
+ // `collapsed` is the state BEFORE the toggle, so the message
515
+ // names the state being entered.
516
+ ctx.announce?.(
517
+ `${collapsed ? 'Expanded' : 'Collapsed'} ${replyCountLabel(descendants)}`,
518
+ )
519
+ }}
520
+ />
521
+ ) : null}
522
+ </div>
523
+
524
+ {/* CONTENT COLUMN — row 1. Identity, then the body, then one action
525
+ row. Nothing here is a box; the hierarchy is weight and space. */}
526
+ <div className="min-w-0" style={{ gridColumn: 2, gridRow: 1 }}>
527
+ <div
528
+ data-thread-byline
529
+ // Deliberately NOT `flex-wrap`. A wrapping byline drops the action
530
+ // cluster onto a second line the moment a name gets long, which is
531
+ // the reflow this row must never do; the identity group absorbs the
532
+ // pressure by truncating instead.
533
+ className="flex items-center gap-[var(--space-2)]"
534
+ // Optically centres the name against the face beside it. The floor
535
+ // is the avatar PLUS the trailing gap, because `border-box` counts
536
+ // padding into `min-block-size`: without the addition the content
537
+ // box would be 28 and the text would centre 2px above the avatar.
538
+ style={{
539
+ minBlockSize: 'calc(var(--thread-avatar) + var(--space-1))',
540
+ paddingBlockEnd: 'var(--space-1)',
541
+ }}
542
+ >
543
+ <div className="flex min-w-0 flex-1 items-center gap-[var(--space-2)]">
544
+ {ctx.onSelectAuthor !== undefined && !deleted && author !== null ? (
545
+ <button
546
+ type="button"
547
+ onClick={() => {
548
+ ctx.onSelectAuthor?.(author)
549
+ }}
550
+ className={cn(
551
+ 'min-w-0 truncate text-[14px] font-semibold text-[rgb(var(--foreground))]',
552
+ 'underline-offset-2 hover:underline',
553
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
554
+ )}
555
+ style={{ borderRadius: 'var(--radius-xs)' }}
556
+ >
557
+ {label}
558
+ </button>
559
+ ) : (
560
+ <span
561
+ className="min-w-0 truncate text-[14px] font-semibold"
562
+ style={{
563
+ color: deleted ? 'rgb(var(--text-tertiary))' : 'rgb(var(--foreground))',
564
+ }}
565
+ >
566
+ {label}
567
+ </span>
568
+ )}
569
+ {author?.role == null || deleted ? null : (
570
+ <span
571
+ className="shrink-0 text-[10.5px] font-semibold tracking-[0.04em] uppercase"
572
+ style={{ color: 'rgb(var(--text-tertiary))' }}
573
+ >
574
+ {author.role}
575
+ </span>
576
+ )}
577
+ <span
578
+ className="flex shrink-0 items-center gap-[var(--space-1)] text-[12px]"
579
+ style={{ color: 'rgb(var(--text-tertiary))' }}
580
+ >
581
+ <RelativeTime
582
+ value={node.createdAt}
583
+ now={thread.now}
584
+ {...(ctx.locale === undefined ? {} : { locale: ctx.locale })}
585
+ />
586
+ {node.editedAt == null ? null : <span>· edited</span>}
587
+ </span>
588
+ </div>
589
+
590
+ <CommentActions
591
+ label={label}
592
+ replyOpen={replyOpen}
593
+ {...(canEngage && thread.canReply
594
+ ? {
595
+ onReplyToggle: () => {
596
+ if (replyOpen) thread.cancelReply()
597
+ else thread.startReply(node.id)
598
+ },
599
+ }
600
+ : {})}
601
+ {...(deleted || node.bodyMd === '' ? {} : { copyText: node.bodyMd })}
602
+ {...(ctx.onCopyText === undefined
603
+ ? {}
604
+ : {
605
+ onCopyText: (ok: boolean) => {
606
+ ctx.onCopyText?.(node.id, ok)
607
+ },
608
+ })}
609
+ {...(ctx.onCopyLink === undefined
610
+ ? {}
611
+ : {
612
+ onCopyLink: () => {
613
+ ctx.onCopyLink?.(node.id)
614
+ },
615
+ })}
616
+ {...(ctx.onEdit === undefined || deleted
617
+ ? {}
618
+ : {
619
+ onEdit: () => {
620
+ ctx.onEdit?.(node.id)
621
+ },
622
+ })}
623
+ {...(ctx.onDelete === undefined || deleted
624
+ ? {}
625
+ : {
626
+ onDelete: () => {
627
+ ctx.onDelete?.(node.id)
628
+ },
629
+ })}
630
+ />
631
+ </div>
632
+
633
+ {node.orphanedParentId === null ? null : (
634
+ // ADR-147 D4: this reply named a parent that is NOT in this list.
635
+ // It is shown at top level because dropping it would be worse,
636
+ // but it is labelled, because silently promoting it would claim
637
+ // it was a new remark rather than an answer to something.
638
+ <p
639
+ className="flex items-start gap-[var(--space-1)] text-[11.5px] italic"
640
+ style={{
641
+ color: 'rgb(var(--text-tertiary))',
642
+ marginBlockEnd: 'var(--space-1)',
643
+ }}
644
+ >
645
+ <LineIcon>
646
+ <CornerDownRight size={10} strokeWidth={1.5} aria-hidden="true" />
647
+ </LineIcon>
648
+ In reply to a comment that isn&rsquo;t shown here
649
+ </p>
650
+ )}
651
+
652
+ {tombstone !== null ? (
653
+ <p className="text-[13px] italic" style={{ color: 'rgb(var(--text-tertiary))' }}>
654
+ {tombstone.body}
655
+ </p>
656
+ ) : (
657
+ <CommentBody
658
+ bodyMd={node.bodyMd}
659
+ {...(ctx.onSelectMention === undefined
660
+ ? {}
661
+ : { onSelectMention: ctx.onSelectMention })}
662
+ />
663
+ )}
664
+
665
+ {node.attachments === undefined || node.attachments.length === 0 || deleted ? null : (
666
+ <Attachments
667
+ attachments={node.attachments}
668
+ {...(ctx.onOpenAttachment === undefined ? {} : { onOpen: ctx.onOpenAttachment })}
669
+ {...(ctx.resolveAttachmentUrl === undefined
670
+ ? {}
671
+ : { resolveUrl: ctx.resolveAttachmentUrl })}
672
+ />
673
+ )}
674
+
675
+ {/* Link cards sit BELOW the person's own attachments: what they
676
+ deliberately attached outranks what a scanner found in their
677
+ prose. A tombstone shows none — the worker already returns none
678
+ for one (ADR-150 D8), and this is the second lock on it, because
679
+ a tombstone followed by three intact cards would leak the body
680
+ the tombstone exists to remove. */}
681
+ {node.linkPreviews === undefined || node.linkPreviews.length === 0 || deleted ? null : (
682
+ <LinkPreviews
683
+ commentId={node.id}
684
+ previews={node.linkPreviews}
685
+ {...(ctx.onHideLinkPreview === undefined
686
+ ? {}
687
+ : { onHide: ctx.onHideLinkPreview })}
688
+ />
689
+ )}
690
+
691
+ {/* THE engagement row. Emoji reactions (or votes) and the in-flight
692
+ notice share one line — the "+" used to sit on a line of its own
693
+ with nothing beside it, which read as an orphan rather than a
694
+ control. `--space-1` from the body above it, a quarter of the gap
695
+ to the next comment, so the grouping is unambiguous without a
696
+ rule. */}
697
+ {showActions ? (
698
+ <div
699
+ className="flex flex-wrap items-center gap-[var(--space-2)]"
700
+ style={{
701
+ marginBlockStart: 'var(--space-1)',
702
+ minBlockSize: 'var(--thread-action-h, var(--space-8))',
703
+ }}
704
+ >
705
+ {canEngage && thread.engagement === 'reactions' ? (
706
+ <ReactionBar
707
+ reactions={node.reactions ?? []}
708
+ {...(ctx.onReact === undefined
709
+ ? {}
710
+ : {
711
+ onReact: (key: ThreadReactionKey) => {
712
+ ctx.onReact?.(node.id, key)
713
+ },
714
+ })}
715
+ />
716
+ ) : null}
717
+
718
+ {canEngage && thread.engagement === 'votes' ? (
719
+ <CommentVote
720
+ score={node.score ?? 0}
721
+ viewerVote={node.viewerVote ?? 0}
722
+ {...(ctx.onVote === undefined
723
+ ? {}
724
+ : {
725
+ onVote: (next: ThreadVote) => {
726
+ ctx.onVote?.(node.id, next)
727
+ },
728
+ })}
729
+ />
730
+ ) : null}
731
+
732
+ {state === 'pending' ? (
733
+ <span
734
+ className="text-[11.5px] font-semibold"
735
+ style={{ color: 'rgb(var(--text-tertiary))' }}
736
+ >
737
+ Sending…
738
+ </span>
739
+ ) : null}
740
+ </div>
741
+ ) : null}
742
+
743
+ {state === 'failed' ? (
744
+ // role="alert": a send that failed after the person already
745
+ // looked away has to announce itself rather than wait to be
746
+ // noticed. ADR-147 D17.
747
+ <div
748
+ role="alert"
749
+ // TWO alignment axes, and they want opposite things — which is why
750
+ // this is two rows rather than one.
751
+ //
752
+ // OUTER is `items-center`: the message and the retry are siblings
753
+ // of unequal height (the button is taller than a line of 12px
754
+ // text), so aligning them to the start left the message riding
755
+ // ~2px above the button's centre line. Centring is what makes the
756
+ // two read as one row.
757
+ //
758
+ // INNER stays `items-start` so the icon keeps its `1lh`
759
+ // first-line alignment when the message wraps. Centring there too
760
+ // would drop the glyph to the middle of a three-line block, which
761
+ // is the defect the LineIcon wrapper exists to prevent.
762
+ className="flex flex-wrap items-center gap-[var(--space-2)] border px-[var(--space-2)] py-[var(--space-1)] text-[12px]"
763
+ style={{
764
+ marginBlockStart: 'var(--space-2)',
765
+ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
766
+ borderColor: 'rgb(var(--destructive))',
767
+ background: 'rgb(var(--destructive-bg))',
768
+ color: 'rgb(var(--destructive-ink))',
769
+ }}
770
+ >
771
+ <span className="flex min-w-0 flex-1 items-start gap-[var(--space-2)]">
772
+ <LineIcon>
773
+ <TriangleAlert size={12} strokeWidth={1.75} aria-hidden="true" />
774
+ </LineIcon>
775
+ <span className="min-w-0 flex-1">
776
+ {node.errorText ?? 'This comment did not send.'}
777
+ </span>
778
+ </span>
779
+ {ctx.onRetry === undefined ? null : (
780
+ <Button
781
+ variant="secondary"
782
+ size="sm"
783
+ leadingIcon={<RotateCw size={10} strokeWidth={1.75} aria-hidden="true" />}
784
+ onClick={() => {
785
+ ctx.onRetry?.(node.id)
786
+ }}
787
+ >
788
+ Try again
789
+ </Button>
790
+ )}
791
+ </div>
792
+ ) : null}
793
+
794
+ {hasChildren && atCap ? (
795
+ <Button
796
+ variant="secondary"
797
+ size="sm"
798
+ className="mt-[var(--space-2)]"
799
+ trailingIcon={<ChevronRight size={12} strokeWidth={1.75} aria-hidden="true" />}
800
+ onClick={() => {
801
+ if (ctx.onContinueThread !== undefined) ctx.onContinueThread(node.id)
802
+ else setContinued(true)
803
+ }}
804
+ >
805
+ Continue this thread
806
+ </Button>
807
+ ) : null}
808
+ </div>
809
+
810
+ {/* ROW 2 — the reply composer, hung on the same alignment edge as the
811
+ body. It sits BELOW the avatar column rather than inside it so that
812
+ opening it cannot drag the collapse circle off the action row; the
813
+ trunk segment keeps the line unbroken across it for as long as it is
814
+ open, and is zero-height while it is closed. */}
815
+ <div
816
+ className="relative"
817
+ style={{
818
+ gridColumn: '1 / -1',
819
+ gridRow: 2,
820
+ paddingInlineStart: 'var(--thread-content-x)',
821
+ }}
822
+ >
823
+ {railed && !collapsed ? <ThreadTrunkSegment /> : null}
824
+ {/* Mounted, driven by data-open — correction 4. Unmounting this would
825
+ make the open/close transition unobservable and would also throw
826
+ away whatever was typed on a stray re-render. `grid-rows` from
827
+ 0fr to 1fr animates the CONTAINER: the content keeps its natural
828
+ size throughout and is clipped, so nothing reflows mid-collapse. */}
829
+ <div
830
+ data-open={replyOpen}
831
+ // Every control inside is `disabled` while closed, so nothing is
832
+ // focusable and hiding it from the a11y tree is honest rather
833
+ // than a focus trap. It stays MOUNTED — correction 4.
834
+ aria-hidden={!replyOpen}
835
+ className={cn(
836
+ 'grid grid-rows-[0fr] opacity-0 data-[open=true]:grid-rows-[1fr] data-[open=true]:opacity-100',
837
+ 'transition-[grid-template-rows,opacity] duration-[var(--dur-base)] ease-[var(--ease-out)]',
838
+ 'motion-reduce:transition-none',
839
+ )}
840
+ >
841
+ <div className="overflow-hidden">
842
+ <div style={{ paddingBlockStart: 'var(--space-2)' }}>
843
+ <ReplyComposer
844
+ {...(ctx.composer === undefined ? {} : { mode: ctx.composer })}
845
+ {...(ctx.onComposerChange === undefined
846
+ ? {}
847
+ : { onModeChange: ctx.onComposerChange })}
848
+ label={`Reply to ${label}`}
849
+ placeholder={`Reply to ${label}…`}
850
+ submitLabel="Reply"
851
+ replyingTo={label}
852
+ value={thread.replyDraft}
853
+ onChange={thread.setReplyDraft}
854
+ onSubmit={thread.submitReply}
855
+ onCancel={thread.cancelReply}
856
+ rows={2}
857
+ toolbar={false}
858
+ disabled={!replyOpen}
859
+ {...(ctx.sending === undefined ? {} : { sending: ctx.sending })}
860
+ {...(ctx.composerMaxLength === undefined
861
+ ? {}
862
+ : { maxLength: ctx.composerMaxLength })}
863
+ {...(ctx.replyAvatar === undefined ? {} : { avatar: ctx.replyAvatar })}
864
+ {...(ctx.mentionItems === undefined ? {} : { mentionItems: ctx.mentionItems })}
865
+ {...(ctx.uploadAttachment === undefined
866
+ ? {}
867
+ : { uploadAttachment: ctx.uploadAttachment })}
868
+ {...(ctx.attachGif === undefined ? {} : { attachGif: ctx.attachGif })}
869
+ {...(ctx.searchGifs === undefined ? {} : { searchGifs: ctx.searchGifs })}
870
+ {...(ctx.attachmentAccept === undefined
871
+ ? {}
872
+ : { attachmentAccept: ctx.attachmentAccept })}
873
+ {...(ctx.attachmentMaxPerComment === undefined
874
+ ? {}
875
+ : { attachmentMaxPerComment: ctx.attachmentMaxPerComment })}
876
+ {...(ctx.attachmentMaxFileBytes === undefined
877
+ ? {}
878
+ : { attachmentMaxFileBytes: ctx.attachmentMaxFileBytes })}
879
+ {...(ctx.attachmentMaxTotalBytes === undefined
880
+ ? {}
881
+ : { attachmentMaxTotalBytes: ctx.attachmentMaxTotalBytes })}
882
+ />
883
+ </div>
884
+ </div>
885
+ </div>
886
+ </div>
887
+
888
+ {/* ROW 3 — the replies. Collapsed, the subtree becomes a summary and the
889
+ rail simply terminates at the circle, exactly as the reference's
890
+ does. No container gap: every reply's own block-start padding is the
891
+ separation, so the connector segments stay flush. */}
892
+ {!hasChildren || atCap ? null : (
893
+ <div style={{ gridColumn: '1 / -1', gridRow: 3 }}>
894
+ {collapsed ? (
895
+ <div
896
+ style={{
897
+ paddingBlockStart: 'var(--thread-row-gap)',
898
+ paddingInlineStart: 'var(--thread-indent)',
899
+ }}
900
+ >
901
+ <CollapsedReplies
902
+ node={node}
903
+ count={descendants}
904
+ now={thread.now}
905
+ parentLabel={label}
906
+ {...(ctx.locale === undefined ? {} : { locale: ctx.locale })}
907
+ onExpand={() => {
908
+ thread.setCollapsed(node.id, false)
909
+ ctx.announce?.(`Expanded ${replyCountLabel(descendants)}`)
910
+ }}
911
+ />
912
+ </div>
913
+ ) : (
914
+ <>
915
+ {/* One `<ul>` per NESTING LEVEL — the depth is the structure,
916
+ so a reader gets it from the markup rather than from an
917
+ indent they cannot see. Zero margin and zero padding, so
918
+ the list contributes no box of its own: the replies keep
919
+ the flush top edges their connector segments need.
920
+ `role="list"` is restated because `list-style: none` drops
921
+ the implicit role in Safari/VoiceOver.
922
+
923
+ "Show N more" stays OUTSIDE the list. It is a control, not
924
+ a reply, and inside it would inflate the announced item
925
+ count past the number of comments actually there. */}
926
+ <ul role="list" className="m-0 list-none p-0">
927
+ {visible.map((reply, index) => (
928
+ <CommentItem
929
+ key={reply.id}
930
+ node={reply}
931
+ ctx={ctx}
932
+ // The line stops at the last reply's avatar — unless a
933
+ // "Show N more" follows, in which case it carries on to it.
934
+ isLast={index === visible.length - 1 && hidden === 0}
935
+ // `continued` restarts the render budget for this subtree;
936
+ // the STORED depth is untouched (D3 — capping display loses
937
+ // nothing, capping storage loses data).
938
+ renderDepth={continued ? 0 : renderDepth + 1}
939
+ />
940
+ ))}
941
+ </ul>
942
+ {hidden > 0 ? (
943
+ <div
944
+ style={{
945
+ paddingBlockStart: 'var(--thread-row-gap)',
946
+ paddingInlineStart: 'var(--thread-indent)',
947
+ }}
948
+ >
949
+ <Button
950
+ variant="secondary"
951
+ size="sm"
952
+ onClick={() => {
953
+ thread.expandReplies(node.id)
954
+ }}
955
+ >
956
+ Show {hidden} more {hidden === 1 ? 'reply' : 'replies'}
957
+ </Button>
958
+ </div>
959
+ ) : null}
960
+ </>
961
+ )}
962
+ </div>
963
+ )}
964
+ </div>
965
+ </Frame>
966
+ )
967
+ }