@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,600 @@
1
+ /**
2
+ * LinkPreview — ADR-150 D8, and the two review amendments that gave it teeth.
3
+ *
4
+ * These are written against AC 27 and AC 32, which are the acceptance criteria
5
+ * that describe a DEFECT rather than a feature:
6
+ *
7
+ * AC 27 — the media geometry is reserved before load, an image failure leaves
8
+ * the text card intact, the dismiss button is never nested inside the
9
+ * link, and reduced motion gets the still-frame/play treatment.
10
+ * AC 32 — the still frame renders from a CROSS-ORIGIN image without a
11
+ * `SecurityError`: the canvas is displayed, and no `toDataURL()` /
12
+ * `getImageData()` call exists on that path.
13
+ *
14
+ * AC 32 is the one that cannot be written as "it works". A tainted canvas
15
+ * displays perfectly and throws only when it is READ, so a passing render tells
16
+ * you nothing. Both halves are therefore asserted: the read APIs are spied on
17
+ * and must never fire, and the source of the module is scanned so a future
18
+ * "let's cache the still as a data URL" refactor fails here rather than in a
19
+ * browser someone else is using.
20
+ */
21
+
22
+ import { afterEach, describe, expect, it, vi } from 'vitest'
23
+ import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
24
+ import { LinkPreview, LINK_PREVIEW_DEFAULT_ASPECT } from '../link-preview'
25
+ import { Thread } from '../thread'
26
+ import type { ThreadComment, ThreadLinkPreview } from '../types'
27
+
28
+ afterEach(cleanup)
29
+
30
+ const HASH_A = 'a'.repeat(64)
31
+ const HASH_B = 'b'.repeat(64)
32
+
33
+ function preview(over: Partial<ThreadLinkPreview> = {}): ThreadLinkPreview {
34
+ return {
35
+ urlHash: HASH_A,
36
+ href: 'https://example.com/article#section-3',
37
+ title: 'The one about reserved layout slots',
38
+ domain: 'example.com',
39
+ description: 'A description long enough to want two lines and no more.',
40
+ imageUrl: 'https://cdn.example.com/og.png',
41
+ siteName: 'Example Journal',
42
+ imageAspect: null,
43
+ canHide: true,
44
+ ...over,
45
+ }
46
+ }
47
+
48
+ /**
49
+ * jsdom serializes `aspect-ratio: 1.777` as `1.777 / 1`, so the raw string is
50
+ * an implementation detail of cssstyle rather than anything this component
51
+ * promised. Compare the NUMBER.
52
+ */
53
+ function declaredAspect(el: HTMLElement): number {
54
+ const raw = el.style.aspectRatio
55
+ expect(raw).not.toBe('')
56
+ const [w, h] = raw.split('/')
57
+ return Number(w) / (h === undefined ? 1 : Number(h))
58
+ }
59
+
60
+ /** The media well: the only element carrying a declared aspect ratio. */
61
+ function mediaSlot(): HTMLElement | null {
62
+ return document.querySelector<HTMLElement>('[style*="aspect-ratio"]')
63
+ }
64
+
65
+ function image(): HTMLImageElement {
66
+ const node = document.querySelector('img')
67
+ if (node === null) throw new Error('no preview image')
68
+ return node
69
+ }
70
+
71
+ /** A 2D context that records draws and would SCREAM if anything read it back. */
72
+ function stubCanvas() {
73
+ const drawImage = vi.fn()
74
+ const getImageData = vi.fn(() => {
75
+ throw new Error('getImageData() on a tainted canvas throws SecurityError')
76
+ })
77
+ const context = { drawImage, getImageData } as unknown as CanvasRenderingContext2D
78
+ const getContext = vi
79
+ .spyOn(HTMLCanvasElement.prototype, 'getContext')
80
+ .mockImplementation(() => context)
81
+ const toDataURL = vi
82
+ .spyOn(HTMLCanvasElement.prototype, 'toDataURL')
83
+ .mockImplementation(() => {
84
+ throw new Error('toDataURL() on a tainted canvas throws SecurityError')
85
+ })
86
+ return {
87
+ drawImage,
88
+ getImageData,
89
+ toDataURL,
90
+ restore: () => {
91
+ getContext.mockRestore()
92
+ toDataURL.mockRestore()
93
+ },
94
+ }
95
+ }
96
+
97
+ interface MotionStub {
98
+ /** Flip the preference and notify subscribers, as a real browser would. */
99
+ readonly set: (next: boolean) => void
100
+ readonly restore: () => void
101
+ }
102
+
103
+ /**
104
+ * A `matchMedia` that can actually CHANGE.
105
+ *
106
+ * `use-attachments.test.tsx` collects listeners but never fires them, so it can
107
+ * only test the mount-time value. The preference flipping mid-session is the
108
+ * interesting case here — someone turning reduced motion on while a comment is
109
+ * on screen has asked for the animation to stop NOW — so this one dispatches.
110
+ */
111
+ function stubReducedMotion(initial: boolean): MotionStub {
112
+ const listeners = new Set<(event: MediaQueryListEvent) => void>()
113
+ let reduce = initial
114
+ const original = window.matchMedia
115
+ Object.defineProperty(window, 'matchMedia', {
116
+ configurable: true,
117
+ writable: true,
118
+ value: (queryText: string) => ({
119
+ get matches() {
120
+ return reduce && queryText.includes('prefers-reduced-motion')
121
+ },
122
+ media: queryText,
123
+ addEventListener: (_: string, fn: (event: MediaQueryListEvent) => void) => {
124
+ listeners.add(fn)
125
+ },
126
+ removeEventListener: (_: string, fn: (event: MediaQueryListEvent) => void) => {
127
+ listeners.delete(fn)
128
+ },
129
+ onchange: null,
130
+ addListener: () => undefined,
131
+ removeListener: () => undefined,
132
+ dispatchEvent: () => false,
133
+ }),
134
+ })
135
+ return {
136
+ set: (next: boolean) => {
137
+ reduce = next
138
+ act(() => {
139
+ for (const fn of [...listeners]) fn({ matches: next } as MediaQueryListEvent)
140
+ })
141
+ },
142
+ restore: () => {
143
+ Object.defineProperty(window, 'matchMedia', {
144
+ configurable: true,
145
+ writable: true,
146
+ value: original,
147
+ })
148
+ },
149
+ }
150
+ }
151
+
152
+ /** Fake a decoded image so `captureStill` has real dimensions to draw. */
153
+ function markLoaded(img: HTMLImageElement, width = 800, height = 450) {
154
+ Object.defineProperties(img, {
155
+ naturalWidth: { configurable: true, value: width },
156
+ naturalHeight: { configurable: true, value: height },
157
+ complete: { configurable: true, value: true },
158
+ })
159
+ fireEvent.load(img)
160
+ }
161
+
162
+ describe('AC 27 - the media slot is declared before the bytes and kept when they never arrive', () => {
163
+ it('reserves 16:9 when the page declared no usable dimensions', () => {
164
+ render(<LinkPreview preview={preview({ imageAspect: null })} />)
165
+ const slot = mediaSlot()
166
+ expect(slot).not.toBeNull()
167
+ expect(declaredAspect(slot as HTMLElement)).toBeCloseTo(LINK_PREVIEW_DEFAULT_ASPECT, 10)
168
+ })
169
+
170
+ it('uses the declared ratio when the page supplied one', () => {
171
+ render(<LinkPreview preview={preview({ imageAspect: 1.25 })} />)
172
+ expect(declaredAspect(mediaSlot() as HTMLElement)).toBeCloseTo(1.25, 10)
173
+ })
174
+
175
+ it.each([
176
+ ['zero', 0, LINK_PREVIEW_DEFAULT_ASPECT],
177
+ ['negative', -4, LINK_PREVIEW_DEFAULT_ASPECT],
178
+ ['not a number', Number.NaN, LINK_PREVIEW_DEFAULT_ASPECT],
179
+ ['infinite', Number.POSITIVE_INFINITY, LINK_PREVIEW_DEFAULT_ASPECT],
180
+ // A hairline and a needle: clamped rather than allowed to collapse the row.
181
+ ['absurdly wide', 1000, 10],
182
+ ['absurdly tall', 0.0001, 0.1],
183
+ ])('refuses a %s ratio and still declares a usable box', (_label, declared, expected) => {
184
+ render(<LinkPreview preview={preview({ imageAspect: declared })} />)
185
+ expect(declaredAspect(mediaSlot() as HTMLElement)).toBeCloseTo(expected, 10)
186
+ cleanup()
187
+ })
188
+
189
+ it('keeps the SAME reserved box when the image fails, and keeps the text', () => {
190
+ render(<LinkPreview preview={preview({ imageAspect: 1.25 })} />)
191
+ const before = declaredAspect(mediaSlot() as HTMLElement)
192
+
193
+ fireEvent.error(image())
194
+
195
+ const after = mediaSlot()
196
+ expect(after).not.toBeNull()
197
+ // The whole point: a dead third-party CDN must not move the thread.
198
+ expect(declaredAspect(after as HTMLElement)).toBeCloseTo(before, 10)
199
+ expect(document.querySelector('img')).toBeNull()
200
+ expect(screen.getByText('The one about reserved layout slots')).toBeInTheDocument()
201
+ expect(
202
+ screen.getByText('A description long enough to want two lines and no more.'),
203
+ ).toBeInTheDocument()
204
+ expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com/article#section-3')
205
+ })
206
+
207
+ it('declares no media slot at all when the row carries no image', () => {
208
+ render(<LinkPreview preview={preview({ imageUrl: null })} />)
209
+ expect(mediaSlot()).toBeNull()
210
+ expect(document.querySelector('img')).toBeNull()
211
+ // A text card is still a card.
212
+ expect(screen.getByRole('link')).toBeInTheDocument()
213
+ })
214
+ })
215
+
216
+ describe('AC 27 - the dismiss button is a SIBLING of the link, never a child', () => {
217
+ it('renders both controls and nests neither inside the other', () => {
218
+ render(<LinkPreview preview={preview()} onHide={vi.fn()} />)
219
+ const link = screen.getByRole('link')
220
+ const dismiss = screen.getByRole('button', {
221
+ name: 'Dismiss link preview: The one about reserved layout slots',
222
+ })
223
+ // Invalid HTML and unusable from a keyboard. Assert it in both directions
224
+ // so neither element can be moved inside the other later.
225
+ expect(link.contains(dismiss)).toBe(false)
226
+ expect(dismiss.contains(link)).toBe(false)
227
+ expect(within(link).queryByRole('button')).toBeNull()
228
+ expect(link.querySelector('a')).toBeNull()
229
+ })
230
+
231
+ it('opens in a new tab without handing the opener over', () => {
232
+ render(<LinkPreview preview={preview()} />)
233
+ const link = screen.getByRole('link')
234
+ expect(link).toHaveAttribute('target', '_blank')
235
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer')
236
+ })
237
+
238
+ it('reports the preview it was asked to hide', () => {
239
+ const onHide = vi.fn()
240
+ const row = preview()
241
+ render(<LinkPreview preview={row} onHide={onHide} />)
242
+ fireEvent.click(
243
+ screen.getByRole('button', {
244
+ name: 'Dismiss link preview: The one about reserved layout slots',
245
+ }),
246
+ )
247
+ expect(onHide).toHaveBeenCalledTimes(1)
248
+ expect(onHide).toHaveBeenCalledWith(row)
249
+ })
250
+
251
+ it('renders no dismiss control when the viewer cannot hide it', () => {
252
+ render(<LinkPreview preview={preview({ canHide: false })} onHide={vi.fn()} />)
253
+ expect(screen.queryByRole('button')).toBeNull()
254
+ })
255
+
256
+ it('renders no dismiss control when the host wired no mutation', () => {
257
+ // An affordance that silently does nothing is worse than no affordance.
258
+ render(<LinkPreview preview={preview({ canHide: true })} />)
259
+ expect(screen.queryByRole('button')).toBeNull()
260
+ })
261
+ })
262
+
263
+ describe('AC 27 / AC 32 - reduced motion gets a drawn still, never a read-back one', () => {
264
+ it('hides the live image behind a captured frame with an explicit play control', () => {
265
+ const motion = stubReducedMotion(true)
266
+ const canvas = stubCanvas()
267
+ try {
268
+ render(<LinkPreview preview={preview()} />)
269
+ const img = image()
270
+ // Hidden from the first paint, so an animated og:image is never seen by
271
+ // someone who asked for less motion.
272
+ expect(img.style.visibility).toBe('hidden')
273
+
274
+ const still = document.querySelector('canvas')
275
+ expect(still).not.toBeNull()
276
+ expect(still).toHaveAttribute('hidden')
277
+
278
+ markLoaded(img)
279
+
280
+ expect(canvas.drawImage).toHaveBeenCalledTimes(1)
281
+ expect(canvas.drawImage).toHaveBeenCalledWith(img, 0, 0, 800, 450)
282
+ expect(still).toHaveProperty('width', 800)
283
+ expect(still).toHaveProperty('height', 450)
284
+ // DISPLAYED. That is legal on a tainted canvas and is the whole design.
285
+ expect(still).not.toHaveAttribute('hidden')
286
+
287
+ // A second load notification must not re-snapshot: the still is stable,
288
+ // not a second frame of the animation.
289
+ markLoaded(img)
290
+ expect(canvas.drawImage).toHaveBeenCalledTimes(1)
291
+
292
+ const play = screen.getByRole('button', { name: 'Play preview image from example.com' })
293
+ fireEvent.click(play)
294
+ expect(
295
+ screen.queryByRole('button', { name: 'Play preview image from example.com' }),
296
+ ).toBeNull()
297
+ expect(image().style.visibility).toBe('visible')
298
+ expect(document.querySelector('canvas')).toHaveAttribute('hidden')
299
+ } finally {
300
+ canvas.restore()
301
+ motion.restore()
302
+ }
303
+ })
304
+
305
+ it('never reads the tainted canvas back', () => {
306
+ const motion = stubReducedMotion(true)
307
+ const canvas = stubCanvas()
308
+ try {
309
+ render(<LinkPreview preview={preview()} />)
310
+ markLoaded(image())
311
+ // Both throw `SecurityError` in a browser the moment a cross-origin
312
+ // image has been drawn. Reaching either is the bug this asserts against.
313
+ expect(canvas.toDataURL).not.toHaveBeenCalled()
314
+ expect(canvas.getImageData).not.toHaveBeenCalled()
315
+ } finally {
316
+ canvas.restore()
317
+ motion.restore()
318
+ }
319
+ })
320
+
321
+ it('never sets crossOrigin, which would break the image on most of the web', () => {
322
+ const motion = stubReducedMotion(true)
323
+ const canvas = stubCanvas()
324
+ try {
325
+ render(<LinkPreview preview={preview()} />)
326
+ const img = image()
327
+ // Setting it would untaint the canvas and make every origin that sends
328
+ // no CORS header fail to load at all (D4, review amendment).
329
+ expect(img.hasAttribute('crossorigin')).toBe(false)
330
+ expect(img.crossOrigin).toBeNull()
331
+ } finally {
332
+ canvas.restore()
333
+ motion.restore()
334
+ }
335
+ })
336
+
337
+ it('captures and covers the image when the preference is turned on MID-SESSION', () => {
338
+ const motion = stubReducedMotion(false)
339
+ const canvas = stubCanvas()
340
+ try {
341
+ render(<LinkPreview preview={preview()} />)
342
+ const img = image()
343
+ markLoaded(img)
344
+ expect(canvas.drawImage).not.toHaveBeenCalled()
345
+ expect(img.style.visibility).toBe('visible')
346
+
347
+ // Somebody just asked for less motion. An og:image that keeps animating
348
+ // from here has ignored them at the moment they asked.
349
+ motion.set(true)
350
+
351
+ expect(canvas.drawImage).toHaveBeenCalledTimes(1)
352
+ expect(image().style.visibility).toBe('hidden')
353
+ expect(document.querySelector('canvas')).not.toHaveAttribute('hidden')
354
+ expect(
355
+ screen.getByRole('button', { name: 'Play preview image from example.com' }),
356
+ ).toBeInTheDocument()
357
+ } finally {
358
+ canvas.restore()
359
+ motion.restore()
360
+ }
361
+ })
362
+
363
+ it('stops a playing image again when the preference is turned back on', () => {
364
+ const motion = stubReducedMotion(true)
365
+ const canvas = stubCanvas()
366
+ try {
367
+ render(<LinkPreview preview={preview()} />)
368
+ markLoaded(image())
369
+ fireEvent.click(screen.getByRole('button', { name: 'Play preview image from example.com' }))
370
+ expect(image().style.visibility).toBe('visible')
371
+
372
+ motion.set(false)
373
+ motion.set(true)
374
+
375
+ // The explicit "play" was a choice about one moment, not a standing
376
+ // opt-out of the preference.
377
+ expect(image().style.visibility).toBe('hidden')
378
+ expect(
379
+ screen.getByRole('button', { name: 'Play preview image from example.com' }),
380
+ ).toBeInTheDocument()
381
+ } finally {
382
+ canvas.restore()
383
+ motion.restore()
384
+ }
385
+ })
386
+
387
+ it('autoplays with no play control when motion is not restricted', () => {
388
+ const motion = stubReducedMotion(false)
389
+ const canvas = stubCanvas()
390
+ try {
391
+ render(<LinkPreview preview={preview()} />)
392
+ expect(image().style.visibility).toBe('visible')
393
+ markLoaded(image())
394
+ expect(canvas.drawImage).not.toHaveBeenCalled()
395
+ expect(
396
+ screen.queryByRole('button', { name: 'Play preview image from example.com' }),
397
+ ).toBeNull()
398
+ } finally {
399
+ canvas.restore()
400
+ motion.restore()
401
+ }
402
+ })
403
+ })
404
+
405
+ describe('D4 privacy + D8 display rules', () => {
406
+ it('sends no referrer and loads lazily, with a decorative alt', () => {
407
+ render(<LinkPreview preview={preview()} />)
408
+ const img = image()
409
+ expect(img).toHaveAttribute('referrerpolicy', 'no-referrer')
410
+ expect(img).toHaveAttribute('loading', 'lazy')
411
+ // The page supplied no trustworthy description of its own image.
412
+ expect(img.getAttribute('alt')).toBe('')
413
+ })
414
+
415
+ it('leads with the worker-derived domain, not the page-chosen site name', () => {
416
+ render(<LinkPreview preview={preview()} />)
417
+ const link = screen.getByRole('link')
418
+ const text = link.textContent ?? ''
419
+ // "Your Bank - evil.example" is a very different claim from the reverse.
420
+ expect(text.indexOf('example.com')).toBeLessThan(text.indexOf('Example Journal'))
421
+ })
422
+
423
+ it('does not repeat the site name when it is only the domain again', () => {
424
+ render(<LinkPreview preview={preview({ siteName: 'Example.com' })} />)
425
+ const link = screen.getByRole('link')
426
+ expect((link.textContent ?? '').match(/example\.com/gi)).toHaveLength(1)
427
+ })
428
+
429
+ it('renders no empty line and no orphan separator for whitespace-only fields', () => {
430
+ render(<LinkPreview preview={preview({ description: ' ', siteName: ' ' })} />)
431
+ const link = screen.getByRole('link')
432
+ expect(link.textContent).not.toContain('\u00B7')
433
+ // The description slot is absent rather than present-and-empty: an empty
434
+ // <p> would reserve a line of nothing under the title.
435
+ expect(document.querySelectorAll('p')).toHaveLength(0)
436
+ })
437
+
438
+ it('renders a title-only card when description, image and site name are all absent', () => {
439
+ render(
440
+ <LinkPreview
441
+ preview={preview({ description: null, imageUrl: null, siteName: null })}
442
+ />,
443
+ )
444
+ expect(screen.getByText('The one about reserved layout slots')).toBeInTheDocument()
445
+ expect(mediaSlot()).toBeNull()
446
+ })
447
+
448
+ it('clamps the description to two lines rather than letting it run', () => {
449
+ render(<LinkPreview preview={preview()} />)
450
+ const description = screen.getByText(
451
+ 'A description long enough to want two lines and no more.',
452
+ )
453
+ expect(description.className).toContain('line-clamp-2')
454
+ })
455
+
456
+ it('falls back to the domain when a row somehow carries a blank title', () => {
457
+ // D5 makes a non-empty title the condition for an `ok` row, so this is
458
+ // defence rather than a feature: a blank one would otherwise produce a link
459
+ // with no accessible name and a dismiss labelled "Dismiss link preview: ".
460
+ render(<LinkPreview preview={preview({ title: ' ' })} onHide={vi.fn()} />)
461
+ expect(screen.getByRole('link')).toHaveAccessibleName(/example\.com/)
462
+ expect(
463
+ screen.getByRole('button', { name: 'Dismiss link preview: example.com' }),
464
+ ).toBeInTheDocument()
465
+ })
466
+
467
+ it('drops a stale broken/played state when a refresh swaps the image', () => {
468
+ // D6: a stale `ok` row keeps rendering while a refresh runs, so the same
469
+ // card (keyed by URL hash) can be handed a new og:image without unmounting.
470
+ const { rerender } = render(<LinkPreview preview={preview()} />)
471
+ fireEvent.error(image())
472
+ expect(document.querySelector('img')).toBeNull()
473
+
474
+ rerender(<LinkPreview preview={preview({ imageUrl: 'https://cdn.example.com/og-2.png' })} />)
475
+
476
+ const refreshed = image()
477
+ expect(refreshed).toHaveAttribute('src', 'https://cdn.example.com/og-2.png')
478
+ expect(mediaSlot()).not.toBeNull()
479
+ })
480
+
481
+ it('renders the strings as TEXT, never as markup', () => {
482
+ const hostile = '<img src=x onerror="alert(1)">'
483
+ render(<LinkPreview preview={preview({ title: hostile, description: hostile })} />)
484
+ // React escaped both; the only <img> is the og:image itself.
485
+ expect(document.querySelectorAll('img')).toHaveLength(1)
486
+ expect(screen.getAllByText(hostile).length).toBeGreaterThan(0)
487
+ })
488
+ })
489
+
490
+ /* ── the seam: one renderer for roots and replies ───────────────────────── */
491
+
492
+ const NOW = Date.UTC(2026, 8, 8, 12, 0, 0)
493
+
494
+ function comment(over: Partial<ThreadComment> = {}): ThreadComment {
495
+ return {
496
+ id: 'c_root',
497
+ bodyMd: 'Have a look at https://example.com/article',
498
+ createdAt: NOW - 60_000,
499
+ author: { id: 'u_1', name: 'Dana' },
500
+ ...over,
501
+ }
502
+ }
503
+
504
+ function commentBox(id: string): HTMLElement {
505
+ const node = document.querySelector<HTMLElement>(`[data-comment-id="${id}"]`)
506
+ if (node === null) throw new Error(`no comment ${id}`)
507
+ return node
508
+ }
509
+
510
+ describe('AC 27 / D8 - root and reply comments use the same renderer and callback seam', () => {
511
+ const COMMENTS: readonly ThreadComment[] = [
512
+ comment({ id: 'c_root', linkPreviews: [preview({ title: 'Root card' })] }),
513
+ comment({
514
+ id: 'c_reply',
515
+ parentId: 'c_root',
516
+ linkPreviews: [preview({ urlHash: HASH_B, title: 'Reply card' })],
517
+ }),
518
+ ]
519
+
520
+ it('renders a card on a root AND on a reply', () => {
521
+ render(<Thread comments={COMMENTS} now={NOW} />)
522
+ expect(within(commentBox('c_root')).getByText('Root card')).toBeInTheDocument()
523
+ expect(within(commentBox('c_reply')).getByText('Reply card')).toBeInTheDocument()
524
+ })
525
+
526
+ it('hands the host the comment id and the url hash from either level', () => {
527
+ const onHideLinkPreview = vi.fn()
528
+ render(<Thread comments={COMMENTS} now={NOW} onHideLinkPreview={onHideLinkPreview} />)
529
+
530
+ fireEvent.click(
531
+ within(commentBox('c_root')).getByRole('button', {
532
+ name: 'Dismiss link preview: Root card',
533
+ }),
534
+ )
535
+ fireEvent.click(
536
+ within(commentBox('c_reply')).getByRole('button', {
537
+ name: 'Dismiss link preview: Reply card',
538
+ }),
539
+ )
540
+
541
+ expect(onHideLinkPreview.mock.calls).toEqual([
542
+ ['c_root', HASH_A],
543
+ ['c_reply', HASH_B],
544
+ ])
545
+ })
546
+
547
+ it('renders every card the worker sent, in the order it sent them', () => {
548
+ render(
549
+ <Thread
550
+ comments={[
551
+ comment({
552
+ id: 'c_root',
553
+ linkPreviews: [
554
+ preview({ urlHash: HASH_A, title: 'First' }),
555
+ preview({ urlHash: HASH_B, title: 'Second' }),
556
+ preview({ urlHash: 'c'.repeat(64), title: 'Third' }),
557
+ ],
558
+ }),
559
+ ]}
560
+ now={NOW}
561
+ />,
562
+ )
563
+ // Scoped to the card TITLES: the comment body autolinks its own bare URL,
564
+ // so `getAllByRole('link')` inside a comment box legitimately includes a
565
+ // link that is not a preview.
566
+ const titles = within(commentBox('c_root'))
567
+ .getAllByText(/^(?:First|Second|Third)$/)
568
+ .map((el) => el.textContent)
569
+ expect(titles).toEqual(['First', 'Second', 'Third'])
570
+ })
571
+
572
+ it('shows no cards on a tombstone', () => {
573
+ render(
574
+ <Thread
575
+ comments={[
576
+ comment({
577
+ id: 'c_root',
578
+ deletedAt: NOW - 1_000,
579
+ linkPreviews: [preview({ title: 'Should not survive a delete' })],
580
+ }),
581
+ ]}
582
+ now={NOW}
583
+ />,
584
+ )
585
+ expect(screen.queryByText('Should not survive a delete')).toBeNull()
586
+ expect(document.querySelector('canvas')).toBeNull()
587
+ // The thread says "[deleted]" in more than one place (the body and the
588
+ // byline), so this asserts presence rather than uniqueness.
589
+ expect(screen.getAllByText('[deleted]').length).toBeGreaterThan(0)
590
+ })
591
+
592
+ it('renders nothing extra for a comment with no previews', () => {
593
+ render(<Thread comments={[comment({ id: 'c_root' })]} now={NOW} />)
594
+ const box = within(commentBox('c_root'))
595
+ // The body still autolinks its own URL — that is markdown, not a card.
596
+ expect(box.getByRole('link')).toHaveTextContent('https://example.com/article')
597
+ expect(mediaSlot()).toBeNull()
598
+ expect(box.queryByText('The one about reserved layout slots')).toBeNull()
599
+ })
600
+ })
@@ -0,0 +1,82 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * ADR-147 D7 — the toolbar wires real behaviour or it is not rendered.
4
+ *
5
+ * The composer's toolbar is exactly this function plus a caret restore, so
6
+ * these tests ARE the toolbar's contract. The reference's decorative toolbar
7
+ * is the anti-pattern being replaced, and a test suite is the difference
8
+ * between "the buttons do something" and "the buttons look like they do".
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest'
12
+ import { applyMarkdownFormat } from '../markdown-format'
13
+
14
+ describe('applyMarkdownFormat — wrappers', () => {
15
+ it('wraps the selection and keeps it selected', () => {
16
+ const edit = applyMarkdownFormat('make this bold', 10, 14, 'bold')
17
+ expect(edit.value).toBe('make this **bold**')
18
+ expect(edit.value.slice(edit.selectionStart, edit.selectionEnd)).toBe('bold')
19
+ })
20
+
21
+ it('leaves the caret between the markers on an empty selection', () => {
22
+ const edit = applyMarkdownFormat('', 0, 0, 'bold')
23
+ expect(edit.value).toBe('****')
24
+ expect(edit.selectionStart).toBe(2)
25
+ expect(edit.selectionEnd).toBe(2)
26
+ })
27
+
28
+ it('unwraps when the markers are INSIDE the selection', () => {
29
+ const edit = applyMarkdownFormat('a **bold** b', 2, 10, 'bold')
30
+ expect(edit.value).toBe('a bold b')
31
+ expect(edit.value.slice(edit.selectionStart, edit.selectionEnd)).toBe('bold')
32
+ })
33
+
34
+ it('unwraps when the markers are OUTSIDE the selection', () => {
35
+ const edit = applyMarkdownFormat('a **bold** b', 4, 8, 'bold')
36
+ expect(edit.value).toBe('a bold b')
37
+ expect(edit.value.slice(edit.selectionStart, edit.selectionEnd)).toBe('bold')
38
+ })
39
+
40
+ it('uses _ for italic and a backtick for code', () => {
41
+ expect(applyMarkdownFormat('x', 0, 1, 'italic').value).toBe('_x_')
42
+ expect(applyMarkdownFormat('x', 0, 1, 'code').value).toBe('`x`')
43
+ })
44
+ })
45
+
46
+ describe('applyMarkdownFormat — link', () => {
47
+ it('makes the selection the label and selects the url placeholder', () => {
48
+ const edit = applyMarkdownFormat('see the plan', 8, 12, 'link')
49
+ expect(edit.value).toBe('see the [plan](url)')
50
+ expect(edit.value.slice(edit.selectionStart, edit.selectionEnd)).toBe('url')
51
+ })
52
+
53
+ it('puts the caret inside the brackets when nothing is selected', () => {
54
+ const edit = applyMarkdownFormat('', 0, 0, 'link')
55
+ expect(edit.value).toBe('[](url)')
56
+ expect(edit.selectionStart).toBe(1)
57
+ expect(edit.selectionEnd).toBe(1)
58
+ })
59
+ })
60
+
61
+ describe('applyMarkdownFormat — list', () => {
62
+ it('prefixes every line the selection touches, not just the selected words', () => {
63
+ const edit = applyMarkdownFormat('one\ntwo\nthree', 1, 6, 'list')
64
+ expect(edit.value).toBe('- one\n- two\nthree')
65
+ })
66
+
67
+ it('toggles the prefix back off', () => {
68
+ const edit = applyMarkdownFormat('- one\n- two', 0, 11, 'list')
69
+ expect(edit.value).toBe('one\ntwo')
70
+ })
71
+
72
+ it('leaves blank lines alone', () => {
73
+ const edit = applyMarkdownFormat('one\n\ntwo', 0, 8, 'list')
74
+ expect(edit.value).toBe('- one\n\n- two')
75
+ })
76
+ })
77
+
78
+ describe('applyMarkdownFormat — robustness', () => {
79
+ it('clamps an out-of-range or reversed selection instead of slicing wildly', () => {
80
+ expect(applyMarkdownFormat('abc', 99, -4, 'bold').value).toBe('**abc**')
81
+ })
82
+ })