@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,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,186 @@
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 { 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
+ })
@@ -0,0 +1,134 @@
1
+ /**
2
+ * DetailHeader and DetailMenu — the slots, and the rule that governs them.
3
+ *
4
+ * §7 names three controls with no backing system, and the owner's standing
5
+ * rule is that a rendered control does what it says. Both components express
6
+ * that the same way: a slot the host does not fill is not drawn. These tests
7
+ * assert the ABSENCE, because a dead control is exactly the kind of regression
8
+ * that no other check catches.
9
+ */
10
+ import { describe, expect, it, vi } from 'vitest'
11
+ import { render, screen } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import { DetailHeader } from '../detail-header'
14
+ import { DetailMenu } from '../detail-menu'
15
+
16
+ const noop = () => {}
17
+ const NOW = new Date(2026, 8, 15, 12, 0, 0).getTime()
18
+
19
+ describe('DetailHeader', () => {
20
+ it('draws nothing for a slot the host did not fill', () => {
21
+ render(<DetailHeader title="Slice B" onClose={noop} />)
22
+ expect(screen.queryByRole('button', { name: /complete/i })).toBeNull()
23
+ expect(screen.queryByRole('button', { name: /favourite/i })).toBeNull()
24
+ expect(screen.queryByRole('button', { name: /Previous item/ })).toBeNull()
25
+ expect(document.querySelector('[data-slot="detail-reminder"]')).toBeNull()
26
+ expect(document.querySelector('[data-slot="detail-menu-slot"]')).toBeNull()
27
+ // A title with no change handler is a heading, not a field.
28
+ expect(screen.getByRole('heading', { name: 'Slice B' })).toBeInTheDocument()
29
+ expect(screen.queryByRole('textbox')).toBeNull()
30
+ })
31
+
32
+ it('reflects completion in the label, not only in colour', async () => {
33
+ const onCompleteChange = vi.fn()
34
+ const view = render(
35
+ <DetailHeader
36
+ title="Slice B"
37
+ onClose={noop}
38
+ complete={false}
39
+ onCompleteChange={onCompleteChange}
40
+ />,
41
+ )
42
+ const toggle = screen.getByRole('button', { name: 'Mark complete' })
43
+ expect(toggle).toHaveAttribute('aria-pressed', 'false')
44
+ await userEvent.click(toggle)
45
+ expect(onCompleteChange).toHaveBeenCalledWith(true)
46
+
47
+ view.rerender(
48
+ <DetailHeader title="Slice B" onClose={noop} complete onCompleteChange={onCompleteChange} />,
49
+ )
50
+ expect(screen.getByRole('button', { name: 'Completed' })).toHaveAttribute(
51
+ 'aria-pressed',
52
+ 'true',
53
+ )
54
+ })
55
+
56
+ it('disables the step at the end it has no handler for', () => {
57
+ render(<DetailHeader title="Slice B" onClose={noop} onNext={noop} />)
58
+ expect(screen.getByRole('button', { name: 'Previous item' })).toBeDisabled()
59
+ expect(screen.getByRole('button', { name: 'Next item' })).toBeEnabled()
60
+ })
61
+
62
+ it('edits the title in place, committing on Enter', async () => {
63
+ const onTitleChange = vi.fn()
64
+ const onTitleCommit = vi.fn()
65
+ render(
66
+ <DetailHeader
67
+ title="Slice B"
68
+ onClose={noop}
69
+ onTitleChange={onTitleChange}
70
+ onTitleCommit={onTitleCommit}
71
+ />,
72
+ )
73
+ const field = screen.getByRole('textbox', { name: 'Item title' })
74
+ await userEvent.type(field, '!{Enter}')
75
+ expect(onTitleChange).toHaveBeenCalledWith('Slice B!')
76
+ expect(onTitleCommit).toHaveBeenCalledTimes(1)
77
+ })
78
+
79
+ it('reads out when the item was updated', () => {
80
+ render(<DetailHeader title="Slice B" onClose={noop} updatedAt={NOW - 3_600_000} now={NOW} />)
81
+ expect(document.querySelector('[data-slot="detail-updated"]')).toHaveTextContent('Updated 1h')
82
+ })
83
+ })
84
+
85
+ describe('DetailMenu', () => {
86
+ it('renders only the actions the host supplied, in the brief order', async () => {
87
+ render(
88
+ <DetailMenu
89
+ mode="full"
90
+ onModeChange={noop}
91
+ actions={{ move: { onSelect: noop }, print: { onSelect: noop }, delete: { onSelect: noop } }}
92
+ />,
93
+ )
94
+ await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
95
+ const items = screen.getAllByRole('menuitem').map((item) => item.dataset['action'])
96
+ expect(items).toEqual(['move', 'print', 'delete'])
97
+ // Repeat has no scheduling engine behind it (§7), so it is not drawn.
98
+ expect(screen.queryByRole('menuitem', { name: /Repeat/ })).toBeNull()
99
+ })
100
+
101
+ it('offers the three modes, with the current one pressed', async () => {
102
+ const onModeChange = vi.fn()
103
+ render(<DetailMenu mode="panel" onModeChange={onModeChange} />)
104
+ await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
105
+ expect(screen.getByRole('button', { name: 'Open in side panel' })).toHaveAttribute(
106
+ 'aria-pressed',
107
+ 'true',
108
+ )
109
+ await userEvent.click(screen.getByRole('button', { name: 'Open full screen' }))
110
+ expect(onModeChange).toHaveBeenCalledWith('full')
111
+ })
112
+
113
+ it('asks twice before deleting', async () => {
114
+ const onDelete = vi.fn()
115
+ render(
116
+ <DetailMenu mode="full" onModeChange={noop} actions={{ delete: { onSelect: onDelete } }} />,
117
+ )
118
+ await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
119
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
120
+ expect(onDelete).not.toHaveBeenCalled()
121
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Confirm delete' }))
122
+ expect(onDelete).toHaveBeenCalledTimes(1)
123
+ })
124
+
125
+ it('offers the record id to the clipboard', async () => {
126
+ const onCopy = vi.fn()
127
+ render(
128
+ <DetailMenu mode="full" onModeChange={noop} recordId="itm_123" onCopyRecordId={onCopy} />,
129
+ )
130
+ await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
131
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Copy record id itm_123' }))
132
+ expect(onCopy).toHaveBeenCalledWith('itm_123')
133
+ })
134
+ })