@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,507 @@
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
+ * ## Everything is read-only
30
+ *
31
+ * The merged feed interleaves both by time so you can see what happened in
32
+ * order. Reacting, replying and collapsing live in Comments, where the thread
33
+ * is drawn in full — a feed that is half interactive and half not is worse
34
+ * than one that is honestly neither.
35
+ */
36
+
37
+ import { useId, useMemo, useState, type ReactNode } from 'react'
38
+ import { Tabs } from '../tabs'
39
+ import { Avatar, asAvatarTint } from '../avatar'
40
+ import { cn } from '../lib/utils'
41
+ import { CommentBody } from '../thread/comment-body'
42
+ import { RelativeTime } from '../thread/relative-time'
43
+ import { Thread } from '../thread/thread'
44
+ import { ThreadComposer } from '../thread/composer'
45
+ import { useNowTick } from '../thread/use-now'
46
+ import type { ThreadAuthor, ThreadComment } from '../thread/types'
47
+ import type { ActivityEntry, FieldPerson } from './types'
48
+
49
+ export type ActivityTab = 'everything' | 'comments' | 'activity'
50
+
51
+ const TAB_ORDER: readonly ActivityTab[] = ['everything', 'comments', 'activity']
52
+
53
+ /**
54
+ * The tabs a given pane actually shows.
55
+ *
56
+ * A host with no comment backend gets Everything and Activity, and Everything
57
+ * then means "the activity feed" rather than a merge of two sources — which is
58
+ * still true, just shorter. Dropping the tab rather than showing an empty
59
+ * Comments panel is the honest version: a tab that can never have content is a
60
+ * promise the product does not keep.
61
+ */
62
+ function tabsFor(withComments: boolean): readonly ActivityTab[] {
63
+ return withComments ? TAB_ORDER : TAB_ORDER.filter((id) => id !== 'comments')
64
+ }
65
+
66
+ const TAB_LABEL: Readonly<Record<ActivityTab, string>> = {
67
+ everything: 'Everything',
68
+ comments: 'Comments',
69
+ activity: 'Activity',
70
+ }
71
+
72
+ /** In the interface's own voice: what this is, and what will appear here. */
73
+ const DEFAULT_EMPTY: Readonly<Record<ActivityTab, string>> = {
74
+ everything: 'Nothing here yet. Comments and changes to this record show up together.',
75
+ comments: 'No comments yet. Start the conversation.',
76
+ activity: 'No activity yet. Every change to this record is recorded here.',
77
+ }
78
+
79
+ export interface ActivityPaneProps {
80
+ /** Controlled tab. Omit for internal state. */
81
+ tab?: ActivityTab | undefined
82
+ defaultTab?: ActivityTab | undefined
83
+ onTabChange?: ((next: ActivityTab) => void) | undefined
84
+
85
+ /**
86
+ * Already shaped. The pane fetches nothing.
87
+ *
88
+ * Omit it — along with the composer trio below — for a host that has no
89
+ * comment backend at all. The Comments tab is then not rendered, rather than
90
+ * rendered empty. See `tabsFor`.
91
+ */
92
+ comments?: readonly ThreadComment[] | undefined
93
+ /** Item history, in any order — the pane sorts by time. */
94
+ activity: readonly ActivityEntry[]
95
+
96
+ /**
97
+ * The composer's draft, controlled by the host so it survives a remount.
98
+ *
99
+ * The trio is all-or-nothing: supply `onSubmit` and you get a composer,
100
+ * omit it and the pane draws none. `onSubmit` is the one that decides,
101
+ * because a composer with nowhere to post is worse than no composer.
102
+ */
103
+ draft?: string | undefined
104
+ onDraftChange?: ((next: string) => void) | undefined
105
+ /** A new root comment. Omitted, the pane renders no composer. */
106
+ onSubmit?: ((body: string) => void) | undefined
107
+ /** A reply. Omitted, the thread offers no reply control at all. */
108
+ onReply?: ((body: string, parentId: string) => void) | undefined
109
+ sending?: boolean | undefined
110
+ composerAvatar?: ReactNode | undefined
111
+ composerPlaceholder?: string | undefined
112
+ composerLabel?: string | undefined
113
+ composerMaxLength?: number | undefined
114
+ submitLabel?: string | undefined
115
+
116
+ /** Frozen clock, epoch ms. Omit and the shared 60s ticker drives the times. */
117
+ now?: number | undefined
118
+ locale?: string | undefined
119
+ /** Override any tab's empty state. */
120
+ emptyStates?: Partial<Record<ActivityTab, ReactNode>> | undefined
121
+ className?: string | undefined
122
+ }
123
+
124
+ export function ActivityPane({
125
+ tab,
126
+ defaultTab = 'everything',
127
+ onTabChange,
128
+ comments = [],
129
+ activity,
130
+ draft = '',
131
+ onDraftChange,
132
+ onSubmit,
133
+ onReply,
134
+ sending = false,
135
+ composerAvatar,
136
+ composerPlaceholder = 'Add a comment…',
137
+ composerLabel = 'Add a comment',
138
+ composerMaxLength,
139
+ submitLabel = 'Comment',
140
+ now,
141
+ locale,
142
+ emptyStates,
143
+ className,
144
+ }: ActivityPaneProps) {
145
+ const reactId = useId()
146
+ const [internalTab, setInternalTab] = useState<ActivityTab>(defaultTab)
147
+ const withComments = onSubmit !== undefined
148
+ const tabs = tabsFor(withComments)
149
+ const requested = tab ?? internalTab
150
+ // A host that drops Comments while it is the active tab (or that defaults to
151
+ // it) must not land on a panel that is not rendered.
152
+ const active = tabs.includes(requested) ? requested : (tabs[0] as ActivityTab)
153
+ const ticked = useNowTick(now === undefined)
154
+ const clock = now ?? ticked
155
+
156
+ const sortedActivity = useMemo(
157
+ () => [...activity].sort((a, b) => a.at - b.at),
158
+ [activity],
159
+ )
160
+
161
+ // Oldest first, so the newest sits nearest the composer.
162
+ const merged = useMemo(() => {
163
+ const entries: FeedEntry[] = [
164
+ ...comments.map((comment) => ({
165
+ kind: 'comment' as const,
166
+ at: comment.createdAt,
167
+ comment,
168
+ })),
169
+ ...sortedActivity.map((entry) => ({
170
+ kind: 'activity' as const,
171
+ at: entry.at,
172
+ entry,
173
+ })),
174
+ ]
175
+ return entries.sort((a, b) => a.at - b.at)
176
+ }, [comments, sortedActivity])
177
+
178
+ const empty = (id: ActivityTab): ReactNode => emptyStates?.[id] ?? DEFAULT_EMPTY[id]
179
+
180
+ // Tab -> panel wiring. The strip alone was a tablist whose tabs
181
+ // controlled nothing; each tab now points `aria-controls` at the panel it
182
+ // shows, and each panel is focusable so keyboard users can reach its
183
+ // scrolling content.
184
+ const idPrefix = `activity-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`
185
+ const panelDomId = (id: ActivityTab) => `${idPrefix}-panel-${id}`
186
+
187
+ return (
188
+ <section
189
+ data-slot="activity-pane"
190
+ aria-label="Activity"
191
+ className={cn('flex min-h-0 flex-1 flex-col', className)}
192
+ >
193
+ {/*
194
+ Matched to the field-list header on the other side of the split, so
195
+ the two panes' first rows sit on one line: same `--space-2` block
196
+ padding, same bottom border, and a control the same 36px tall (the
197
+ segmented track is 26 + 4 + 4 + 1 + 1, the same as that header's
198
+ SegmentedChoice). Change one and change the other.
199
+ */}
200
+ <div
201
+ className="shrink-0 border-b"
202
+ style={{
203
+ paddingInline: 'var(--space-4)',
204
+ paddingBlock: 'var(--space-2)',
205
+ borderColor: 'rgb(var(--border))',
206
+ }}
207
+ >
208
+ <Tabs
209
+ size="sm"
210
+ variant="segmented"
211
+ stretch
212
+ idPrefix={idPrefix}
213
+ activeTabId={active}
214
+ onTabChange={(next) => {
215
+ const id = next as ActivityTab
216
+ if (tab === undefined) setInternalTab(id)
217
+ onTabChange?.(id)
218
+ }}
219
+ tabs={tabs.map((id) => ({
220
+ id,
221
+ label: TAB_LABEL[id],
222
+ panelId: panelDomId(id),
223
+ // A badge only when there is something to count. "Comments 0"
224
+ // says nothing the empty state does not, and in a segmented
225
+ // strip at the pane's 280px floor that zero is bought with the
226
+ // label — "Co… 0" instead of "Comments".
227
+ ...(id === 'comments' && comments.length > 0
228
+ ? { count: comments.length }
229
+ : {}),
230
+ ...(id === 'activity' && activity.length > 0
231
+ ? { count: activity.length }
232
+ : {}),
233
+ }))}
234
+ />
235
+ </div>
236
+
237
+ {/* EVERYTHING */}
238
+ <div
239
+ role="tabpanel"
240
+ id={panelDomId('everything')}
241
+ // Named by `aria-label`, not `aria-labelledby` back at the tab: two
242
+ // of the three tabs carry a count badge, so borrowing the tab's text
243
+ // would name the panel "Comments 3".
244
+ aria-label={TAB_LABEL.everything}
245
+ tabIndex={0}
246
+ hidden={active !== 'everything'}
247
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto"
248
+ style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
249
+ >
250
+ {merged.length === 0 ? (
251
+ <EmptyLine>{empty('everything')}</EmptyLine>
252
+ ) : (
253
+ <ol className="flex list-none flex-col" style={{ gap: 'var(--space-3)', margin: 0, padding: 0 }}>
254
+ {merged.map((item) =>
255
+ item.kind === 'comment' ? (
256
+ <li key={`comment-${item.comment.id}`}>
257
+ <FeedLine
258
+ author={item.comment.author ?? null}
259
+ at={item.at}
260
+ now={clock}
261
+ locale={locale}
262
+ body={
263
+ item.comment.deletedAt === null || item.comment.deletedAt === undefined ? (
264
+ <CommentBody bodyMd={item.comment.bodyMd} />
265
+ ) : (
266
+ <span style={{ color: 'rgb(var(--text-tertiary))' }}>
267
+ Comment deleted
268
+ </span>
269
+ )
270
+ }
271
+ lead="commented"
272
+ />
273
+ </li>
274
+ ) : (
275
+ <li key={`activity-${item.entry.id}`}>
276
+ <ActivityLine entry={item.entry} now={clock} locale={locale} />
277
+ </li>
278
+ ),
279
+ )}
280
+ </ol>
281
+ )}
282
+ </div>
283
+
284
+ {/* COMMENTS — omitted entirely without a backend, not rendered empty. */}
285
+ {!withComments ? null : (
286
+ <div
287
+ role="tabpanel"
288
+ id={panelDomId('comments')}
289
+ // Named by `aria-label`, not `aria-labelledby` back at the tab: two
290
+ // of the three tabs carry a count badge, so borrowing the tab's text
291
+ // would name the panel "Comments 3".
292
+ aria-label={TAB_LABEL.comments}
293
+ tabIndex={0}
294
+ hidden={active !== 'comments'}
295
+ className="flex min-h-0 flex-1 flex-col"
296
+ style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
297
+ >
298
+ <Thread
299
+ variant="bare"
300
+ rootComposer={false}
301
+ comments={comments}
302
+ emptyState={empty('comments')}
303
+ {...(now === undefined ? {} : { now })}
304
+ {...(locale === undefined ? {} : { locale })}
305
+ {...(onReply === undefined
306
+ ? {}
307
+ : {
308
+ onSubmit: (body: string, parentId?: string) => {
309
+ // Root submissions come from the pinned composer below, so
310
+ // anything arriving here without a parent is dropped rather
311
+ // than posted twice.
312
+ if (parentId !== undefined) onReply(body, parentId)
313
+ },
314
+ })}
315
+ />
316
+ </div>
317
+ )}
318
+
319
+ {/* ACTIVITY */}
320
+ <div
321
+ role="tabpanel"
322
+ id={panelDomId('activity')}
323
+ // Named by `aria-label`, not `aria-labelledby` back at the tab: two
324
+ // of the three tabs carry a count badge, so borrowing the tab's text
325
+ // would name the panel "Comments 3".
326
+ aria-label={TAB_LABEL.activity}
327
+ tabIndex={0}
328
+ hidden={active !== 'activity'}
329
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto"
330
+ style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
331
+ >
332
+ {sortedActivity.length === 0 ? (
333
+ <EmptyLine>{empty('activity')}</EmptyLine>
334
+ ) : (
335
+ <ol className="flex list-none flex-col" style={{ gap: 'var(--space-3)', margin: 0, padding: 0 }}>
336
+ {sortedActivity.map((entry) => (
337
+ <li key={entry.id}>
338
+ <ActivityLine entry={entry} now={clock} locale={locale} />
339
+ </li>
340
+ ))}
341
+ </ol>
342
+ )}
343
+ </div>
344
+
345
+ {/* THE COMPOSER — one, mounted once, below every panel. Absent when
346
+ there is nowhere to post to; a composer that discards what you typed
347
+ is worse than no composer. */}
348
+ {onSubmit === undefined ? null : (
349
+ <div
350
+ data-slot="activity-composer"
351
+ className="shrink-0 border-t"
352
+ style={{
353
+ borderColor: 'rgb(var(--border))',
354
+ paddingInline: 'var(--space-4)',
355
+ paddingBlock: 'var(--space-3)',
356
+ }}
357
+ >
358
+ <ThreadComposer
359
+ label={composerLabel}
360
+ placeholder={composerPlaceholder}
361
+ submitLabel={submitLabel}
362
+ value={draft}
363
+ onChange={onDraftChange ?? (() => {})}
364
+ onSubmit={() => {
365
+ const body = draft.trim()
366
+ if (body === '') return
367
+ onSubmit(body)
368
+ }}
369
+ sending={sending}
370
+ {...(composerAvatar === undefined ? {} : { avatar: composerAvatar })}
371
+ {...(composerMaxLength === undefined ? {} : { maxLength: composerMaxLength })}
372
+ />
373
+ </div>
374
+ )}
375
+ </section>
376
+ )
377
+ }
378
+
379
+ type FeedEntry =
380
+ | { kind: 'comment'; at: number; comment: ThreadComment }
381
+ | { kind: 'activity'; at: number; entry: ActivityEntry }
382
+
383
+ function EmptyLine({ children }: { children: ReactNode }) {
384
+ return (
385
+ <p
386
+ className="text-[12.5px] leading-[1.5]"
387
+ style={{ margin: 0, color: 'rgb(var(--text-tertiary))' }}
388
+ >
389
+ {children}
390
+ </p>
391
+ )
392
+ }
393
+
394
+ /** Never fabricate a name (thread D5) — fall back through what exists. */
395
+ function actorName(actor: FieldPerson | ThreadAuthor | null | undefined): string {
396
+ if (actor === null || actor === undefined) return 'Someone'
397
+ if ('name' in actor && typeof actor.name === 'string' && actor.name.length > 0) {
398
+ return actor.name
399
+ }
400
+ if ('email' in actor && typeof actor.email === 'string' && actor.email.length > 0) {
401
+ return actor.email
402
+ }
403
+ return 'Someone'
404
+ }
405
+
406
+ function FeedLine({
407
+ author,
408
+ at,
409
+ now,
410
+ locale,
411
+ lead,
412
+ body,
413
+ }: {
414
+ author: FieldPerson | ThreadAuthor | null
415
+ at: number
416
+ now: number
417
+ locale?: string | undefined
418
+ lead: ReactNode
419
+ body?: ReactNode | undefined
420
+ }) {
421
+ const tint = asAvatarTint(author?.tint)
422
+ const name = actorName(author)
423
+ return (
424
+ <div className="flex items-start" style={{ gap: 'var(--space-2)' }}>
425
+ <Avatar
426
+ name={author?.name ?? null}
427
+ size="sm"
428
+ src={author?.avatarUrl ?? null}
429
+ {...(author?.id === undefined || author.id === null ? {} : { seed: author.id })}
430
+ {...(tint === undefined ? {} : { tint })}
431
+ />
432
+ <div className="min-w-0 flex-1">
433
+ <p className="text-[12.5px] leading-[1.5]" style={{ margin: 0, color: 'rgb(var(--text-secondary))' }}>
434
+ <span className="font-semibold" style={{ color: 'rgb(var(--foreground))' }}>
435
+ {name}
436
+ </span>{' '}
437
+ {lead}{' '}
438
+ <RelativeTime value={at} now={now} locale={locale} />
439
+ </p>
440
+ {body === undefined ? null : (
441
+ <div className="text-[12.5px] leading-[1.55]" style={{ marginBlockStart: 'var(--space-1)' }}>
442
+ {body}
443
+ </div>
444
+ )}
445
+ </div>
446
+ </div>
447
+ )
448
+ }
449
+
450
+ /**
451
+ * `‹who› moved from ‹A› to ‹B› ‹when›` (§5), and the three degenerate cases
452
+ * the sentence has to cover — set, cleared, and a change the server described
453
+ * itself. Field names arrive user-facing ("Status"), never as schema words:
454
+ * §7's copy rule is a host responsibility this component cannot enforce, but
455
+ * it never invents one either.
456
+ */
457
+ function ActivityLine({
458
+ entry,
459
+ now,
460
+ locale,
461
+ }: {
462
+ entry: ActivityEntry
463
+ now: number
464
+ locale?: string | undefined
465
+ }) {
466
+ const lead = ((): ReactNode => {
467
+ if (entry.summary !== undefined) return entry.summary
468
+ const { field, from, to } = entry
469
+ if (field === undefined) return 'updated this item'
470
+ const has = (value: string | null | undefined): value is string =>
471
+ typeof value === 'string' && value.length > 0
472
+ if (has(from) && has(to)) {
473
+ return (
474
+ <>
475
+ changed {field} from <Value>{from}</Value> to <Value>{to}</Value>
476
+ </>
477
+ )
478
+ }
479
+ if (has(to)) {
480
+ return (
481
+ <>
482
+ set {field} to <Value>{to}</Value>
483
+ </>
484
+ )
485
+ }
486
+ if (has(from)) return <>cleared {field}</>
487
+ return <>updated {field}</>
488
+ })()
489
+
490
+ return (
491
+ <FeedLine
492
+ author={entry.actor ?? null}
493
+ at={entry.at}
494
+ now={now}
495
+ locale={locale}
496
+ lead={lead}
497
+ />
498
+ )
499
+ }
500
+
501
+ function Value({ children }: { children: ReactNode }) {
502
+ return (
503
+ <span className="font-medium" style={{ color: 'rgb(var(--foreground))' }}>
504
+ {children}
505
+ </span>
506
+ )
507
+ }