@linktr.ee/messaging-react 4.2.0-rc-1787083791 → 4.2.0-rc-1787181113

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 (71) hide show
  1. package/dist/{Card-B4lMv-0o.js → Card-BOudQBd6.js} +2 -2
  2. package/dist/{Card-B4lMv-0o.js.map → Card-BOudQBd6.js.map} +1 -1
  3. package/dist/{Card-DWL5I62S.cjs → Card-k2959zNX.cjs} +2 -2
  4. package/dist/{Card-DWL5I62S.cjs.map → Card-k2959zNX.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-C93LTGLc.js → index-6RdyZh6F.js} +1809 -1912
  7. package/dist/index-6RdyZh6F.js.map +1 -0
  8. package/dist/index-BcU18EjV.cjs +3 -0
  9. package/dist/index-BcU18EjV.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +78 -59
  12. package/dist/index.js +30 -26
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.js +2 -2
  16. package/dist/testing.js.map +1 -1
  17. package/package.json +2 -2
  18. package/src/components/AttachmentCard/MediaPlayer.test.tsx +346 -0
  19. package/src/components/AttachmentCard/Thumbnail.test.tsx +215 -0
  20. package/src/components/AttachmentCard/index.test.tsx +160 -0
  21. package/src/components/Avatar/Avatar.test.tsx +126 -0
  22. package/src/components/Avatar/getAvatarEmoji.test.ts +39 -0
  23. package/src/components/ChannelList/ChannelListContext.test.tsx +87 -0
  24. package/src/components/ChannelList/CustomChannelPreview.test.tsx +410 -3
  25. package/src/components/ChannelList/index.test.tsx +132 -1
  26. package/src/components/CustomMessage/CustomMessage.stories.tsx +6 -314
  27. package/src/components/CustomMessage/CustomMessage.test.tsx +56 -2
  28. package/src/components/CustomMessage/Locked.stories.tsx +108 -0
  29. package/src/components/CustomMessage/MessageTag.stories.tsx +17 -53
  30. package/src/components/CustomMessage/MessageTag.test.tsx +7 -40
  31. package/src/components/CustomMessage/MessageTag.tsx +7 -39
  32. package/src/components/CustomMessage/PaidMessage.stories.tsx +42 -0
  33. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +8 -7
  34. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +2 -1
  35. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
  36. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -6
  37. package/src/components/CustomMessage/Tip.stories.tsx +48 -0
  38. package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +51 -0
  39. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +65 -0
  40. package/src/components/CustomMessage/TipMessage/index.tsx +27 -0
  41. package/src/components/CustomMessage/index.tsx +22 -19
  42. package/src/components/FaqList/FaqList.test.tsx +204 -0
  43. package/src/components/LinkAttachment/LinkAttachment.test.tsx +327 -0
  44. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +76 -0
  45. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +248 -0
  46. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +123 -0
  47. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +193 -0
  48. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +242 -0
  49. package/src/components/LinkAttachment/components/_shared/hex.test.ts +37 -0
  50. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +72 -0
  51. package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +162 -0
  52. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +58 -165
  53. package/src/components/MessageAttachment/Audio/index.tsx +77 -71
  54. package/src/components/MessageAttachment/MessageAttachment.test.tsx +74 -176
  55. package/src/components/MessageAttachment/index.tsx +9 -25
  56. package/src/components/MessageAttachment/types.ts +9 -19
  57. package/src/components/MessagingShell/MessagingShell.test.tsx +20 -0
  58. package/src/components/MessagingShell/index.tsx +2 -0
  59. package/src/index.ts +4 -0
  60. package/src/logging/index.test.tsx +136 -0
  61. package/src/logging/index.tsx +48 -0
  62. package/src/providers/MessagingProvider.test.tsx +113 -1
  63. package/src/providers/MessagingProvider.tsx +25 -15
  64. package/src/stories/customMessageStoryTemplate.tsx +199 -0
  65. package/src/styles.css +10 -121
  66. package/src/testing/createMockMessagingClient.ts +2 -2
  67. package/src/types.ts +9 -0
  68. package/dist/index-B-9LkWep.cjs +0 -3
  69. package/dist/index-B-9LkWep.cjs.map +0 -1
  70. package/dist/index-C93LTGLc.js.map +0 -1
  71. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +0 -335
@@ -0,0 +1,242 @@
1
+ import React from 'react'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { renderWithProviders, screen, fireEvent } from '../../../../test/utils'
5
+
6
+ import CardThumbnail, {
7
+ AUDIO_BG_CLASS,
8
+ isPlayableAudio,
9
+ isPlayableMedia,
10
+ } from './CardThumbnail'
11
+
12
+ const AUDIO_SRC = 'https://cdn.example.com/clip.mp3'
13
+ const VIDEO_SRC = 'https://cdn.example.com/clip.mp4'
14
+ const IMAGE_SRC = 'https://cdn.example.com/cover.jpg'
15
+
16
+ describe('isPlayableAudio', () => {
17
+ it('needs both an audio mime type and a source url', () => {
18
+ expect(isPlayableAudio('audio/mpeg', AUDIO_SRC)).toBe(true)
19
+ expect(isPlayableAudio('audio/mpeg', undefined)).toBe(false)
20
+ expect(isPlayableAudio('audio/mpeg', '')).toBe(false)
21
+ expect(isPlayableAudio(undefined, AUDIO_SRC)).toBe(false)
22
+ expect(isPlayableAudio('', AUDIO_SRC)).toBe(false)
23
+ })
24
+
25
+ it('rejects non-audio mime types', () => {
26
+ expect(isPlayableAudio('video/mp4', VIDEO_SRC)).toBe(false)
27
+ expect(isPlayableAudio('image/png', IMAGE_SRC)).toBe(false)
28
+ expect(isPlayableAudio('application/pdf', IMAGE_SRC)).toBe(false)
29
+ })
30
+ })
31
+
32
+ describe('isPlayableMedia', () => {
33
+ it('covers both playable shapes and nothing else', () => {
34
+ expect(isPlayableMedia('video/mp4', VIDEO_SRC)).toBe(true)
35
+ expect(isPlayableMedia('audio/mpeg', AUDIO_SRC)).toBe(true)
36
+ expect(isPlayableMedia('image/png', IMAGE_SRC)).toBe(false)
37
+ expect(isPlayableMedia('application/pdf', IMAGE_SRC)).toBe(false)
38
+ })
39
+
40
+ it('needs both halves of the pair', () => {
41
+ expect(isPlayableMedia('video/mp4', undefined)).toBe(false)
42
+ expect(isPlayableMedia('video/mp4', '')).toBe(false)
43
+ expect(isPlayableMedia(undefined, VIDEO_SRC)).toBe(false)
44
+ expect(isPlayableMedia('', VIDEO_SRC)).toBe(false)
45
+ })
46
+ })
47
+
48
+ describe('AUDIO_BG_CLASS', () => {
49
+ it('is a verbatim literal so Tailwind can find it when scanning dist', () => {
50
+ expect(AUDIO_BG_CLASS).toBe('bg-[#F2F3F4]')
51
+ })
52
+ })
53
+
54
+ describe('CardThumbnail', () => {
55
+ it('collapses the hero to a padded native audio player', () => {
56
+ const { container } = renderWithProviders(
57
+ <CardThumbnail
58
+ variant="dark"
59
+ mimeType="audio/mpeg"
60
+ sourceUrl={AUDIO_SRC}
61
+ thumbnailUrl={IMAGE_SRC}
62
+ />
63
+ )
64
+ const audio = container.querySelector('audio')
65
+ expect(audio).not.toBeNull()
66
+ expect(audio?.getAttribute('src')).toBe(AUDIO_SRC)
67
+ expect(audio?.hasAttribute('controls')).toBe(true)
68
+ expect(audio?.getAttribute('preload')).toBe('metadata')
69
+ // The hero region (and its artwork) is gone entirely on this branch.
70
+ expect(container.querySelectorAll('img')).toHaveLength(0)
71
+ expect(container.querySelector('div.flex-1')).toBeNull()
72
+ })
73
+
74
+ it('keeps the skeleton instead of an audio player while loading', () => {
75
+ const { container } = renderWithProviders(
76
+ <CardThumbnail
77
+ variant="dark"
78
+ mimeType="audio/mpeg"
79
+ sourceUrl={AUDIO_SRC}
80
+ isLoading
81
+ />
82
+ )
83
+ expect(container.querySelector('audio')).toBeNull()
84
+ expect(container.querySelector('div.animate-pulse')).not.toBeNull()
85
+ })
86
+
87
+ it('renders a video player with the poster over a black hero', () => {
88
+ const { container } = renderWithProviders(
89
+ <CardThumbnail
90
+ variant="light"
91
+ mimeType="video/mp4"
92
+ sourceUrl={VIDEO_SRC}
93
+ thumbnailUrl={IMAGE_SRC}
94
+ title="Clip"
95
+ onImageError={() => {}}
96
+ />
97
+ )
98
+ const video = container.querySelector('video')
99
+ expect(video?.getAttribute('src')).toBe(VIDEO_SRC)
100
+ expect(video?.getAttribute('poster')).toBe(IMAGE_SRC)
101
+ expect(video?.hasAttribute('controls')).toBe(true)
102
+ expect(video?.getAttribute('preload')).toBe('metadata')
103
+ expect(container.querySelector('div.flex-1')?.className).toContain(
104
+ 'bg-black'
105
+ )
106
+ })
107
+
108
+ it('probes the video poster with a hidden image that reports failures', () => {
109
+ const onImageError = vi.fn()
110
+ const { container } = renderWithProviders(
111
+ <CardThumbnail
112
+ variant="light"
113
+ mimeType="video/mp4"
114
+ sourceUrl={VIDEO_SRC}
115
+ thumbnailUrl={IMAGE_SRC}
116
+ onImageError={onImageError}
117
+ />
118
+ )
119
+ const probe = container.querySelector('img')
120
+ expect(probe).not.toBeNull()
121
+ expect(probe?.className).toContain('hidden')
122
+ expect(probe?.getAttribute('aria-hidden')).toBe('true')
123
+ fireEvent.error(probe as HTMLImageElement)
124
+ expect(onImageError).toHaveBeenCalledTimes(1)
125
+ })
126
+
127
+ it('skips the poster probe when there is nothing to report to', () => {
128
+ const { container: noHandler } = renderWithProviders(
129
+ <CardThumbnail
130
+ variant="light"
131
+ mimeType="video/mp4"
132
+ sourceUrl={VIDEO_SRC}
133
+ thumbnailUrl={IMAGE_SRC}
134
+ />
135
+ )
136
+ expect(noHandler.querySelectorAll('img')).toHaveLength(0)
137
+
138
+ const { container: noPoster } = renderWithProviders(
139
+ <CardThumbnail
140
+ variant="light"
141
+ mimeType="video/mp4"
142
+ sourceUrl={VIDEO_SRC}
143
+ onImageError={() => {}}
144
+ />
145
+ )
146
+ expect(noPoster.querySelectorAll('img')).toHaveLength(0)
147
+ expect(noPoster.querySelector('video')?.getAttribute('poster')).toBeNull()
148
+ })
149
+
150
+ it('renders the thumbnail image with the title as its alt text', () => {
151
+ const { container } = renderWithProviders(
152
+ <CardThumbnail
153
+ variant="light"
154
+ thumbnailUrl={IMAGE_SRC}
155
+ title="Cover art"
156
+ />
157
+ )
158
+ const image = container.querySelector('img')
159
+ expect(image?.getAttribute('src')).toBe(IMAGE_SRC)
160
+ expect(image?.getAttribute('alt')).toBe('Cover art')
161
+ expect(image?.getAttribute('loading')).toBe('lazy')
162
+ expect(image?.getAttribute('decoding')).toBe('async')
163
+ expect(image?.getAttribute('draggable')).toBe('false')
164
+ })
165
+
166
+ it('falls back to empty alt text when the card has no title', () => {
167
+ const { container } = renderWithProviders(
168
+ <CardThumbnail variant="light" thumbnailUrl={IMAGE_SRC} />
169
+ )
170
+ expect(container.querySelector('img')?.getAttribute('alt')).toBe('')
171
+ })
172
+
173
+ it('reports a failing hero image to its owner', () => {
174
+ const onImageError = vi.fn()
175
+ const { container } = renderWithProviders(
176
+ <CardThumbnail
177
+ variant="light"
178
+ thumbnailUrl={IMAGE_SRC}
179
+ onImageError={onImageError}
180
+ />
181
+ )
182
+ fireEvent.error(container.querySelector('img') as HTMLImageElement)
183
+ expect(onImageError).toHaveBeenCalledTimes(1)
184
+ })
185
+
186
+ it('shows a variant-aware type-icon placeholder without a thumbnail', () => {
187
+ const { container: dark } = renderWithProviders(
188
+ <CardThumbnail variant="dark" mimeType="application/pdf" />
189
+ )
190
+ expect(dark.querySelectorAll('img')).toHaveLength(0)
191
+ const darkPlaceholder = dark.querySelector('div.items-center')
192
+ expect(darkPlaceholder?.className).toContain('bg-white/10')
193
+ expect(darkPlaceholder?.querySelector('svg')?.getAttribute('class')).toBe(
194
+ 'size-16 text-white/25'
195
+ )
196
+
197
+ const { container: light } = renderWithProviders(
198
+ <CardThumbnail variant="light" />
199
+ )
200
+ const lightPlaceholder = light.querySelector('div.items-center')
201
+ expect(lightPlaceholder?.className).toContain('bg-black/5')
202
+ expect(lightPlaceholder?.querySelector('svg')?.getAttribute('class')).toBe(
203
+ 'size-16 text-black/25'
204
+ )
205
+ })
206
+
207
+ it('pulses a variant-aware block while loading, ignoring the thumbnail', () => {
208
+ const { container: dark } = renderWithProviders(
209
+ <CardThumbnail variant="dark" thumbnailUrl={IMAGE_SRC} isLoading />
210
+ )
211
+ expect(dark.querySelectorAll('img')).toHaveLength(0)
212
+ const bar = dark.querySelector('div.animate-pulse')
213
+ expect(bar?.className).toContain('bg-white/10')
214
+ expect(bar?.getAttribute('aria-hidden')).toBe('true')
215
+
216
+ const { container: light } = renderWithProviders(
217
+ <CardThumbnail variant="light" isLoading />
218
+ )
219
+ expect(light.querySelector('div.animate-pulse')?.className).toContain(
220
+ 'bg-black/5'
221
+ )
222
+ })
223
+
224
+ it('pins the corner slots and omits their wrappers when unused', () => {
225
+ renderWithProviders(
226
+ <CardThumbnail
227
+ variant="dark"
228
+ topLeft={<span>left</span>}
229
+ topRight={<span>right</span>}
230
+ />
231
+ )
232
+ expect(screen.getByText('left').parentElement?.className).toContain(
233
+ 'absolute left-3 top-3'
234
+ )
235
+ expect(screen.getByText('right').parentElement?.className).toContain(
236
+ 'absolute right-3 top-3'
237
+ )
238
+
239
+ const { container } = renderWithProviders(<CardThumbnail variant="dark" />)
240
+ expect(container.querySelectorAll('div.absolute')).toHaveLength(0)
241
+ })
242
+ })
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { HEX_PATTERN, toCssHex } from './hex'
4
+
5
+ describe('HEX_PATTERN', () => {
6
+ it.each(['#7008E7', '7008e7', '#abc', 'ABC'])('accepts %s', (value) => {
7
+ expect(HEX_PATTERN.test(value)).toBe(true)
8
+ })
9
+
10
+ it.each([
11
+ '#7008E', // 5 digits — neither shorthand nor full
12
+ '#ab', // too short for the shorthand
13
+ '#abcz', // trailing non-hex character
14
+ 'x#abc', // hex embedded in a longer value
15
+ '#abc ', // untrimmed
16
+ 'rebeccapurple', // CSS would resolve it; the extractor never emits it
17
+ 'rgb(1,2,3)',
18
+ '',
19
+ ])('rejects %s', (value) => {
20
+ expect(HEX_PATTERN.test(value)).toBe(false)
21
+ })
22
+ })
23
+
24
+ describe('toCssHex', () => {
25
+ it('adds the missing hash to a bare hex', () => {
26
+ expect(toCssHex('7008E7')).toBe('#7008E7')
27
+ })
28
+
29
+ it('leaves an already-hashed hex alone', () => {
30
+ expect(toCssHex('#7008E7')).toBe('#7008E7')
31
+ })
32
+
33
+ it('trims before hashing so the value is usable as a CSS colour', () => {
34
+ expect(toCssHex(' 7008E7 ')).toBe('#7008E7')
35
+ expect(toCssHex(' #7008E7 ')).toBe('#7008E7')
36
+ })
37
+ })
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { normalizeExternalHref } from './normalizeExternalHref'
4
+
5
+ describe('normalizeExternalHref', () => {
6
+ it('prefixes a bare hostname so it opens externally', () => {
7
+ expect(normalizeExternalHref('tr.ee/briemix')).toBe('https://tr.ee/briemix')
8
+ })
9
+
10
+ it('trims surrounding whitespace before prefixing', () => {
11
+ expect(normalizeExternalHref(' tr.ee/briemix ')).toBe(
12
+ 'https://tr.ee/briemix'
13
+ )
14
+ })
15
+
16
+ it('returns undefined for missing, non-string and blank values', () => {
17
+ expect(normalizeExternalHref()).toBeUndefined()
18
+ expect(normalizeExternalHref(undefined)).toBeUndefined()
19
+ expect(
20
+ normalizeExternalHref(42 as unknown as string | undefined)
21
+ ).toBeUndefined()
22
+ expect(normalizeExternalHref('')).toBeUndefined()
23
+ expect(normalizeExternalHref(' ')).toBeUndefined()
24
+ })
25
+
26
+ it.each([
27
+ 'http://example.com/a',
28
+ 'https://example.com/a',
29
+ 'mailto:hello@example.com',
30
+ 'tel:+61400000000',
31
+ 'sms:+61400000000',
32
+ ])('passes the safe scheme %s through untouched', (value) => {
33
+ expect(normalizeExternalHref(value)).toBe(value)
34
+ })
35
+
36
+ it('accepts a safe scheme regardless of case', () => {
37
+ expect(normalizeExternalHref('HTTPS://example.com')).toBe(
38
+ 'HTTPS://example.com'
39
+ )
40
+ })
41
+
42
+ it.each([
43
+ 'javascript:alert(1)',
44
+ 'data:text/html;base64,PHNjcmlwdD4=',
45
+ 'ftp://example.com/a',
46
+ 'file:///etc/passwd',
47
+ ])('rejects the unsafe scheme %s', (value) => {
48
+ expect(normalizeExternalHref(value)).toBeUndefined()
49
+ })
50
+
51
+ it('only reads a scheme at the start of the value', () => {
52
+ // A nested `https:` inside a query string is not this value's scheme, so
53
+ // the bare host still gets prefixed.
54
+ expect(normalizeExternalHref('tr.ee/a?next=https://example.com')).toBe(
55
+ 'https://tr.ee/a?next=https://example.com'
56
+ )
57
+ })
58
+
59
+ it('keeps protocol-relative and root-relative paths as-is', () => {
60
+ expect(normalizeExternalHref('//cdn.example.com/a.png')).toBe(
61
+ '//cdn.example.com/a.png'
62
+ )
63
+ expect(normalizeExternalHref('/local/path')).toBe('/local/path')
64
+ })
65
+
66
+ it('prefixes hosts that merely end in a slash', () => {
67
+ // The leading-slash checks are prefix tests: a trailing slash is still a
68
+ // bare host and must be prefixed.
69
+ expect(normalizeExternalHref('example.com/')).toBe('https://example.com/')
70
+ expect(normalizeExternalHref('example.com//')).toBe('https://example.com//')
71
+ })
72
+ })
@@ -0,0 +1,162 @@
1
+ import { act, renderHook } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { useChinPalette } from './useChinPalette'
5
+ import type { ChinPaletteInput } from './useChinPalette'
6
+
7
+ const THUMBNAIL = 'https://cdn.example.com/cover.jpg'
8
+
9
+ const render = (input: ChinPaletteInput) =>
10
+ renderHook((props: ChinPaletteInput) => useChinPalette(props), {
11
+ initialProps: input,
12
+ })
13
+
14
+ describe('useChinPalette', () => {
15
+ it('publishes a validated accent for a ready featured hero', () => {
16
+ const { result } = render({
17
+ variant: 'dark',
18
+ accentColor: '#7008E7',
19
+ thumbnailUrl: THUMBNAIL,
20
+ })
21
+ expect(result.current.accentHex).toBe('#7008E7')
22
+ expect(result.current.thumbnailUrl).toBe(THUMBNAIL)
23
+ expect(result.current.isLoading).toBe(false)
24
+ })
25
+
26
+ it('hashes a bare hex and tolerates surrounding whitespace', () => {
27
+ const { result } = render({
28
+ variant: 'dark',
29
+ accentColor: ' 7008E7 ',
30
+ thumbnailUrl: THUMBNAIL,
31
+ })
32
+ expect(result.current.accentHex).toBe('#7008E7')
33
+ })
34
+
35
+ it.each([
36
+ 'rebeccapurple',
37
+ 'rgb(112, 8, 231)',
38
+ '#12g',
39
+ '#1234',
40
+ '',
41
+ undefined,
42
+ ])('drops the unusable accent %s', (accentColor) => {
43
+ const { result } = render({
44
+ variant: 'dark',
45
+ accentColor,
46
+ thumbnailUrl: THUMBNAIL,
47
+ })
48
+ expect(result.current.accentHex).toBeUndefined()
49
+ })
50
+
51
+ it('drops the accent for a classic card, which has no hero', () => {
52
+ const { result } = render({
53
+ variant: 'dark',
54
+ accentColor: '#7008E7',
55
+ layout: 'classic',
56
+ thumbnailUrl: THUMBNAIL,
57
+ })
58
+ expect(result.current.accentHex).toBeUndefined()
59
+ })
60
+
61
+ it.each(['loading', 'failed'] as const)(
62
+ 'drops the accent while status is %s',
63
+ (status) => {
64
+ const { result } = render({
65
+ variant: 'dark',
66
+ accentColor: '#7008E7',
67
+ status,
68
+ thumbnailUrl: THUMBNAIL,
69
+ })
70
+ expect(result.current.accentHex).toBeUndefined()
71
+ expect(result.current.isLoading).toBe(status === 'loading')
72
+ }
73
+ )
74
+
75
+ it.each([undefined, ''])(
76
+ 'drops the accent when the hero url is %s',
77
+ (thumbnailUrl) => {
78
+ const { result } = render({
79
+ variant: 'dark',
80
+ accentColor: '#7008E7',
81
+ thumbnailUrl,
82
+ })
83
+ expect(result.current.accentHex).toBeUndefined()
84
+ }
85
+ )
86
+
87
+ it('drops the accent for playable audio, whose hero is the player', () => {
88
+ const { result } = render({
89
+ variant: 'dark',
90
+ accentColor: '#7008E7',
91
+ thumbnailUrl: THUMBNAIL,
92
+ mimeType: 'audio/mpeg',
93
+ sourceUrl: 'https://cdn.example.com/clip.mp3',
94
+ })
95
+ expect(result.current.accentHex).toBeUndefined()
96
+ })
97
+
98
+ it('keeps the accent for playable video, whose poster is the hero', () => {
99
+ const { result } = render({
100
+ variant: 'dark',
101
+ accentColor: '#7008E7',
102
+ thumbnailUrl: THUMBNAIL,
103
+ mimeType: 'video/mp4',
104
+ sourceUrl: 'https://cdn.example.com/clip.mp4',
105
+ })
106
+ expect(result.current.accentHex).toBe('#7008E7')
107
+ })
108
+
109
+ it('withdraws the hero and its accent once the image fails', () => {
110
+ const { result } = render({
111
+ variant: 'dark',
112
+ accentColor: '#7008E7',
113
+ thumbnailUrl: THUMBNAIL,
114
+ })
115
+ act(() => {
116
+ result.current.onImageError()
117
+ })
118
+ expect(result.current.thumbnailUrl).toBeUndefined()
119
+ expect(result.current.accentHex).toBeUndefined()
120
+ })
121
+
122
+ it('gives a new hero url a fresh chance to load', () => {
123
+ const { result, rerender } = render({
124
+ variant: 'dark',
125
+ accentColor: '#7008E7',
126
+ thumbnailUrl: THUMBNAIL,
127
+ })
128
+ act(() => {
129
+ result.current.onImageError()
130
+ })
131
+ expect(result.current.accentHex).toBeUndefined()
132
+
133
+ rerender({
134
+ variant: 'dark',
135
+ accentColor: '#7008E7',
136
+ thumbnailUrl: 'https://cdn.example.com/other.jpg',
137
+ })
138
+ expect(result.current.thumbnailUrl).toBe(
139
+ 'https://cdn.example.com/other.jpg'
140
+ )
141
+ expect(result.current.accentHex).toBe('#7008E7')
142
+ })
143
+
144
+ it.each([
145
+ ['dark', '#FFFFFF'],
146
+ ['light', '#040403'],
147
+ ] as const)('inks the %s surface with %s', (variant, ink) => {
148
+ const { result } = render({ variant })
149
+ expect(result.current.chinTextColor).toBe(ink)
150
+ expect(result.current.surface).toBe(variant)
151
+ })
152
+
153
+ it('treats the audio strip as a light surface on either side', () => {
154
+ const { result } = render({
155
+ variant: 'dark',
156
+ mimeType: 'audio/mpeg',
157
+ sourceUrl: 'https://cdn.example.com/clip.mp3',
158
+ })
159
+ expect(result.current.chinTextColor).toBe('#040403')
160
+ expect(result.current.surface).toBe('light')
161
+ })
162
+ })