@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,372 @@
1
+ /**
2
+ * The depth rail — a CONNECTOR, not a margin decoration.
3
+ *
4
+ * WHAT THE PREVIOUS PASS GOT WRONG
5
+ *
6
+ * It drew a hairline at the inline-start edge of a nesting level. That line was
7
+ * anchored to nothing: it started at an arbitrary x, ended at an arbitrary y,
8
+ * and touched neither the comment it came from nor the comment it led to. Read
9
+ * as a floating margin rule, which is exactly what it was. It also shipped a
10
+ * "Collapse" text button at the bottom of every level, so a ten-deep chain
11
+ * ended in ten stacked Collapse links. Both are gone.
12
+ *
13
+ * THE FIGURE, and it is one figure repeated verbatim at every depth:
14
+ *
15
+ * ( A ) Marcus Webb · 4m ← the avatar, and the byline beside it
16
+ * | body text
17
+ * | reactions Reply ← the action row
18
+ * (-) ← the collapse circle, LEVEL with it
19
+ * |
20
+ * '--( A ) Dana Ortiz · 3m ← elbow, terminating ON the child avatar
21
+ *
22
+ * The vertical runs down the PARENT AVATAR'S CENTRE LINE. It ends in a circular
23
+ * collapse control sitting on that line, vertically level with the parent's
24
+ * action row — the last row of the parent's own content. Below the circle it
25
+ * continues and turns a rounded elbow into the child's avatar. Parent and child
26
+ * are physically joined; the line is never ornamental.
27
+ *
28
+ * EVERY NUMBER IS DERIVED FROM THE AVATAR SIZE AND ONE INDENT TOKEN, so nothing
29
+ * can drift. With `A` = `--thread-avatar` and `I` = `--thread-indent`:
30
+ *
31
+ * rail x = A / 2 the parent avatar's centre line
32
+ * elbow run = I − A/2 horizontal distance to the child
33
+ * elbow radius = I − A/2 identical, so it is a PURE quarter
34
+ * arc with no straight horizontal tail
35
+ * elbow height = rowGap + A/2 the child avatar's vertical centre
36
+ *
37
+ * Radius equalling the run is not a coincidence to preserve by hand: it falls
38
+ * out of terminating a quarter turn exactly on the child's avatar edge, and it
39
+ * holds for any A and any I. Change `--thread-avatar` and the whole figure moves
40
+ * correctly.
41
+ *
42
+ * CONTINUITY ACROSS SIBLINGS is why the connector belongs to the CHILD rather
43
+ * than to the level. Each child draws the segment that spans its OWN box: an
44
+ * elbow from its top edge to its avatar, plus — when it is not the last sibling
45
+ * — a trunk from that avatar down to its bottom edge. Sibling boxes are
46
+ * flush (all vertical separation is a child's own top padding, never a
47
+ * container gap), so those segments concatenate into one unbroken line that
48
+ * stops precisely at the LAST reply's avatar and never dangles past it. No
49
+ * element has to know any other element's height.
50
+ *
51
+ * THE 24x24 FLOOR. The visible mark is a 1px line and a quiet circle; the
52
+ * TARGET is the whole avatar-width column beneath the avatar — `--thread-avatar`
53
+ * (32) wide and, at minimum, a circle tall. The transparent padding does the
54
+ * work, and the focus ring lands on the circle, which is a real visible object,
55
+ * rather than around a 600px column of nothing.
56
+ */
57
+
58
+ import type { ReactNode } from 'react'
59
+ import { ChevronRight, Minus, Plus } from 'lucide-react'
60
+ import { Avatar, AvatarGroup, asAvatarTint } from '../avatar'
61
+ import { cn } from '../lib/utils'
62
+ import { RelativeTime } from './relative-time'
63
+ import type { CommentNode, ThreadAuthor } from './types'
64
+
65
+ /** Levels deeper than this stop ADDING indent. Nesting itself stays uncapped. */
66
+ export const DEFAULT_INDENT_CLAMP_DEPTH = 6
67
+
68
+ export interface ThreadRailProps {
69
+ readonly collapsed: boolean
70
+ /** Accessible name. The stem is a hairline, so this is most of what a reader gets. */
71
+ readonly label: string
72
+ readonly onToggle: () => void
73
+ }
74
+
75
+ /**
76
+ * The stem and the circle: ONE control, in the avatar column, under the avatar.
77
+ *
78
+ * One per comment that has replies — never one per level plus a footer link,
79
+ * which is how the previous pass ended up with ten stacked "Collapse"s at the
80
+ * bottom of a deep chain. The circle is the affordance you aim at; the stem is
81
+ * the same button, so the whole column beneath the avatar is live.
82
+ *
83
+ * It is `flex-1` inside a column that stretches to the comment's content
84
+ * height, which is what pins the circle level with the action row without
85
+ * anything measuring anything.
86
+ */
87
+ export function ThreadRail({ collapsed, label, onToggle }: ThreadRailProps) {
88
+ return (
89
+ <button
90
+ type="button"
91
+ onClick={onToggle}
92
+ aria-label={label}
93
+ aria-expanded={!collapsed}
94
+ title={label}
95
+ className={cn(
96
+ 'group/rail flex w-full flex-1 cursor-pointer flex-col items-center border-0 bg-transparent p-0',
97
+ 'focus-visible:outline-none',
98
+ )}
99
+ style={{
100
+ minBlockSize: 'var(--thread-circle)',
101
+ // Centres the circle ON the engagement row rather than flush with its
102
+ // bottom edge: half the difference between that row's floor
103
+ // (`--thread-action-h`) and the circle lifts it onto the row's centre
104
+ // line. Derived, so it tracks either token — it followed the row up to
105
+ // 32 when emoji reactions landed and back down to 24 when the owner
106
+ // asked for ~30% smaller, with no change here.
107
+ paddingBlockEnd: 'calc((var(--thread-action-h) - var(--thread-circle)) / 2)',
108
+ }}
109
+ >
110
+ {/* The stem. `flex-1` so it eats whatever height the content has, which
111
+ is what puts the circle on the action row's line. `--space-1` of clear
112
+ air under the avatar so the line reads as leaving it, not welded to
113
+ it. */}
114
+ <span
115
+ className={cn(
116
+ 'w-px flex-1 bg-[rgb(var(--border))]',
117
+ 'transition-[background-color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
118
+ 'motion-reduce:transition-none',
119
+ 'group-hover/rail:bg-[rgb(var(--border-strong))]',
120
+ 'group-focus-visible/rail:bg-[rgb(var(--accent))]',
121
+ )}
122
+ style={{ marginBlockStart: 'var(--space-1)' }}
123
+ />
124
+ <span
125
+ className={cn(
126
+ 'flex shrink-0 items-center justify-center rounded-full border',
127
+ 'border-[rgb(var(--border))] bg-[rgb(var(--surface-card))] text-[rgb(var(--text-tertiary))]',
128
+ 'transition-[color,border-color,background-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
129
+ 'motion-reduce:transition-none',
130
+ 'group-hover/rail:border-[rgb(var(--border-strong))] group-hover/rail:text-[rgb(var(--foreground))]',
131
+ 'group-focus-visible/rail:border-[rgb(var(--accent))] group-focus-visible/rail:text-[rgb(var(--accent-ink))]',
132
+ 'group-focus-visible/rail:[box-shadow:var(--ring-focus)]',
133
+ 'group-active/rail:scale-[0.96] motion-reduce:group-active/rail:scale-100',
134
+ )}
135
+ style={{
136
+ inlineSize: 'var(--thread-circle)',
137
+ blockSize: 'var(--thread-circle)',
138
+ }}
139
+ >
140
+ {/* Outline glyph, one asset, recoloured by state. The stroke matches the
141
+ optical weight of the 14/600 name it sits under. */}
142
+ {collapsed ? (
143
+ <Plus size={10} strokeWidth={2} aria-hidden="true" />
144
+ ) : (
145
+ <Minus size={10} strokeWidth={2} aria-hidden="true" />
146
+ )}
147
+ </span>
148
+ </button>
149
+ )
150
+ }
151
+
152
+ export interface ThreadConnectorProps {
153
+ /**
154
+ * Past `indentClampDepth` a level adds no indent, so there is no gutter to
155
+ * curve in. The elbow's horizontal run collapses to zero and the trunk drops
156
+ * straight onto the TOP edge of the child's avatar, which sits directly under
157
+ * the parent's rail. Still connected, still the same figure — it just stops
158
+ * stepping sideways, which is the whole point of the clamp.
159
+ */
160
+ readonly clamped: boolean
161
+ /** The last sibling terminates the line; every other one carries it onward. */
162
+ readonly isLast: boolean
163
+ }
164
+
165
+ /**
166
+ * The segment of the connector that belongs to ONE child.
167
+ *
168
+ * Purely decorative — the rail button above it is the control — so it is hidden
169
+ * from the accessibility tree entirely.
170
+ */
171
+ export function ThreadConnector({ clamped, isLast }: ThreadConnectorProps) {
172
+ const run = clamped ? '0' : 'var(--thread-elbow-run)'
173
+ return (
174
+ <>
175
+ <span
176
+ aria-hidden="true"
177
+ className="pointer-events-none absolute"
178
+ style={{
179
+ insetBlockStart: '0',
180
+ insetInlineStart: 'var(--thread-rail-x)',
181
+ inlineSize: run,
182
+ // Down to the child avatar's centre when there is an elbow to land
183
+ // there; only as far as its top edge when the run is zero, so the
184
+ // avatar itself terminates the line instead of being crossed by it.
185
+ blockSize: clamped ? 'var(--thread-row-gap)' : 'var(--thread-avatar-center)',
186
+ borderInlineStartWidth: '1px',
187
+ borderBlockEndWidth: clamped ? '0' : '1px',
188
+ borderStyle: 'solid',
189
+ borderColor: 'rgb(var(--border))',
190
+ borderEndStartRadius: run,
191
+ }}
192
+ />
193
+ {isLast || clamped ? null : (
194
+ // Carries the line from this child's avatar down to its bottom edge,
195
+ // where the next sibling's segment picks it up. Flush, so the join is
196
+ // invisible.
197
+ //
198
+ // Never past the clamp: with no indent there is no gutter for it to run
199
+ // in, and it would strike straight down through the avatar and the body
200
+ // text. Clamped siblings each get their own short drop instead.
201
+ <span
202
+ aria-hidden="true"
203
+ className="pointer-events-none absolute w-px"
204
+ style={{
205
+ insetBlockStart: clamped
206
+ ? 'var(--thread-row-gap)'
207
+ : 'var(--thread-avatar-center)',
208
+ insetBlockEnd: '0',
209
+ insetInlineStart: 'var(--thread-rail-x)',
210
+ background: 'rgb(var(--border))',
211
+ }}
212
+ />
213
+ )}
214
+ </>
215
+ )
216
+ }
217
+
218
+ /**
219
+ * A bare vertical continuation, for the gap an open reply composer opens up
220
+ * between the collapse circle and the first child. Without it the trunk breaks
221
+ * for exactly as long as someone is typing.
222
+ */
223
+ export function ThreadTrunkSegment() {
224
+ return (
225
+ <span
226
+ aria-hidden="true"
227
+ className="pointer-events-none absolute inset-y-0 w-px"
228
+ style={{
229
+ insetInlineStart: 'var(--thread-rail-x)',
230
+ background: 'rgb(var(--border))',
231
+ }}
232
+ />
233
+ )
234
+ }
235
+
236
+ /** An inline icon that centres on the FIRST line of the text beside it. */
237
+ export function LineIcon({ children }: { readonly children: ReactNode }) {
238
+ // `1lh` is the line box, so the icon tracks the text's own leading rather
239
+ // than the block's centre — which is what keeps it on line one when the text
240
+ // beside it wraps to three.
241
+ return <span className="flex h-[1lh] shrink-0 items-center">{children}</span>
242
+ }
243
+
244
+ /** Distinct authors in a subtree, first-seen order. Presentation, not data. */
245
+ function subtreeAuthors(node: CommentNode): readonly ThreadAuthor[] {
246
+ const seen = new Set<string>()
247
+ const out: ThreadAuthor[] = []
248
+ const walk = (current: CommentNode): void => {
249
+ for (const reply of current.replies) {
250
+ const author = reply.author
251
+ if (author != null) {
252
+ // No id (it can be null once the user row is gone) means no identity to
253
+ // dedupe on, so the name stands in and an unnamed one is skipped
254
+ // rather than collapsing every anonymous reply into one face.
255
+ const key = author.id ?? author.name ?? author.email ?? null
256
+ if (key !== null && !seen.has(key)) {
257
+ seen.add(key)
258
+ out.push(author)
259
+ }
260
+ }
261
+ walk(reply)
262
+ }
263
+ }
264
+ walk(node)
265
+ return out
266
+ }
267
+
268
+ /** The most recent timestamp anywhere in the subtree. */
269
+ function latestReplyAt(node: CommentNode): number {
270
+ let latest = 0
271
+ const walk = (current: CommentNode): void => {
272
+ for (const reply of current.replies) {
273
+ if (reply.createdAt > latest) latest = reply.createdAt
274
+ walk(reply)
275
+ }
276
+ }
277
+ walk(node)
278
+ return latest
279
+ }
280
+
281
+ export interface CollapsedRepliesProps {
282
+ readonly node: CommentNode
283
+ readonly count: number
284
+ readonly now: number
285
+ readonly locale?: string | undefined
286
+ /** The parent's display name, for the accessible name. */
287
+ readonly parentLabel: string
288
+ readonly onExpand: () => void
289
+ }
290
+
291
+ /**
292
+ * A collapsed subtree is a SUMMARY, not a count.
293
+ *
294
+ * "8 replies" tells you how much you are not reading. Faces plus a recency tell
295
+ * you whether it is worth opening — who is in there and whether anything
296
+ * happened since you last looked. That is the actual question.
297
+ *
298
+ * It deliberately has NO connector. Collapsed, the rail terminates at the
299
+ * circle exactly as the reference's does, and a line running on to a chip that
300
+ * is itself the "there is more here" signal would be saying the same thing
301
+ * twice.
302
+ */
303
+ export function CollapsedReplies({
304
+ node,
305
+ count,
306
+ now,
307
+ locale,
308
+ parentLabel,
309
+ onExpand,
310
+ }: CollapsedRepliesProps) {
311
+ const authors = subtreeAuthors(node)
312
+ const latest = latestReplyAt(node)
313
+ const word = count === 1 ? 'reply' : 'replies'
314
+
315
+ return (
316
+ <button
317
+ type="button"
318
+ onClick={onExpand}
319
+ aria-expanded={false}
320
+ aria-label={`Show ${String(count)} ${word} to ${parentLabel}`}
321
+ className={cn(
322
+ 'inline-flex items-center gap-[var(--space-2)] border px-[var(--space-2)] py-[var(--space-1)] text-[12px]',
323
+ 'border-[rgb(var(--border))] bg-[rgb(var(--surface-inset))] text-[rgb(var(--text-secondary))]',
324
+ 'transition-[background-color,border-color,color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
325
+ 'motion-reduce:transition-none',
326
+ 'hover:border-[rgb(var(--border-strong))] hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))]',
327
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
328
+ )}
329
+ style={{ minBlockSize: 'var(--space-6)', borderRadius: 'var(--radius-sm)' }}
330
+ >
331
+ <LineIcon>
332
+ <ChevronRight size={10} strokeWidth={1.75} aria-hidden="true" />
333
+ </LineIcon>
334
+ {authors.length === 0 ? null : (
335
+ <AvatarGroup
336
+ max={3}
337
+ total={authors.length}
338
+ size="sm"
339
+ className="ds-avatar-on-inset"
340
+ >
341
+ {authors.map((author, index) => {
342
+ const tint = asAvatarTint(author.tint)
343
+ return (
344
+ <Avatar
345
+ key={author.id ?? author.email ?? `${author.name ?? 'unknown'}-${String(index)}`}
346
+ decorative
347
+ name={author.name ?? author.email ?? null}
348
+ size="sm"
349
+ ring
350
+ src={author.avatarUrl ?? null}
351
+ {...(tint === undefined ? {} : { tint })}
352
+ {...(author.id == null ? {} : { seed: author.id })}
353
+ />
354
+ )
355
+ })}
356
+ </AvatarGroup>
357
+ )}
358
+ <span className="font-semibold tabular-nums">
359
+ {count} {word}
360
+ </span>
361
+ {latest === 0 ? null : (
362
+ <span
363
+ className="flex items-center gap-[var(--space-1)]"
364
+ style={{ color: 'rgb(var(--text-tertiary))' }}
365
+ >
366
+ ·
367
+ <RelativeTime value={latest} now={now} {...(locale === undefined ? {} : { locale })} />
368
+ </span>
369
+ )}
370
+ </button>
371
+ )
372
+ }