@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,611 @@
1
+ /**
2
+ * ActivityPane — Everything · Comments · Activity, with one composer.
3
+ *
4
+ * `docs/design-briefs/tasks-item-detail-full.md` §5.
5
+ *
6
+ * ## The composer is pinned to the bottom of the pane on EVERY tab
7
+ *
8
+ * Including Activity, which is the instruction that decides the whole
9
+ * structure. `Thread` pins a root composer of its own at the foot of the
10
+ * comments list, so mounting it inside the Comments tab and a second one for
11
+ * the other tabs would put two composers on screen at once — and switching
12
+ * tabs would unmount whichever one you were typing in. So the composer lives
13
+ * OUTSIDE the tab panels, mounted once, and `Thread` is asked to draw no root
14
+ * composer (`rootComposer={false}`, added to that primitive for this). Replies
15
+ * still work: they are gated on `onReply`, not on the root composer.
16
+ *
17
+ * The three panels are all mounted and toggled with `hidden` for the same
18
+ * reason — a scroll position, an expanded reply and a half-read thread all
19
+ * survive a tab change, and `hidden` takes them out of the accessibility tree
20
+ * and the tab ring while they are away.
21
+ *
22
+ * ## It fetches nothing
23
+ *
24
+ * Comments and activity arrive as props, already shaped. §6 is explicit that
25
+ * the activity feed is written server-side in the same transaction as the
26
+ * change — never derived on the client — so this component has no business
27
+ * inferring history from an item, and does not.
28
+ *
29
+ * ## It owns no thread behaviour either
30
+ *
31
+ * Every comment callback it declares is `ThreadProps`' own, forwarded
32
+ * unchanged to the `<Thread>` below. The pane decides WHERE a control lives —
33
+ * one composer, outside the panels — and never what one does. That is not a
34
+ * style preference: a prop it fails to forward is a control a host cannot draw
35
+ * at all, and three shipped mutations spent a release unreachable that way
36
+ * (Lightwork FU-0019). What is still not forwarded, and why, is listed on
37
+ * `ActivityPaneProps`, so the next gap is chosen rather than discovered.
38
+ *
39
+ * ## Everything is read-only
40
+ *
41
+ * The merged feed interleaves both by time so you can see what happened in
42
+ * order. Reacting, replying and collapsing live in Comments, where the thread
43
+ * is drawn in full — a feed that is half interactive and half not is worse
44
+ * than one that is honestly neither.
45
+ */
46
+
47
+ import { useId, useMemo, useState, type ReactNode } from 'react'
48
+ import { Tabs } from '../tabs'
49
+ import { Avatar, asAvatarTint } from '../avatar'
50
+ import { cn } from '../lib/utils'
51
+ import { CommentBody } from '../thread/comment-body'
52
+ import { RelativeTime } from '../thread/relative-time'
53
+ import { Thread } from '../thread/thread'
54
+ import { ThreadComposer } from '../thread/composer'
55
+ import { useNowTick } from '../thread/use-now'
56
+ import type { ThreadAuthor, ThreadComment, ThreadReactionKey } from '../thread/types'
57
+ import type { ActivityEntry, FieldPerson } from './types'
58
+
59
+ export type ActivityTab = 'everything' | 'comments' | 'activity'
60
+
61
+ const TAB_ORDER: readonly ActivityTab[] = ['everything', 'comments', 'activity']
62
+
63
+ /**
64
+ * The tabs a given pane actually shows.
65
+ *
66
+ * A host with no comment backend gets Everything and Activity, and Everything
67
+ * then means "the activity feed" rather than a merge of two sources — which is
68
+ * still true, just shorter. Dropping the tab rather than showing an empty
69
+ * Comments panel is the honest version: a tab that can never have content is a
70
+ * promise the product does not keep.
71
+ */
72
+ function tabsFor(withComments: boolean): readonly ActivityTab[] {
73
+ return withComments ? TAB_ORDER : TAB_ORDER.filter((id) => id !== 'comments')
74
+ }
75
+
76
+ const TAB_LABEL: Readonly<Record<ActivityTab, string>> = {
77
+ everything: 'Everything',
78
+ comments: 'Comments',
79
+ activity: 'Activity',
80
+ }
81
+
82
+ /** In the interface's own voice: what this is, and what will appear here. */
83
+ const DEFAULT_EMPTY: Readonly<Record<ActivityTab, string>> = {
84
+ everything: 'Nothing here yet. Comments and changes to this record show up together.',
85
+ comments: 'No comments yet. Start the conversation.',
86
+ activity: 'No activity yet. Every change to this record is recorded here.',
87
+ }
88
+
89
+ export interface ActivityPaneProps {
90
+ /** Controlled tab. Omit for internal state. */
91
+ tab?: ActivityTab | undefined
92
+ defaultTab?: ActivityTab | undefined
93
+ onTabChange?: ((next: ActivityTab) => void) | undefined
94
+
95
+ /**
96
+ * Already shaped. The pane fetches nothing.
97
+ *
98
+ * Omit it — along with the composer trio below — for a host that has no
99
+ * comment backend at all. The Comments tab is then not rendered, rather than
100
+ * rendered empty. See `tabsFor`.
101
+ */
102
+ comments?: readonly ThreadComment[] | undefined
103
+ /** Item history, in any order — the pane sorts by time. */
104
+ activity: readonly ActivityEntry[]
105
+
106
+ /**
107
+ * The composer's draft, controlled by the host so it survives a remount.
108
+ *
109
+ * The trio is all-or-nothing: supply `onSubmit` and you get a composer,
110
+ * omit it and the pane draws none. `onSubmit` is the one that decides,
111
+ * because a composer with nowhere to post is worse than no composer.
112
+ */
113
+ draft?: string | undefined
114
+ onDraftChange?: ((next: string) => void) | undefined
115
+ /** A new root comment. Omitted, the pane renders no composer. */
116
+ onSubmit?: ((body: string) => void) | undefined
117
+ /** A reply. Omitted, the thread offers no reply control at all. */
118
+ onReply?: ((body: string, parentId: string) => void) | undefined
119
+
120
+ /**
121
+ * Forwarded verbatim to the `<Thread>` the Comments tab already mounts.
122
+ *
123
+ * These four are here because the pane used to declare `onSubmit` and
124
+ * `onReply` and stop. `CommentItem` gates its reaction PICKER on `onReact`
125
+ * and its overflow MENU ITEMS on `onEdit` / `onDelete`; a menu item whose
126
+ * callback is absent is not rendered, and the reaction chips still render
127
+ * DISABLED rather than vanishing. (An earlier draft of this docblock said
128
+ * the whole bar was "NOT RENDERED". That is the rule from `ThreadProps`'
129
+ * overflow-menu docblock, and it does not generalise to the engagement row —
130
+ * `reactions.tsx` renders `disabled={!canReact}`.) So a host composing this
131
+ * pane shipped an inert
132
+ * engagement row with no picker to open and a menu holding one line, while
133
+ * the three routes behind them had no caller in the browser at all. Nothing
134
+ * errored, which is how it survived a release; Lightwork's FU-0019 found it
135
+ * by opening the surface rather than by reading the source, and the source
136
+ * understates it.
137
+ *
138
+ * `onRetry` is the same omission with one sharper edge: the failed send it
139
+ * recovers was posted by THIS pane's own composer, so dropping it left the
140
+ * pane manufacturing a state and offering nobody a way out of it.
141
+ *
142
+ * The signatures are `ThreadProps`', unchanged, because a conduit that
143
+ * reshapes what passes through it is a second contract to keep in step —
144
+ * and this one is held by two repos. Absent still means no control, so a
145
+ * host that passes none renders exactly as it did on 0.1.0.
146
+ *
147
+ * STILL NOT FORWARDED, deliberately. This list is EXHAUSTIVE against
148
+ * `ThreadProps` — check it against that interface before adding one, because
149
+ * a list whose whole purpose is completeness is worse than no list when it
150
+ * is short: `onVote`, `onCopyLink`, `onCopyText`, `onSelectAuthor`,
151
+ * `onSelectMention`, `onOpenAttachment`, `resolveAttachmentUrl`,
152
+ * `onContinueThread`.
153
+ *
154
+ * The line is whether a MUTATION becomes unreachable. `onVote` fails it for
155
+ * a different reason — the pane never exposes `engagement`, so there is no
156
+ * vote control to receive it and forwarding it would be a dead prop.
157
+ * `onCopyText` only reports a clipboard outcome; "Copy text" renders without
158
+ * it. `onContinueThread` falls back to local state, so the button works
159
+ * unwired. The rest are reads.
160
+ *
161
+ * `onHideLinkPreview` USED to be on this list and should not have been: it is
162
+ * a write in the same pattern as the other mutations, and `link-preview.tsx`
163
+ * gates its dismiss control on the callback being present. Since the pane
164
+ * forwards `comments` verbatim — link previews included — a host got
165
+ * dismissible-looking rows with no route to the mutation. Same shape as the
166
+ * defect this whole change fixes, caught in review.
167
+ */
168
+ onReact?: ((commentId: string, key: ThreadReactionKey) => void) | undefined
169
+ onEdit?: ((commentId: string) => void) | undefined
170
+ onDelete?: ((commentId: string) => void) | undefined
171
+ onRetry?: ((commentId: string) => void) | undefined
172
+
173
+ /**
174
+ * A write, in the same pattern as the mutations above — `link-preview.tsx`
175
+ * gates its dismiss control on this being present, and the pane forwards
176
+ * `comments` verbatim, previews included. Without it a host draws rows that
177
+ * look dismissible and are not. Added in review, not in the original change.
178
+ */
179
+ onHideLinkPreview?: ((commentId: string, urlHash: string) => void) | undefined
180
+
181
+ sending?: boolean | undefined
182
+ composerAvatar?: ReactNode | undefined
183
+ composerPlaceholder?: string | undefined
184
+ composerLabel?: string | undefined
185
+ composerMaxLength?: number | undefined
186
+ submitLabel?: string | undefined
187
+
188
+ /** Frozen clock, epoch ms. Omit and the shared 60s ticker drives the times. */
189
+ now?: number | undefined
190
+ locale?: string | undefined
191
+ /** Override any tab's empty state. */
192
+ emptyStates?: Partial<Record<ActivityTab, ReactNode>> | undefined
193
+ className?: string | undefined
194
+ }
195
+
196
+ export function ActivityPane({
197
+ tab,
198
+ defaultTab = 'everything',
199
+ onTabChange,
200
+ comments = [],
201
+ activity,
202
+ draft = '',
203
+ onDraftChange,
204
+ onSubmit,
205
+ onReply,
206
+ onReact,
207
+ onEdit,
208
+ onDelete,
209
+ onRetry,
210
+ onHideLinkPreview,
211
+ sending = false,
212
+ composerAvatar,
213
+ composerPlaceholder = 'Add a comment…',
214
+ composerLabel = 'Add a comment',
215
+ composerMaxLength,
216
+ submitLabel = 'Comment',
217
+ now,
218
+ locale,
219
+ emptyStates,
220
+ className,
221
+ }: ActivityPaneProps) {
222
+ const reactId = useId()
223
+ const [internalTab, setInternalTab] = useState<ActivityTab>(defaultTab)
224
+ const withComments = onSubmit !== undefined
225
+ const tabs = tabsFor(withComments)
226
+ const requested = tab ?? internalTab
227
+ // A host that drops Comments while it is the active tab (or that defaults to
228
+ // it) must not land on a panel that is not rendered.
229
+ const active = tabs.includes(requested) ? requested : (tabs[0] as ActivityTab)
230
+ const ticked = useNowTick(now === undefined)
231
+ const clock = now ?? ticked
232
+
233
+ const sortedActivity = useMemo(
234
+ () => [...activity].sort((a, b) => a.at - b.at),
235
+ [activity],
236
+ )
237
+
238
+ // Oldest first, so the newest sits nearest the composer.
239
+ const merged = useMemo(() => {
240
+ const entries: FeedEntry[] = [
241
+ ...comments.map((comment) => ({
242
+ kind: 'comment' as const,
243
+ at: comment.createdAt,
244
+ comment,
245
+ })),
246
+ ...sortedActivity.map((entry) => ({
247
+ kind: 'activity' as const,
248
+ at: entry.at,
249
+ entry,
250
+ })),
251
+ ]
252
+ return entries.sort((a, b) => a.at - b.at)
253
+ }, [comments, sortedActivity])
254
+
255
+ const empty = (id: ActivityTab): ReactNode => emptyStates?.[id] ?? DEFAULT_EMPTY[id]
256
+
257
+ // Tab -> panel wiring. The strip alone was a tablist whose tabs
258
+ // controlled nothing; each tab now points `aria-controls` at the panel it
259
+ // shows, and each panel is focusable so keyboard users can reach its
260
+ // scrolling content.
261
+ const idPrefix = `activity-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`
262
+ const panelDomId = (id: ActivityTab) => `${idPrefix}-panel-${id}`
263
+
264
+ return (
265
+ <section
266
+ data-slot="activity-pane"
267
+ aria-label="Activity"
268
+ className={cn('flex min-h-0 flex-1 flex-col', className)}
269
+ >
270
+ {/*
271
+ Matched to the field-list header on the other side of the split, so
272
+ the two panes' first rows sit on one line: same `--space-2` block
273
+ padding, same bottom border, and a control the same 36px tall (the
274
+ segmented track is 26 + 4 + 4 + 1 + 1, the same as that header's
275
+ SegmentedChoice). Change one and change the other.
276
+ */}
277
+ <div
278
+ className="shrink-0 border-b"
279
+ style={{
280
+ paddingInline: 'var(--space-4)',
281
+ paddingBlock: 'var(--space-2)',
282
+ borderColor: 'rgb(var(--border))',
283
+ }}
284
+ >
285
+ <Tabs
286
+ size="sm"
287
+ variant="segmented"
288
+ stretch
289
+ idPrefix={idPrefix}
290
+ activeTabId={active}
291
+ onTabChange={(next) => {
292
+ const id = next as ActivityTab
293
+ if (tab === undefined) setInternalTab(id)
294
+ onTabChange?.(id)
295
+ }}
296
+ tabs={tabs.map((id) => ({
297
+ id,
298
+ label: TAB_LABEL[id],
299
+ panelId: panelDomId(id),
300
+ // A badge only when there is something to count. "Comments 0"
301
+ // says nothing the empty state does not, and in a segmented
302
+ // strip at the pane's 280px floor that zero is bought with the
303
+ // label — "Co… 0" instead of "Comments".
304
+ ...(id === 'comments' && comments.length > 0
305
+ ? { count: comments.length }
306
+ : {}),
307
+ ...(id === 'activity' && activity.length > 0
308
+ ? { count: activity.length }
309
+ : {}),
310
+ }))}
311
+ />
312
+ </div>
313
+
314
+ {/* EVERYTHING */}
315
+ <div
316
+ role="tabpanel"
317
+ id={panelDomId('everything')}
318
+ // Named by `aria-label`, not `aria-labelledby` back at the tab: two
319
+ // of the three tabs carry a count badge, so borrowing the tab's text
320
+ // would name the panel "Comments 3".
321
+ aria-label={TAB_LABEL.everything}
322
+ tabIndex={0}
323
+ hidden={active !== 'everything'}
324
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto"
325
+ style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
326
+ >
327
+ {merged.length === 0 ? (
328
+ <EmptyLine>{empty('everything')}</EmptyLine>
329
+ ) : (
330
+ <ol className="flex list-none flex-col" style={{ gap: 'var(--space-3)', margin: 0, padding: 0 }}>
331
+ {merged.map((item) =>
332
+ item.kind === 'comment' ? (
333
+ <li key={`comment-${item.comment.id}`}>
334
+ <FeedLine
335
+ // WITHHELD on a tombstone, matching the thread's treatment
336
+ // exactly. `CommentItem` replaces the byline with the
337
+ // tombstone label and drops the avatar to `?`; printing the
338
+ // name here would mean the same comment answers "who wrote
339
+ // this" differently one tab apart.
340
+ //
341
+ // It matters more since FU-0032 than it did before. A
342
+ // neutral "Comment deleted" beside a name was a deletion
343
+ // nobody had to explain; "Comment removed by an admin"
344
+ // beside a name is a per-author public record of who got
345
+ // moderated, readable by everyone in the workspace.
346
+ author={
347
+ item.comment.deletedAt === null || item.comment.deletedAt === undefined
348
+ ? (item.comment.author ?? null)
349
+ : null
350
+ }
351
+ at={item.at}
352
+ now={clock}
353
+ locale={locale}
354
+ body={
355
+ item.comment.deletedAt === null || item.comment.deletedAt === undefined ? (
356
+ <CommentBody bodyMd={item.comment.bodyMd} />
357
+ ) : (
358
+ // The feed's register, not the thread's: a line in a
359
+ // sentence rather than a bracketed placeholder. The
360
+ // DISTINCTION is the same one, and it has to be, or a
361
+ // reader gets one answer on Everything and another on
362
+ // Comments about the same comment (FU-0032).
363
+ <span style={{ color: 'rgb(var(--text-tertiary))' }}>
364
+ {item.comment.moderated === true
365
+ ? 'Comment removed by an admin'
366
+ : 'Comment deleted'}
367
+ </span>
368
+ )
369
+ }
370
+ lead="commented"
371
+ />
372
+ </li>
373
+ ) : (
374
+ <li key={`activity-${item.entry.id}`}>
375
+ <ActivityLine entry={item.entry} now={clock} locale={locale} />
376
+ </li>
377
+ ),
378
+ )}
379
+ </ol>
380
+ )}
381
+ </div>
382
+
383
+ {/* COMMENTS — omitted entirely without a backend, not rendered empty. */}
384
+ {!withComments ? null : (
385
+ <div
386
+ role="tabpanel"
387
+ id={panelDomId('comments')}
388
+ // Named by `aria-label`, not `aria-labelledby` back at the tab: two
389
+ // of the three tabs carry a count badge, so borrowing the tab's text
390
+ // would name the panel "Comments 3".
391
+ aria-label={TAB_LABEL.comments}
392
+ tabIndex={0}
393
+ hidden={active !== 'comments'}
394
+ className="flex min-h-0 flex-1 flex-col"
395
+ style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
396
+ >
397
+ <Thread
398
+ variant="bare"
399
+ rootComposer={false}
400
+ comments={comments}
401
+ emptyState={empty('comments')}
402
+ {...(now === undefined ? {} : { now })}
403
+ {...(locale === undefined ? {} : { locale })}
404
+ {...(onReply === undefined
405
+ ? {}
406
+ : {
407
+ onSubmit: (body: string, parentId?: string) => {
408
+ // Root submissions come from the pinned composer below, so
409
+ // anything arriving here without a parent is dropped rather
410
+ // than posted twice.
411
+ if (parentId !== undefined) onReply(body, parentId)
412
+ },
413
+ })}
414
+ {...(onReact === undefined ? {} : { onReact })}
415
+ {...(onEdit === undefined ? {} : { onEdit })}
416
+ {...(onDelete === undefined ? {} : { onDelete })}
417
+ {...(onRetry === undefined ? {} : { onRetry })}
418
+ {...(onHideLinkPreview === undefined ? {} : { onHideLinkPreview })}
419
+ />
420
+ </div>
421
+ )}
422
+
423
+ {/* ACTIVITY */}
424
+ <div
425
+ role="tabpanel"
426
+ id={panelDomId('activity')}
427
+ // Named by `aria-label`, not `aria-labelledby` back at the tab: two
428
+ // of the three tabs carry a count badge, so borrowing the tab's text
429
+ // would name the panel "Comments 3".
430
+ aria-label={TAB_LABEL.activity}
431
+ tabIndex={0}
432
+ hidden={active !== 'activity'}
433
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto"
434
+ style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
435
+ >
436
+ {sortedActivity.length === 0 ? (
437
+ <EmptyLine>{empty('activity')}</EmptyLine>
438
+ ) : (
439
+ <ol className="flex list-none flex-col" style={{ gap: 'var(--space-3)', margin: 0, padding: 0 }}>
440
+ {sortedActivity.map((entry) => (
441
+ <li key={entry.id}>
442
+ <ActivityLine entry={entry} now={clock} locale={locale} />
443
+ </li>
444
+ ))}
445
+ </ol>
446
+ )}
447
+ </div>
448
+
449
+ {/* THE COMPOSER — one, mounted once, below every panel. Absent when
450
+ there is nowhere to post to; a composer that discards what you typed
451
+ is worse than no composer. */}
452
+ {onSubmit === undefined ? null : (
453
+ <div
454
+ data-slot="activity-composer"
455
+ className="shrink-0 border-t"
456
+ style={{
457
+ borderColor: 'rgb(var(--border))',
458
+ paddingInline: 'var(--space-4)',
459
+ paddingBlock: 'var(--space-3)',
460
+ }}
461
+ >
462
+ <ThreadComposer
463
+ label={composerLabel}
464
+ placeholder={composerPlaceholder}
465
+ submitLabel={submitLabel}
466
+ value={draft}
467
+ onChange={onDraftChange ?? (() => {})}
468
+ onSubmit={() => {
469
+ const body = draft.trim()
470
+ if (body === '') return
471
+ onSubmit(body)
472
+ }}
473
+ sending={sending}
474
+ {...(composerAvatar === undefined ? {} : { avatar: composerAvatar })}
475
+ {...(composerMaxLength === undefined ? {} : { maxLength: composerMaxLength })}
476
+ />
477
+ </div>
478
+ )}
479
+ </section>
480
+ )
481
+ }
482
+
483
+ type FeedEntry =
484
+ | { kind: 'comment'; at: number; comment: ThreadComment }
485
+ | { kind: 'activity'; at: number; entry: ActivityEntry }
486
+
487
+ function EmptyLine({ children }: { children: ReactNode }) {
488
+ return (
489
+ <p
490
+ className="text-[12.5px] leading-[1.5]"
491
+ style={{ margin: 0, color: 'rgb(var(--text-tertiary))' }}
492
+ >
493
+ {children}
494
+ </p>
495
+ )
496
+ }
497
+
498
+ /** Never fabricate a name (thread D5) — fall back through what exists. */
499
+ function actorName(actor: FieldPerson | ThreadAuthor | null | undefined): string {
500
+ if (actor === null || actor === undefined) return 'Someone'
501
+ if ('name' in actor && typeof actor.name === 'string' && actor.name.length > 0) {
502
+ return actor.name
503
+ }
504
+ if ('email' in actor && typeof actor.email === 'string' && actor.email.length > 0) {
505
+ return actor.email
506
+ }
507
+ return 'Someone'
508
+ }
509
+
510
+ function FeedLine({
511
+ author,
512
+ at,
513
+ now,
514
+ locale,
515
+ lead,
516
+ body,
517
+ }: {
518
+ author: FieldPerson | ThreadAuthor | null
519
+ at: number
520
+ now: number
521
+ locale?: string | undefined
522
+ lead: ReactNode
523
+ body?: ReactNode | undefined
524
+ }) {
525
+ const tint = asAvatarTint(author?.tint)
526
+ const name = actorName(author)
527
+ return (
528
+ <div className="flex items-start" style={{ gap: 'var(--space-2)' }}>
529
+ <Avatar
530
+ name={author?.name ?? null}
531
+ size="sm"
532
+ src={author?.avatarUrl ?? null}
533
+ {...(author?.id === undefined || author.id === null ? {} : { seed: author.id })}
534
+ {...(tint === undefined ? {} : { tint })}
535
+ />
536
+ <div className="min-w-0 flex-1">
537
+ <p className="text-[12.5px] leading-[1.5]" style={{ margin: 0, color: 'rgb(var(--text-secondary))' }}>
538
+ <span className="font-semibold" style={{ color: 'rgb(var(--foreground))' }}>
539
+ {name}
540
+ </span>{' '}
541
+ {lead}{' '}
542
+ <RelativeTime value={at} now={now} locale={locale} />
543
+ </p>
544
+ {body === undefined ? null : (
545
+ <div className="text-[12.5px] leading-[1.55]" style={{ marginBlockStart: 'var(--space-1)' }}>
546
+ {body}
547
+ </div>
548
+ )}
549
+ </div>
550
+ </div>
551
+ )
552
+ }
553
+
554
+ /**
555
+ * `‹who› moved from ‹A› to ‹B› ‹when›` (§5), and the three degenerate cases
556
+ * the sentence has to cover — set, cleared, and a change the server described
557
+ * itself. Field names arrive user-facing ("Status"), never as schema words:
558
+ * §7's copy rule is a host responsibility this component cannot enforce, but
559
+ * it never invents one either.
560
+ */
561
+ function ActivityLine({
562
+ entry,
563
+ now,
564
+ locale,
565
+ }: {
566
+ entry: ActivityEntry
567
+ now: number
568
+ locale?: string | undefined
569
+ }) {
570
+ const lead = ((): ReactNode => {
571
+ if (entry.summary !== undefined) return entry.summary
572
+ const { field, from, to } = entry
573
+ if (field === undefined) return 'updated this item'
574
+ const has = (value: string | null | undefined): value is string =>
575
+ typeof value === 'string' && value.length > 0
576
+ if (has(from) && has(to)) {
577
+ return (
578
+ <>
579
+ changed {field} from <Value>{from}</Value> to <Value>{to}</Value>
580
+ </>
581
+ )
582
+ }
583
+ if (has(to)) {
584
+ return (
585
+ <>
586
+ set {field} to <Value>{to}</Value>
587
+ </>
588
+ )
589
+ }
590
+ if (has(from)) return <>cleared {field}</>
591
+ return <>updated {field}</>
592
+ })()
593
+
594
+ return (
595
+ <FeedLine
596
+ author={entry.actor ?? null}
597
+ at={entry.at}
598
+ now={now}
599
+ locale={locale}
600
+ lead={lead}
601
+ />
602
+ )
603
+ }
604
+
605
+ function Value({ children }: { children: ReactNode }) {
606
+ return (
607
+ <span className="font-medium" style={{ color: 'rgb(var(--foreground))' }}>
608
+ {children}
609
+ </span>
610
+ )
611
+ }