@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,263 @@
1
+ /**
2
+ * DeltaChip — the period-over-period change chip that sits beside a KPI value.
3
+ *
4
+ * Promoted in ADR-146 D5 (stat layer). It subsumes three independent forks
5
+ * that were all the same `ArrowUp` / `ArrowDown` / `Minus` ternary:
6
+ *
7
+ * • `Delta` — `components/ui/admin/stat-tile.tsx:10` (percent, `--success`/`--destructive` ink, no fill)
8
+ * • `DeltaChip` — `design-system/spec/shell-preview.tsx:254` (tinted fill, no border, up/down only — no flat)
9
+ * • `Trend` — `components/ui/community/primitives.tsx:117` (absolute count, down painted MUTED not red)
10
+ *
11
+ * Between them they cover percent, absolute and flat, so this primitive
12
+ * covers all three.
13
+ *
14
+ * CONSUMERS — measured 2026-09-06 (real `from '@lovett/ui'` imports under
15
+ * `apps/workspace/src`; the design-system gallery is a demo harness and does
16
+ * NOT count toward the ADR-008 D3 gate).
17
+ * TODAY (1 direct — gate NOT MET):
18
+ * • `components/ui/community/leaderboard.tsx` — points trend, absolute
19
+ * INDIRECT, and deliberately not counted: `components/ui/admin/stat-tile.tsx`
20
+ * renders one, but through `StatCard`'s `delta` prop — it never imports
21
+ * `DeltaChip`, so it is not a second direct consumer.
22
+ * PLANNED: no second direct consumer is named yet. The `Delta` fork above
23
+ * is deleted; the `DeltaChip` fork at `design-system/spec/shell-preview.tsx:254`
24
+ * is gallery code and cannot count. Tracked in
25
+ * `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
26
+ *
27
+ * Migration of the remaining forks is ADR-146 D13
28
+ * (`converge-delta-chips.md`), not this file's job.
29
+ *
30
+ * THREE SIGNALS, NOT ONE — fill + border + ink
31
+ * --------------------------------------------
32
+ * The source kit measures a pale tinted fill PLUS a mid-tint border PLUS a
33
+ * saturated label — three signals, not one (METORIC-ANALYTICS-SPEC §5.2, the
34
+ * exact values are recorded there). The border is not decoration: a fill-only
35
+ * chip at ~10% alpha disappears against one of the two surfaces it has to
36
+ * live on — the white panel (`--surface-card`) and the grey tray
37
+ * (`--surface-frame`). Two of the three forks above shipped fill-only or
38
+ * ink-only for exactly this reason and are illegible on one surface each.
39
+ * Keep all three signals.
40
+ *
41
+ * DIRECTION IS NEVER COLOUR ALONE
42
+ * -------------------------------
43
+ * The arrow carries direction independently of the hue (ADR-146 D10.1, and
44
+ * the "don't rely on colour alone" design principle), and an `sr-only` word
45
+ * carries it for assistive tech. A red/green swap alone would be unreadable
46
+ * for a deuteranope and silent to a screen reader.
47
+ *
48
+ * UP IS NOT ALWAYS GOOD
49
+ * ---------------------
50
+ * `invert` exists because roughly half our metrics are cost-shaped — CPA,
51
+ * CPC, spend, bounce rate, time-to-first-response — where DOWN is the good
52
+ * outcome. Hardcoding "up is green" is wrong for all of them. `direction`
53
+ * (the arrow) and tone (the colour) are resolved separately: the arrow
54
+ * always follows the sign, the colour follows the sign XOR `invert`.
55
+ *
56
+ * Token discipline: every colour is a frozen `rgb(var(--token))` literal in
57
+ * `DELTA_TONE` — no constructed token names (ADR-146 D8), no colour literals.
58
+ * `--success-bg` / `--destructive-bg` already carry their alpha, so they are
59
+ * consumed bare and NEVER given a second one; the border alphas hang off the
60
+ * bare `--success` / `--destructive` triples, which is legal. Geometry is
61
+ * `--radius-full` + the `--space` scale; the 2px vertical pad the kit
62
+ * measures is written `calc(var(--space-1) / 2)` so it stays derived from the
63
+ * scale rather than becoming a magic number.
64
+ */
65
+
66
+ import { ArrowDown, ArrowUp, Minus, type LucideIcon } from 'lucide-react'
67
+ import { cn } from './lib/utils'
68
+ import { formatNumber, formatPercent } from './format'
69
+
70
+ /** Which way the arrow points. Inferred from the sign unless overridden. */
71
+ export type DeltaDirection = 'up' | 'down' | 'flat'
72
+
73
+ /** How the magnitude is rendered. `percent` appends `%`. */
74
+ export type DeltaFormat = 'percent' | 'absolute'
75
+
76
+ /** Whether the change reads as good, bad or neither. Never set directly — it
77
+ * is derived from `direction` and `invert`. */
78
+ type DeltaTone = 'positive' | 'negative' | 'neutral'
79
+
80
+ interface DeltaToneStyle {
81
+ background: string
82
+ border: string
83
+ color: string
84
+ }
85
+
86
+ /**
87
+ * Frozen literal lookup — the same shape as `metric-card.tsx`'s `TONE_COLOR`.
88
+ * Never build these strings at runtime: a constructed custom-property name
89
+ * that misses produces invalid CSS which the browser silently discards, with
90
+ * every gate green (ADR-146 D8, ENGINEERING-AXES.md:186).
91
+ */
92
+ /** Whether the direction is an outcome to be judged, or just a direction. */
93
+ export type DeltaSentiment = 'directional' | 'none'
94
+
95
+ const DELTA_TONE: Record<DeltaTone, DeltaToneStyle> = {
96
+ positive: {
97
+ background: 'rgb(var(--success-bg))',
98
+ border: '1px solid rgb(var(--success) / 0.30)',
99
+ color: 'rgb(var(--success-ink))',
100
+ },
101
+ negative: {
102
+ background: 'rgb(var(--destructive-bg))',
103
+ border: '1px solid rgb(var(--destructive) / 0.30)',
104
+ color: 'rgb(var(--destructive-ink))',
105
+ },
106
+ neutral: {
107
+ background: 'rgb(var(--muted))',
108
+ border: '1px solid rgb(var(--border))',
109
+ color: 'rgb(var(--text-secondary))',
110
+ },
111
+ }
112
+
113
+ const DELTA_ICON: Record<DeltaDirection, LucideIcon> = {
114
+ up: ArrowUp,
115
+ down: ArrowDown,
116
+ flat: Minus,
117
+ }
118
+
119
+ /** Spoken direction, so the chip is not colour- or glyph-only for AT. */
120
+ const DELTA_WORD: Record<DeltaDirection, string> = {
121
+ up: 'Up',
122
+ down: 'Down',
123
+ flat: 'No change,',
124
+ }
125
+
126
+ /**
127
+ * Direction from the sign OF THE NUMBER WE PRINT, not of the raw input.
128
+ *
129
+ * The two disagree whenever a change rounds away at the displayed
130
+ * precision: `+0.04` at one decimal prints "0.0%", and taking direction
131
+ * from the raw sign shipped that as a GREEN UP ARROW beside a zero. The
132
+ * chip would be claiming a rise it is simultaneously reporting as none.
133
+ * Rounding first makes the glyph, the hue and the numeral agree — which is
134
+ * the whole point of carrying direction in three channels.
135
+ *
136
+ * Non-finite input (NaN from a division by zero, a missing denominator)
137
+ * reads as flat rather than throwing — the formatters render it as an
138
+ * em-dash, so the chip degrades to "no change, —".
139
+ */
140
+ function directionOf(value: number, decimals: number): DeltaDirection {
141
+ if (!Number.isFinite(value)) return 'flat'
142
+ const shown = Number(value.toFixed(decimals))
143
+ if (shown === 0) return 'flat'
144
+ return shown > 0 ? 'up' : 'down'
145
+ }
146
+
147
+ function toneOf(
148
+ direction: DeltaDirection,
149
+ invert: boolean,
150
+ sentiment: DeltaSentiment,
151
+ ): DeltaTone {
152
+ if (direction === 'flat') return 'neutral'
153
+ // Some numbers move without either direction being an outcome. The arrow
154
+ // still reports WHICH way; the colour declines to judge it.
155
+ if (sentiment === 'none') return 'neutral'
156
+ const good = invert ? direction === 'down' : direction === 'up'
157
+ return good ? 'positive' : 'negative'
158
+ }
159
+
160
+ export interface DeltaChipProps {
161
+ /**
162
+ * The change itself. For `format: 'percent'` this is in PERCENT UNITS, not
163
+ * a 0-1 fraction — pass `12.5` for "12.5%" (ADR-146 D11; `formatPercent`
164
+ * takes percent units, `format.ts:57-68`). The sign drives the arrow; the
165
+ * chip renders the magnitude, so the sign is never printed twice.
166
+ */
167
+ value: number
168
+ /** `'percent'` (default) appends `%`; `'absolute'` renders a plain count. */
169
+ format?: DeltaFormat
170
+ /**
171
+ * Force the arrow. Defaults to the sign of `value`. Override only when the
172
+ * caller knows something the sign does not — e.g. a rank where "moved from
173
+ * 4 to 2" is an improvement expressed as a negative number.
174
+ */
175
+ direction?: DeltaDirection
176
+ /**
177
+ * `true` when DOWN is the good outcome — cost, CPA, CPC, spend, bounce
178
+ * rate, latency. Flips the colour only; the arrow still follows the sign.
179
+ */
180
+ invert?: boolean
181
+ /**
182
+ * Whether the direction carries a good/bad judgement at all.
183
+ *
184
+ * `'directional'` (default) is the KPI case: up is good, or down is when
185
+ * `invert` is set. `'none'` renders both directions in the neutral tone —
186
+ * the arrow still reports which way the number moved, but the colour makes
187
+ * no claim about it.
188
+ *
189
+ * Use `'none'` for counts that simply vary and are attached to a PERSON —
190
+ * a contributor's monthly total, a volunteer's hours. Painting those
191
+ * `--destructive` states that someone failed, spends the error channel on
192
+ * something that is not an error, and is the regression that reverted this
193
+ * chip's first leaderboard consumer (ADR-146 amendment, phase audit H1).
194
+ */
195
+ sentiment?: DeltaSentiment
196
+ /** Decimal places. Defaults to 1 for percent, 0 for absolute. */
197
+ decimals?: number
198
+ /**
199
+ * Character slot reserved for the numeral so a ticking value cannot resize
200
+ * the chip (design principles → Layout stability). Sized for the largest
201
+ * plausible value; the chip still grows past it rather than clipping.
202
+ * Default 5 — fits "12.5%" and "1,204".
203
+ */
204
+ reserveChars?: number
205
+ /** Optional class override for the outer chip. */
206
+ className?: string
207
+ }
208
+
209
+ export function DeltaChip({
210
+ value,
211
+ format = 'percent',
212
+ direction,
213
+ invert = false,
214
+ sentiment = 'directional',
215
+ decimals,
216
+ reserveChars = 5,
217
+ className,
218
+ }: DeltaChipProps) {
219
+ const places = decimals ?? (format === 'percent' ? 1 : 0)
220
+ const dir = direction ?? directionOf(value, places)
221
+ const tone = DELTA_TONE[toneOf(dir, invert, sentiment)]
222
+ const Icon = DELTA_ICON[dir]
223
+
224
+ const magnitude = Math.abs(value)
225
+ // percent units, not a fraction — see `value` above.
226
+ const text =
227
+ format === 'percent'
228
+ ? formatPercent(magnitude, { decimals: places })
229
+ : formatNumber(magnitude, { decimals: places })
230
+
231
+ return (
232
+ <span
233
+ className={cn(
234
+ 'inline-flex items-center shrink-0 align-middle',
235
+ 'text-[12px] font-semibold leading-none tabular-nums',
236
+ // Colour is the only thing that animates here, and a data change
237
+ // eases out — never springs (ADR-146 D9). Reduced motion drops it.
238
+ 'transition-colors duration-[var(--dur-base)] ease-[var(--ease-out)]',
239
+ 'motion-reduce:transition-none',
240
+ className,
241
+ )}
242
+ style={{
243
+ gap: 'var(--space-1)',
244
+ // 2px/4px in the source kit. 2px is not on the --space scale, so it
245
+ // is derived from it rather than written as a literal.
246
+ padding: 'calc(var(--space-1) / 2) var(--space-1)',
247
+ borderRadius: 'var(--radius-full)',
248
+ background: tone.background,
249
+ border: tone.border,
250
+ color: tone.color,
251
+ }}
252
+ >
253
+ <span className="sr-only">{DELTA_WORD[dir]}</span>
254
+ <Icon size={16} strokeWidth={2} aria-hidden="true" className="shrink-0" />
255
+ <span
256
+ className="text-center"
257
+ style={{ minWidth: `${reserveChars}ch` }}
258
+ >
259
+ {text}
260
+ </span>
261
+ </span>
262
+ )
263
+ }
@@ -0,0 +1,369 @@
1
+ /**
2
+ * ActivityPane — three tabs, ONE composer.
3
+ *
4
+ * The load-bearing test is the last one: the composer must be the SAME element
5
+ * before and after a tab change, including on Activity. Anything that mounts a
6
+ * composer per tab passes a "the composer is visible" assertion and still
7
+ * throws away what you were typing when you glance at the history.
8
+ */
9
+ import { useState } from 'react'
10
+ import { describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen, within } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import { ActivityPane } from '../activity-pane'
14
+ import type { ThreadComment } from '../../thread/types'
15
+ import type { ActivityEntry } from '../types'
16
+
17
+ const NOW = new Date(2026, 8, 15, 12, 0, 0).getTime()
18
+
19
+ const comments: ThreadComment[] = [
20
+ {
21
+ id: 'c1',
22
+ author: { id: 'u1', name: 'Ada Lovelace' },
23
+ bodyMd: 'Looks right to me.',
24
+ createdAt: NOW - 60_000,
25
+ },
26
+ ]
27
+
28
+ const activity: ActivityEntry[] = [
29
+ {
30
+ id: 'a1',
31
+ at: NOW - 120_000,
32
+ actor: { id: 'u2', name: 'Grace Hopper' },
33
+ field: 'Status',
34
+ from: 'Building',
35
+ to: 'Review',
36
+ },
37
+ ]
38
+
39
+ function Host(props: { onSubmit?: (body: string) => void }) {
40
+ const [draft, setDraft] = useState('')
41
+ return (
42
+ <ActivityPane
43
+ comments={comments}
44
+ activity={activity}
45
+ draft={draft}
46
+ onDraftChange={setDraft}
47
+ onSubmit={props.onSubmit ?? (() => {})}
48
+ now={NOW}
49
+ locale="en-US"
50
+ />
51
+ )
52
+ }
53
+
54
+ function composer(): HTMLElement {
55
+ return screen.getByRole('textbox', { name: 'Add a comment' })
56
+ }
57
+
58
+ function panel(name: string): HTMLElement {
59
+ const node = document.querySelector<HTMLElement>(`[role="tabpanel"][aria-label="${name}"]`)
60
+ if (node === null) throw new Error(`no ${name} panel`)
61
+ return node
62
+ }
63
+
64
+ describe('ActivityPane', () => {
65
+ it('opens on Everything with both kinds of entry, in time order', () => {
66
+ render(<Host />)
67
+ const everything = panel('Everything')
68
+ expect(everything).toBeVisible()
69
+ expect(everything).toHaveTextContent('Grace Hopper')
70
+ expect(everything).toHaveTextContent('changed Status from Building to Review')
71
+ expect(everything).toHaveTextContent('Looks right to me.')
72
+ })
73
+
74
+ it('shows only the history on Activity', async () => {
75
+ render(<Host />)
76
+ await userEvent.click(screen.getByRole('tab', { name: /Activity/ }))
77
+ expect(panel('Activity')).toBeVisible()
78
+ expect(panel('Everything')).not.toBeVisible()
79
+ expect(panel('Activity')).not.toHaveTextContent('Looks right to me.')
80
+ })
81
+
82
+ it('draws exactly one composer, on every tab, and keeps it mounted', async () => {
83
+ render(<Host />)
84
+ const first = composer()
85
+ await userEvent.type(first, 'half a thought')
86
+
87
+ for (const tab of ['Comments', 'Activity', 'Everything']) {
88
+ await userEvent.click(screen.getByRole('tab', { name: new RegExp(tab) }))
89
+ expect(screen.getAllByRole('textbox', { name: 'Add a comment' })).toHaveLength(1)
90
+ // The SAME node, with what was typed into it still there.
91
+ expect(composer()).toBe(first)
92
+ expect(first).toHaveValue('half a thought')
93
+ }
94
+ })
95
+
96
+ it('submits the draft as a root comment', async () => {
97
+ const onSubmit = vi.fn()
98
+ render(<Host onSubmit={onSubmit} />)
99
+ await userEvent.type(composer(), 'Shipping this')
100
+ await userEvent.click(screen.getByRole('button', { name: 'Comment' }))
101
+ expect(onSubmit).toHaveBeenCalledWith('Shipping this')
102
+ })
103
+
104
+ it('speaks in the interface voice when a tab is empty', async () => {
105
+ render(
106
+ <ActivityPane
107
+ comments={[]}
108
+ activity={[]}
109
+ draft=""
110
+ onDraftChange={() => {}}
111
+ onSubmit={() => {}}
112
+ now={NOW}
113
+ />,
114
+ )
115
+ expect(panel('Everything')).toHaveTextContent(
116
+ 'Comments and changes to this record show up together.',
117
+ )
118
+ await userEvent.click(screen.getByRole('tab', { name: /Activity/ }))
119
+ expect(panel('Activity')).toHaveTextContent('Every change to this record is recorded here.')
120
+ })
121
+ })
122
+
123
+ /**
124
+ * A host with no comment backend — Lightwork, and anything else that wants a
125
+ * history feed without a conversation.
126
+ *
127
+ * The pane used to REQUIRE `comments`, `draft`, `onDraftChange` and `onSubmit`,
128
+ * which meant a host with nowhere to post had two bad options: pass no-op
129
+ * handlers and ship a composer that silently discards what you type, or fork
130
+ * the file. One consumer did fork it — 77% duplicated — which is what these
131
+ * props exist to make unnecessary.
132
+ */
133
+ describe('without a comment backend', () => {
134
+ const ACTIVITY = [
135
+ { id: 'a1', at: 1_700_000_000_000, field: 'Status', from: 'Planned', to: 'Live' },
136
+ ]
137
+
138
+ it('drops the Comments tab rather than rendering it empty', () => {
139
+ render(<ActivityPane activity={ACTIVITY} />)
140
+ expect(screen.getByRole('tab', { name: /everything/i })).toBeInTheDocument()
141
+ expect(screen.getByRole('tab', { name: /activity/i })).toBeInTheDocument()
142
+ expect(screen.queryByRole('tab', { name: /comments/i })).toBeNull()
143
+ })
144
+
145
+ it('draws no composer — there is nowhere to post', () => {
146
+ render(<ActivityPane activity={ACTIVITY} />)
147
+ expect(screen.queryByRole('textbox')).toBeNull()
148
+ expect(document.querySelector('[data-slot="activity-composer"]')).toBeNull()
149
+ })
150
+
151
+ it('still shows the history', () => {
152
+ render(<ActivityPane activity={ACTIVITY} />)
153
+ // Every panel stays MOUNTED and is hidden rather than unmounted (so a
154
+ // scroll position and a half-read feed survive a tab change), so the same
155
+ // entry legitimately appears twice in the DOM. Scope to the visible one.
156
+ const shown = document.querySelector('[role="tabpanel"]:not([hidden])')
157
+ expect(shown).not.toBeNull()
158
+ expect(within(shown as HTMLElement).getByText(/Planned/)).toBeInTheDocument()
159
+ expect(within(shown as HTMLElement).getByText(/Live/)).toBeInTheDocument()
160
+ })
161
+
162
+ it('never lands on a tab it does not render', () => {
163
+ // `comments` as the requested tab, with no backend: the pane must fall
164
+ // back rather than show a panel that was never mounted.
165
+ render(<ActivityPane activity={ACTIVITY} defaultTab="comments" />)
166
+ const selected = screen
167
+ .getAllByRole('tab')
168
+ .filter((t) => t.getAttribute('aria-selected') === 'true')
169
+ expect(selected).toHaveLength(1)
170
+ expect(selected[0]).toHaveTextContent(/everything/i)
171
+ })
172
+
173
+ it('is unchanged for a host that DOES supply a backend', () => {
174
+ render(
175
+ <ActivityPane
176
+ activity={ACTIVITY}
177
+ comments={[]}
178
+ draft=""
179
+ onDraftChange={() => {}}
180
+ onSubmit={() => {}}
181
+ />,
182
+ )
183
+ expect(screen.getByRole('tab', { name: /comments/i })).toBeInTheDocument()
184
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
185
+ })
186
+ })
187
+
188
+ /**
189
+ * FU-0019 — the pane is a conduit, and it used to be a leaky one.
190
+ *
191
+ * `ActivityPaneProps` declared `onSubmit` and `onReply` and stopped, so three
192
+ * mutations `<Thread>` already accepted could not be reached through the pane
193
+ * at all. Nothing errored, which is why it survived a release: `CommentItem`
194
+ * draws no control for a callback it was not given, so a host shipped an inert
195
+ * engagement row and a one-line overflow menu while the routes behind them had
196
+ * no caller anywhere in the browser.
197
+ *
198
+ * These assert at the CONTROL, never at the prop. A prop that arrives and
199
+ * lands nowhere is the same bug one layer further in.
200
+ */
201
+ describe('ActivityPane — forwarding the thread callbacks', () => {
202
+ const WIRED: readonly ThreadComment[] = [
203
+ {
204
+ id: 'c1',
205
+ author: { id: 'u1', name: 'Ada Lovelace' },
206
+ bodyMd: 'Looks right to me.',
207
+ createdAt: NOW - 60_000,
208
+ reactions: [{ key: 'up', count: 7, viewerReacted: true }],
209
+ },
210
+ {
211
+ id: 'c-failed',
212
+ author: { id: 'u2', name: 'Grace Hopper' },
213
+ bodyMd: 'This one never left the device.',
214
+ createdAt: NOW - 30_000,
215
+ state: 'failed',
216
+ errorText: 'This comment did not send.',
217
+ },
218
+ ]
219
+
220
+ /** Mounts the pane and opens Comments, which is where `<Thread>` lives. */
221
+ async function comments(
222
+ props: Partial<React.ComponentProps<typeof ActivityPane>> = {},
223
+ ): Promise<HTMLElement> {
224
+ render(
225
+ <ActivityPane
226
+ comments={WIRED}
227
+ activity={activity}
228
+ draft=""
229
+ onDraftChange={() => {}}
230
+ onSubmit={() => {}}
231
+ now={NOW}
232
+ locale="en-US"
233
+ {...props}
234
+ />,
235
+ )
236
+ await userEvent.click(screen.getByRole('tab', { name: /Comments/ }))
237
+ return panel('Comments')
238
+ }
239
+
240
+ it('reports a reaction key from the chip the thread drew', async () => {
241
+ const onReact = vi.fn()
242
+ const shown = await comments({ onReact })
243
+ await userEvent.click(within(shown).getByRole('button', { name: 'Agree — 7' }))
244
+ expect(onReact).toHaveBeenCalledWith('c1', 'up')
245
+ })
246
+
247
+ it('offers a picker, so a reaction can come into existence at all', async () => {
248
+ const shown = await comments({ onReact: vi.fn() })
249
+ expect(within(shown).getByRole('button', { name: 'Add a reaction' })).toBeInTheDocument()
250
+ })
251
+
252
+ it('puts Edit and Delete in the overflow menu and reports the comment id', async () => {
253
+ const onEdit = vi.fn()
254
+ const onDelete = vi.fn()
255
+ const shown = await comments({ onEdit, onDelete })
256
+ await userEvent.click(
257
+ within(shown).getByRole('button', { name: "More actions for Ada Lovelace's comment" }),
258
+ )
259
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Edit' }))
260
+ expect(onEdit).toHaveBeenCalledWith('c1')
261
+
262
+ await userEvent.click(
263
+ within(shown).getByRole('button', { name: "More actions for Ada Lovelace's comment" }),
264
+ )
265
+ const remove = screen.getByRole('menuitem', { name: 'Delete' })
266
+ expect(remove.getAttribute('data-variant')).toBe('destructive')
267
+ await userEvent.click(remove)
268
+ expect(onDelete).toHaveBeenCalledWith('c1')
269
+ })
270
+
271
+ it('recovers a send that failed in its OWN composer', async () => {
272
+ const onRetry = vi.fn()
273
+ const shown = await comments({ onRetry })
274
+ await userEvent.click(within(shown).getByRole('button', { name: 'Try again' }))
275
+ expect(onRetry).toHaveBeenCalledWith('c-failed')
276
+ })
277
+
278
+ it('draws none of them for a host that wires none — the 0.1.0 render', async () => {
279
+ const shown = await comments()
280
+ // What FU-0019 measured in a browser: a written reaction is readable and
281
+ // inert, and there is no picker, so a reaction can never be created.
282
+ expect(within(shown).getByRole('button', { name: 'Agree — 7' })).toBeDisabled()
283
+ expect(within(shown).queryByRole('button', { name: 'Add a reaction' })).toBeNull()
284
+ expect(within(shown).queryByRole('button', { name: 'Try again' })).toBeNull()
285
+ // The failure itself is still announced; only the recovery is missing.
286
+ expect(within(shown).getByText('This comment did not send.')).toBeInTheDocument()
287
+ // And no overflow menu at all: with no Edit, no Delete, no Copy link and
288
+ // no clipboard under jsdom, `CommentActions` has nothing to put in one and
289
+ // renders none. In a real browser it holds exactly one line — "Copy text",
290
+ // the single item that needs no callback — which is what FU-0019 measured.
291
+ expect(
292
+ within(shown).queryByRole('button', { name: "More actions for Ada Lovelace's comment" }),
293
+ ).toBeNull()
294
+ })
295
+ })
296
+
297
+ /**
298
+ * FU-0032 — the Everything feed draws its own tombstone, and it has to agree
299
+ * with the one the Comments tab draws for the same comment.
300
+ */
301
+ describe('ActivityPane — a moderated comment in the merged feed', () => {
302
+ // One tombstone, two removals. Derived from a single base so the ONLY
303
+ // difference between the two renders is the field under test.
304
+ const BASE: ThreadComment = {
305
+ id: 'c-mod',
306
+ author: { id: 'u1', name: 'Ada Lovelace' },
307
+ bodyMd: '',
308
+ createdAt: NOW - 60_000,
309
+ deletedAt: NOW - 30_000,
310
+ }
311
+
312
+ const REMOVED: readonly ThreadComment[] = [{ ...BASE, moderated: true }]
313
+ const SELF: readonly ThreadComment[] = [{ ...BASE, id: 'c-self', moderated: false }]
314
+
315
+ function feed(list: readonly ThreadComment[]): HTMLElement {
316
+ render(
317
+ <ActivityPane
318
+ comments={list}
319
+ activity={[]}
320
+ draft=""
321
+ onDraftChange={() => {}}
322
+ onSubmit={() => {}}
323
+ now={NOW}
324
+ />,
325
+ )
326
+ return panel('Everything')
327
+ }
328
+
329
+ it('says an admin removed it', () => {
330
+ expect(feed(REMOVED)).toHaveTextContent('Comment removed by an admin')
331
+ })
332
+
333
+ it('still says "Comment deleted" for an author\'s own delete', () => {
334
+ const shown = feed(SELF)
335
+ expect(shown).toHaveTextContent('Comment deleted')
336
+ expect(shown).not.toHaveTextContent('removed')
337
+ })
338
+
339
+ /**
340
+ * The feed WITHHOLDS the author of a tombstone, exactly as the thread does.
341
+ *
342
+ * Found in review. `FeedLine` was given `author` unconditionally, so the
343
+ * Everything tab printed "Ada Lovelace commented" beside "Comment removed by
344
+ * an admin" while the Comments tab one click away replaced her byline with
345
+ * `[removed]` and dropped her avatar to `?`. The same comment answered "who
346
+ * wrote this" two different ways depending on which tab you were on.
347
+ *
348
+ * It was harmless while the line read "Comment deleted" — a deletion nobody
349
+ * had to account for. FU-0032 is what made it matter: a name beside "removed
350
+ * by an admin" is a per-author public record of who got moderated, readable
351
+ * by everyone in the workspace, and it is the one fact the thread render
352
+ * deliberately refuses to publish.
353
+ *
354
+ * Asserted for BOTH removals, because the leak was never specific to
355
+ * moderation — only its consequence was.
356
+ */
357
+ it('withholds the author of a tombstone, on both kinds of removal', () => {
358
+ expect(feed(REMOVED)).not.toHaveTextContent('Ada Lovelace')
359
+ cleanup()
360
+ expect(feed(SELF)).not.toHaveTextContent('Ada Lovelace')
361
+ })
362
+
363
+ it('still names the author of a comment that is NOT a tombstone', () => {
364
+ const live: readonly ThreadComment[] = [
365
+ { ...BASE, id: 'c-live', deletedAt: null, bodyMd: 'Still here.' },
366
+ ]
367
+ expect(feed(live)).toHaveTextContent('Ada Lovelace')
368
+ })
369
+ })