@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,415 @@
1
+ /**
2
+ * Reactions and votes — the two engagement models, never both at once.
3
+ *
4
+ * ADR-147 D8. Reactions are ACKNOWLEDGEMENT, votes are RANKING. A surface
5
+ * picks exactly one: task threads take reactions, community and roadmap take
6
+ * votes. Mixing them asks a reader to decide whether a heart means "I agree"
7
+ * or "this should be higher", and the answer is different per person.
8
+ *
9
+ * REACTIONS ARE EMOJI. Owner instruction, 2026-09-06, overriding the "Lucide
10
+ * glyph, never emoji" half of D8: "more emojis rather than svgs (this is where
11
+ * emojis are allowed)". This is the ONE carve-out from CLAUDE.md §6 and the
12
+ * house no-emoji rule — code, comments, commit messages and UI copy are all
13
+ * still emoji-free. It is a defensible carve-out because a reaction IS the
14
+ * glyph: the emoji is the content, not decoration standing in for it.
15
+ *
16
+ * D8's other two reasons are untouched, and they were the load-bearing ones:
17
+ *
18
+ * • The set stays BOUNDED, and what we store is still the KEY (`up`,
19
+ * `heart`, …), never the emoji literal. An open picker would mean an
20
+ * unbounded string column that cannot be enumerated or aggregated.
21
+ * • Because the key is what persists, the glyph is a presentation detail we
22
+ * can change later without a migration. Storing the literal would weld the
23
+ * 2026 rasterisation into the database.
24
+ *
25
+ * TWO CONSEQUENCES OF EMOJI THAT NEEDED HANDLING:
26
+ *
27
+ * • They do not inherit `currentColor`, so the viewer-reacted state cannot be
28
+ * signalled by glyph colour the way a Lucide icon's would be. It is carried
29
+ * entirely by the chip — accent border, opaque step-up, accent-ink count —
30
+ * plus `aria-pressed`. Colour was never the only cue, which is why this
31
+ * survived the switch unchanged.
32
+ * • They need an explicit font stack. A host that sets a narrow
33
+ * `font-family` on an ancestor turns an emoji into tofu, and the thread
34
+ * primitive does not control its host's typography.
35
+ *
36
+ * The remaining correction to the port still holds: state is props in,
37
+ * callbacks out. The reference held vote state in local `useState`, so closing
38
+ * the dialog threw the vote away. Nothing in this file remembers anything.
39
+ *
40
+ * The selected state is an OPAQUE step-up, not an alpha tint (CLAUDE.md §2):
41
+ * an unreacted chip is recessed (`--surface-inset`), a reacted one steps up to
42
+ * `--surface-card` with an accent border and a shadow. Two translucent layers
43
+ * stacked is exactly how a selected element ends up rendering darker than the
44
+ * track it sits in.
45
+ */
46
+
47
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
48
+ import type { CSSProperties } from 'react'
49
+ import { ArrowBigDown, ArrowBigUp, SmilePlus } from 'lucide-react'
50
+ import { cn } from '../lib/utils'
51
+ import { THREAD_REACTION_KEYS, type ThreadReactionCount, type ThreadReactionKey, type ThreadVote } from './types'
52
+
53
+ /**
54
+ * The emoji font stack, as a value rather than a class so it can be applied to
55
+ * exactly the glyph and nothing else. `--font-emoji` is declared by `.ds-thread`
56
+ * so a host can substitute a bundled font; the literal fallback is what keeps
57
+ * these from rendering as tofu when `ReactionBar` is used outside a thread, or
58
+ * under an ancestor with a narrow `font-family`.
59
+ */
60
+ const EMOJI_FONT =
61
+ 'var(--font-emoji, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif)'
62
+
63
+ interface ReactionMeta {
64
+ /**
65
+ * The glyph. Chosen for CROSS-PLATFORM STABILITY, not expressiveness — every
66
+ * one below renders as recognisably the same object on Apple, Windows and
67
+ * Android, because a reaction whose meaning changes with the reader's OS is
68
+ * worse than no reaction. That rules out the expressive faces (the same
69
+ * codepoint is a smirk on one platform and a grimace on another) and the
70
+ * gesture emoji, whose skin-tone and orientation handling differ.
71
+ */
72
+ readonly emoji: string
73
+ /**
74
+ * Sentence case, names what it MEANS rather than what it looks like. This is
75
+ * the accessible name — a screen reader announcing "party popper" has told
76
+ * someone what the picture is and nothing about what pressing it says.
77
+ */
78
+ readonly label: string
79
+ }
80
+
81
+ export const REACTION_META: Readonly<Record<ThreadReactionKey, ReactionMeta>> = {
82
+ // Thumbs up: the single most consistently drawn emoji there is.
83
+ up: { emoji: '\u{1F44D}', label: 'Agree' },
84
+ // Red heart. Identical silhouette and hue everywhere; the VS16 is required or
85
+ // some platforms fall back to a monochrome text-presentation heart.
86
+ heart: { emoji: '\u{2764}\u{FE0F}', label: 'Love this' },
87
+ // Party popper: a cone plus confetti on every platform.
88
+ celebrate: { emoji: '\u{1F389}', label: 'Celebrate' },
89
+ // Eyes: two eyes looking aside, unambiguous everywhere.
90
+ eyes: { emoji: '\u{1F440}', label: 'Looking at this' },
91
+ // White heavy check mark — the GREEN-BOXED one. Deliberately not U+2714,
92
+ // which Apple draws grey-black and Windows draws green: the same codepoint
93
+ // reading "done" or "just a tick" by platform is exactly the failure mode.
94
+ check: { emoji: '\u{2705}', label: 'Done' },
95
+ // Red question mark ornament. Red on all three, unlike U+2753's text variant.
96
+ question: { emoji: '\u{2754}', label: 'Question' },
97
+ }
98
+
99
+ /**
100
+ * The glyph itself. `aria-hidden` because the button's `aria-label` is the
101
+ * accessible name — without this a reader would announce the emoji's Unicode
102
+ * name after the label, which is the "party popper" problem twice over.
103
+ */
104
+ function ReactionGlyph({ emoji }: { readonly emoji: string }) {
105
+ return (
106
+ <span
107
+ aria-hidden="true"
108
+ className="flex h-[1lh] shrink-0 items-center leading-none"
109
+ style={{ fontFamily: EMOJI_FONT, fontSize: '12px' }}
110
+ >
111
+ {emoji}
112
+ </span>
113
+ )
114
+ }
115
+
116
+ const CHIP_BASE = cn(
117
+ 'inline-flex items-center justify-center gap-[var(--space-1)] border px-[var(--space-2)] text-[12px] font-semibold tabular-nums',
118
+ 'transition-[background-color,border-color,color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
119
+ 'motion-reduce:transition-none',
120
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
121
+ 'active:scale-[0.96] motion-reduce:active:scale-100',
122
+ 'disabled:cursor-not-allowed disabled:opacity-60',
123
+ )
124
+
125
+ const CHIP_RESTING = cn(
126
+ 'border-[rgb(var(--border))] bg-[rgb(var(--surface-inset))] text-[rgb(var(--text-secondary))]',
127
+ 'hover:border-[rgb(var(--border-strong))] hover:bg-[rgb(var(--surface-hover))]',
128
+ )
129
+
130
+ const CHIP_ACTIVE = cn(
131
+ 'border-[rgb(var(--accent))] bg-[rgb(var(--surface-card))] text-[rgb(var(--accent-ink))]',
132
+ '[box-shadow:var(--shadow-sm)]',
133
+ )
134
+
135
+ /**
136
+ * The chip's box, `--thread-action-h` (24). It went 24 → 32 when emoji landed
137
+ * ("a tap target, not a footnote") and back to 24 on the owner's eyeball pass
138
+ * ("icons are still a little too big, maybe 30% smaller"). 24 is also the
139
+ * accessibility floor, so it cannot go lower without the transparent-padding
140
+ * trick the rail uses; the glyphs inside shrank instead. Declared as a style
141
+ * rather than a class because it reads a thread-scoped custom property, with a
142
+ * literal fallback for use outside `.ds-thread`.
143
+ */
144
+ const CHIP_SIZE: CSSProperties = {
145
+ blockSize: 'var(--thread-action-h, var(--space-8))',
146
+ borderRadius: 'var(--radius-sm)',
147
+ }
148
+
149
+ /** Icon-only variants: square, so the glyph sits on the chip's optical centre. */
150
+ const CHIP_SQUARE: CSSProperties = {
151
+ ...CHIP_SIZE,
152
+ inlineSize: 'var(--thread-action-h, var(--space-8))',
153
+ }
154
+
155
+ export interface ReactionBarProps {
156
+ readonly reactions: readonly ThreadReactionCount[]
157
+ readonly onReact?: ((key: ThreadReactionKey) => void) | undefined
158
+ readonly disabled?: boolean | undefined
159
+ readonly className?: string | undefined
160
+ }
161
+
162
+ export function ReactionBar({ reactions, onReact, disabled = false, className }: ReactionBarProps) {
163
+ const [pickerOpen, setPickerOpen] = useState(false)
164
+ const pickerId = useId()
165
+ const rootRef = useRef<HTMLDivElement>(null)
166
+ const triggerRef = useRef<HTMLButtonElement>(null)
167
+ const panelRef = useRef<HTMLDivElement>(null)
168
+
169
+ const present = reactions.filter((reaction) => reaction.count > 0)
170
+ const canReact = onReact !== undefined && !disabled
171
+
172
+ /**
173
+ * Close the picker, moving focus back to the trigger BEFORE the close.
174
+ *
175
+ * This ordering is the whole point. The panel below is always mounted and
176
+ * goes `aria-hidden` + `tabIndex={-1}` when it closes, so closing it while a
177
+ * reaction button inside it still holds focus strands that focus on an
178
+ * element that is not in the accessibility tree — browsers then drop it to
179
+ * `<body>` and the next Tab restarts from the top of the document. Calling
180
+ * `focus()` synchronously first means focus lands on the trigger while the
181
+ * panel is still open and focusable, and React's batching applies the close
182
+ * in the same commit.
183
+ *
184
+ * `restoreFocus` is false for exactly one path: a pointer press outside. The
185
+ * press is already choosing its own focus target, and pulling focus back to
186
+ * the trigger would fight it.
187
+ */
188
+ const closePicker = useCallback((restoreFocus: boolean) => {
189
+ if (restoreFocus) triggerRef.current?.focus()
190
+ setPickerOpen(false)
191
+ }, [])
192
+
193
+ /**
194
+ * Dismiss on a pointer press outside the bar.
195
+ *
196
+ * Bound only WHILE OPEN and removed by the effect's own cleanup, so there is
197
+ * no always-on document listener and nothing survives an unmount — a global
198
+ * listener registered once per bar for the life of the thread would be one
199
+ * per comment, all of them idle.
200
+ *
201
+ * `pointerdown` rather than `click`: it fires before focus moves, so the
202
+ * decision about where focus should land is made while the answer is still
203
+ * knowable. `focusout` was the other candidate and is wrong here — Safari
204
+ * does not focus a button on mousedown, so the picker would close before its
205
+ * own button's click handler ran.
206
+ */
207
+ useEffect(() => {
208
+ if (!pickerOpen) return undefined
209
+ const onPointerDown = (event: PointerEvent) => {
210
+ const root = rootRef.current
211
+ if (root !== null && event.target instanceof Node && root.contains(event.target)) return
212
+ const panel = panelRef.current
213
+ // Restore only when focus is inside the panel we are about to hide.
214
+ closePicker(panel !== null && panel.contains(document.activeElement))
215
+ }
216
+ document.addEventListener('pointerdown', onPointerDown)
217
+ return () => {
218
+ document.removeEventListener('pointerdown', onPointerDown)
219
+ }
220
+ }, [closePicker, pickerOpen])
221
+
222
+ return (
223
+ <div
224
+ ref={rootRef}
225
+ className={cn('flex flex-col items-start', className)}
226
+ // APG: Escape closes an overlay and returns focus to its trigger. The
227
+ // handler sits on the bar rather than on the panel so it works from the
228
+ // trigger too, and it CONSUMES the key when it acted — an Escape aimed at
229
+ // this picker must not also close the drawer the thread is mounted in.
230
+ onKeyDown={(event) => {
231
+ if (event.key !== 'Escape' || !pickerOpen) return
232
+ event.preventDefault()
233
+ event.stopPropagation()
234
+ closePicker(true)
235
+ }}
236
+ >
237
+ <div className="flex flex-wrap items-center gap-[var(--space-1)]">
238
+ {present.map((reaction) => {
239
+ const meta = REACTION_META[reaction.key]
240
+ return (
241
+ <button
242
+ key={reaction.key}
243
+ type="button"
244
+ // aria-pressed carries the toggle semantics; the label carries the
245
+ // meaning, because the colour alone must never be the signal.
246
+ aria-pressed={reaction.viewerReacted}
247
+ aria-label={`${meta.label} — ${String(reaction.count)}`}
248
+ disabled={!canReact}
249
+ onClick={() => {
250
+ onReact?.(reaction.key)
251
+ }}
252
+ className={cn(
253
+ CHIP_BASE,
254
+ reaction.viewerReacted ? CHIP_ACTIVE : CHIP_RESTING,
255
+ )}
256
+ style={CHIP_SIZE}
257
+ >
258
+ <ReactionGlyph emoji={meta.emoji} />
259
+ <span>{reaction.count}</span>
260
+ </button>
261
+ )
262
+ })}
263
+
264
+ {canReact ? (
265
+ <button
266
+ ref={triggerRef}
267
+ type="button"
268
+ aria-label="Add a reaction"
269
+ aria-expanded={pickerOpen}
270
+ aria-controls={pickerId}
271
+ // Routed through `closePicker` on the way down so that EVERY close
272
+ // path restores focus, without a reader having to check which ones
273
+ // do. Focus is already here on a real click; this only matters as
274
+ // an invariant.
275
+ onClick={() => {
276
+ if (pickerOpen) closePicker(true)
277
+ else setPickerOpen(true)
278
+ }}
279
+ className={cn(CHIP_BASE, CHIP_RESTING, 'px-[var(--space-1)]')}
280
+ style={CHIP_SQUARE}
281
+ >
282
+ <SmilePlus size={12} strokeWidth={1.75} aria-hidden="true" />
283
+ </button>
284
+ ) : null}
285
+ </div>
286
+
287
+ {/* Mounted, never conditionally rendered — correction 4 to the port.
288
+ `if (!open) return null` kills the open/close transition outright, so
289
+ the panel is always in the DOM and driven by `data-open`. Closed rows
290
+ leave the tab order via tabIndex rather than display:none, which would
291
+ make the grid-rows transition a no-op. */}
292
+ {canReact ? (
293
+ <div
294
+ ref={panelRef}
295
+ id={pickerId}
296
+ data-open={pickerOpen}
297
+ aria-hidden={!pickerOpen}
298
+ className={cn(
299
+ 'grid grid-rows-[0fr] opacity-0 data-[open=true]:grid-rows-[1fr] data-[open=true]:opacity-100',
300
+ 'transition-[grid-template-rows,opacity] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
301
+ 'motion-reduce:transition-none',
302
+ )}
303
+ >
304
+ <div className="overflow-hidden">
305
+ <div
306
+ className="flex flex-wrap items-center gap-[var(--space-1)]"
307
+ style={{ paddingBlockStart: 'var(--space-1)' }}
308
+ >
309
+ {THREAD_REACTION_KEYS.map((key) => {
310
+ const meta = REACTION_META[key]
311
+ return (
312
+ <button
313
+ key={key}
314
+ type="button"
315
+ tabIndex={pickerOpen ? 0 : -1}
316
+ aria-label={meta.label}
317
+ title={meta.label}
318
+ onClick={() => {
319
+ onReact?.(key)
320
+ // Focus first, then close — the button being pressed is
321
+ // the one that is about to become aria-hidden.
322
+ closePicker(true)
323
+ }}
324
+ className={cn(CHIP_BASE, CHIP_RESTING, 'px-0')}
325
+ style={CHIP_SQUARE}
326
+ >
327
+ <ReactionGlyph emoji={meta.emoji} />
328
+ </button>
329
+ )
330
+ })}
331
+ </div>
332
+ </div>
333
+ </div>
334
+ ) : null}
335
+ </div>
336
+ )
337
+ }
338
+
339
+ export interface CommentVoteProps {
340
+ readonly score: number
341
+ readonly viewerVote: ThreadVote
342
+ readonly onVote?: ((next: ThreadVote) => void) | undefined
343
+ readonly disabled?: boolean | undefined
344
+ readonly className?: string | undefined
345
+ }
346
+
347
+ /**
348
+ * The vote variant (D8), exposed so the community surface can choose it in T6.
349
+ * Controlled: `viewerVote` comes in, `onVote` goes out, nothing is remembered.
350
+ */
351
+ export function CommentVote({
352
+ score,
353
+ viewerVote,
354
+ onVote,
355
+ disabled = false,
356
+ className,
357
+ }: CommentVoteProps) {
358
+ const up = viewerVote === 1
359
+ const down = viewerVote === -1
360
+ const canVote = onVote !== undefined && !disabled
361
+
362
+ const buttonClass = (active: boolean) =>
363
+ cn(
364
+ 'inline-flex items-center justify-center border border-transparent',
365
+ 'transition-[color,background-color,transform] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
366
+ 'motion-reduce:transition-none',
367
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
368
+ 'active:scale-[0.96] motion-reduce:active:scale-100',
369
+ 'disabled:cursor-not-allowed disabled:opacity-60',
370
+ active
371
+ ? 'text-[rgb(var(--accent-ink))]'
372
+ : 'text-[rgb(var(--text-tertiary))] hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))]',
373
+ )
374
+
375
+ return (
376
+ <div className={cn('inline-flex items-center gap-[var(--space-1)]', className)}>
377
+ <button
378
+ type="button"
379
+ aria-label={up ? 'Remove upvote' : 'Upvote'}
380
+ aria-pressed={up}
381
+ disabled={!canVote}
382
+ onClick={() => {
383
+ onVote?.(up ? 0 : 1)
384
+ }}
385
+ className={buttonClass(up)}
386
+ style={CHIP_SQUARE}
387
+ >
388
+ <ArrowBigUp size={14} strokeWidth={1.75} aria-hidden="true" />
389
+ </button>
390
+ {/* Reserved slot: a score going 9 → 10 must not shove the buttons. */}
391
+ <span
392
+ className="text-center text-[13px] font-semibold tabular-nums"
393
+ style={{
394
+ minInlineSize: '3ch',
395
+ color: up || down ? 'rgb(var(--accent-ink))' : 'rgb(var(--text-secondary))',
396
+ }}
397
+ >
398
+ {score}
399
+ </span>
400
+ <button
401
+ type="button"
402
+ aria-label={down ? 'Remove downvote' : 'Downvote'}
403
+ aria-pressed={down}
404
+ disabled={!canVote}
405
+ onClick={() => {
406
+ onVote?.(down ? 0 : -1)
407
+ }}
408
+ className={buttonClass(down)}
409
+ style={CHIP_SQUARE}
410
+ >
411
+ <ArrowBigDown size={14} strokeWidth={1.75} aria-hidden="true" />
412
+ </button>
413
+ </div>
414
+ )
415
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * RelativeTime — "now" / "4m" / "3h" / "6d", then an absolute date.
3
+ *
4
+ * ADR-147 D16. Three rules, all of them load-bearing:
5
+ *
6
+ * • `now` is a PROP. Nothing here reads the clock. See use-now.ts.
7
+ * • The output sits inside `<time dateTime>` carrying the exact ISO value,
8
+ * so the precise timestamp survives for a screen reader, a copy-paste, and
9
+ * anything scraping the DOM — the relative string is a convenience layer
10
+ * over real data, not a replacement for it.
11
+ * • The slot has a `min-inline-size` and `tabular-nums`, so "now" becoming
12
+ * "10m" cannot reflow the byline. Layout stability: the box's size is
13
+ * decoupled from its contents.
14
+ *
15
+ * The crossover to absolute is seven days, matching D16. Relative labels stop
16
+ * being useful roughly when people stop counting in days, and an absolute date
17
+ * never changes, so nothing past the crossover can reflow anything.
18
+ */
19
+
20
+ import { cn } from '../lib/utils'
21
+
22
+ const MINUTE = 60_000
23
+ const HOUR = 60 * MINUTE
24
+ const DAY = 24 * HOUR
25
+ const WEEK = 7 * DAY
26
+
27
+ /** ISO 8601 for the `datetime` attribute. Invalid input degrades to ''. */
28
+ export function toIsoTimestamp(value: number): string {
29
+ if (!Number.isFinite(value)) return ''
30
+ const date = new Date(value)
31
+ if (Number.isNaN(date.getTime())) return ''
32
+ return date.toISOString()
33
+ }
34
+
35
+ /** The full, unambiguous timestamp — used as the hover title. */
36
+ export function formatAbsoluteTime(value: number, locale?: string): string {
37
+ if (!Number.isFinite(value)) return ''
38
+ return new Date(value).toLocaleString(locale, {
39
+ dateStyle: 'medium',
40
+ timeStyle: 'short',
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Pure. Deterministic for a given (value, now) pair — that is the whole point.
46
+ * A future timestamp (clock skew between the server and the browser is normal)
47
+ * reads "now" rather than a negative age.
48
+ */
49
+ export function formatRelativeTime(
50
+ value: number,
51
+ now: number,
52
+ locale?: string,
53
+ ): string {
54
+ if (!Number.isFinite(value) || !Number.isFinite(now)) return ''
55
+ const elapsed = now - value
56
+ if (elapsed < MINUTE) return 'now'
57
+ if (elapsed < HOUR) return `${String(Math.floor(elapsed / MINUTE))}m`
58
+ if (elapsed < DAY) return `${String(Math.floor(elapsed / HOUR))}h`
59
+ if (elapsed < WEEK) return `${String(Math.floor(elapsed / DAY))}d`
60
+
61
+ const date = new Date(value)
62
+ const sameYear = date.getFullYear() === new Date(now).getFullYear()
63
+ return date.toLocaleDateString(locale, {
64
+ day: 'numeric',
65
+ month: 'short',
66
+ ...(sameYear ? {} : { year: 'numeric' }),
67
+ })
68
+ }
69
+
70
+ export interface RelativeTimeProps {
71
+ /** Epoch milliseconds. */
72
+ readonly value: number
73
+ /** Epoch milliseconds, injected from the thread root's single ticker. */
74
+ readonly now: number
75
+ readonly locale?: string | undefined
76
+ readonly className?: string | undefined
77
+ }
78
+
79
+ export function RelativeTime({ value, now, locale, className }: RelativeTimeProps) {
80
+ const iso = toIsoTimestamp(value)
81
+ return (
82
+ <time
83
+ // An unparseable timestamp gets no `datetime` rather than an invalid one.
84
+ {...(iso === '' ? {} : { dateTime: iso })}
85
+ title={formatAbsoluteTime(value, locale)}
86
+ className={cn('inline-block shrink-0 tabular-nums', className)}
87
+ // Reserved slot: 3.5ch fits every relative label ("now" … "6d"). Past the
88
+ // seven-day crossover the label is an absolute date, which never changes.
89
+ style={{ minInlineSize: '3.5ch' }}
90
+ >
91
+ {formatRelativeTime(value, now, locale)}
92
+ </time>
93
+ )
94
+ }