@linktr.ee/messaging-react 3.24.2-rc-1785436624 → 3.25.0-rc-1785440433

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 (29) hide show
  1. package/dist/{Card-BI3bA2Cj.js → Card-DcyixIfP.js} +2 -2
  2. package/dist/{Card-BI3bA2Cj.js.map → Card-DcyixIfP.js.map} +1 -1
  3. package/dist/{Card-iXdD3NF2.cjs → Card-_fDH4x4V.cjs} +2 -2
  4. package/dist/{Card-iXdD3NF2.cjs.map → Card-_fDH4x4V.cjs.map} +1 -1
  5. package/dist/{index-BlHbSVwK.js → index-DSQieSNh.js} +881 -798
  6. package/dist/index-DSQieSNh.js.map +1 -0
  7. package/dist/index-quV01XX7.cjs +2 -0
  8. package/dist/index-quV01XX7.cjs.map +1 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +1 -1
  12. package/package.json +2 -2
  13. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +17 -9
  14. package/src/components/LinkAttachment/LinkAttachment.test.tsx +87 -53
  15. package/src/components/LinkAttachment/components/Composer/Card.tsx +1 -1
  16. package/src/components/LinkAttachment/components/Received/Card.tsx +1 -1
  17. package/src/components/LinkAttachment/components/Sent/Card.tsx +1 -1
  18. package/src/components/LinkAttachment/components/_shared/CardBody.tsx +11 -10
  19. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +7 -1
  20. package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +9 -0
  21. package/src/components/LinkAttachment/components/_shared/accentSurface.test.ts +151 -0
  22. package/src/components/LinkAttachment/components/_shared/accentSurface.ts +211 -0
  23. package/src/components/LinkAttachment/components/_shared/chinContrast.test.ts +64 -124
  24. package/src/components/LinkAttachment/components/_shared/chinContrast.ts +68 -71
  25. package/src/components/LinkAttachment/components/_shared/useChinPalette.ts +35 -31
  26. package/src/components/LinkAttachment/types.ts +2 -2
  27. package/dist/index-BlHbSVwK.js.map +0 -1
  28. package/dist/index-F3RRurYR.cjs +0 -2
  29. package/dist/index-F3RRurYR.cjs.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-F3RRurYR.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageBubble=e.MessageBubble;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.optimizeMessagingAttachmentUrl=e.optimizeMessagingAttachmentUrl;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-quV01XX7.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageBubble=e.MessageBubble;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.optimizeMessagingAttachmentUrl=e.optimizeMessagingAttachmentUrl;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.d.ts CHANGED
@@ -693,8 +693,8 @@ export declare interface LinkAttachmentBaseProps {
693
693
  * screen. Omitted, unparseable, `layout: 'classic'`, no `thumbnailUrl`,
694
694
  * `status` other than `'ready'`, a playable-audio `mimeType` + `sourceUrl`
695
695
  * (whose hero is the native player rather than an image), or a hero that
696
- * fails to load => the card falls back to `CHIN_FALLBACK_BG` with
697
- * `CHIN_TEXT_LIGHT`.
696
+ * fails to load, or the colour cannot clear AA against this side's ink =>
697
+ * the card falls back to `BUBBLE_BG_BY_VARIANT[variant]`, the plain bubble fill.
698
698
  */
699
699
  accentColor?: string;
700
700
  /**
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as c, i as u, j as L, k as h, l as p, m as d, n as v, p as C, q as A, s as k, t as F, u as b, v as P, w as f, x, o as y, y as z, z as B, B as S, D as q, E as D, G as E, H as V, I as w } from "./index-BlHbSVwK.js";
1
+ import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as c, i as u, j as L, k as h, l as p, m as d, n as v, p as C, q as A, s as k, t as F, u as b, v as P, w as f, x, o as y, y as z, z as B, B as S, D as q, E as D, G as E, H as V, I as w } from "./index-DSQieSNh.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.24.2-rc-1785436624",
3
+ "version": "3.25.0-rc-1785440433",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@linktr.ee/component-library": "11.8.6",
53
- "@linktr.ee/messaging-core": "2.3.0-rc-1785436624",
53
+ "@linktr.ee/messaging-core": "2.3.0-rc-1785440433",
54
54
  "@linktr.ee/messaging-taxonomy": "^0.3.0",
55
55
  "@phosphor-icons/react": "^2.1.10"
56
56
  },
@@ -239,10 +239,10 @@ export const LinkApps: StoryFn = () => (
239
239
  ────────────────────────────────────────────────────────────────── */
240
240
 
241
241
  const ACCENTS: Array<{ label: string; accentColor?: string }> = [
242
- // The Figma mock's own chin colour. White measures 3.76:1 on it and fails
243
- // AA, so the contrast rule flips this card to near-black text and the
244
- // shipped card deliberately does NOT match the mock. This cell is the
245
- // trade-off, made visible for design review.
242
+ // The Figma mock's own chin colour. Painted raw it measures 3.76:1 against
243
+ // the sender's white ink, which is why the mock cannot simply be reproduced;
244
+ // derived, its hue survives at a lightness both inks clear. The shipped sent
245
+ // card is therefore darker than the mock on purpose (MES-1388).
246
246
  { label: '#738883 (mock)', accentColor: '#738883' },
247
247
  { label: '#AA9F93', accentColor: '#AA9F93' },
248
248
  { label: '#FFF099', accentColor: '#FFF099' },
@@ -260,12 +260,20 @@ const ACCENT_CARD = {
260
260
  }
261
261
 
262
262
  /**
263
- * The dominant-colour chin and its WCAG AA contrast rule (MES-1349).
263
+ * The dominant-colour card and its WCAG AA gate (MES-1349, corrected MES-1388).
264
264
  *
265
- * Each column is one extracted accent; the card derives its own chin text
266
- * colour by taking whichever of `#FFFFFF` / `#040403` scores the higher
267
- * contrast ratio against it. Every cell clears 4.5:1 — the two candidates'
268
- * passing ranges overlap, so no sRGB colour fails against both.
265
+ * Each column is one extracted accent. Ink is *fixed per side* — `text-white`
266
+ * when sent, `text-black/90` when received — so contrast decides whether the
267
+ * accent may paint at all rather than which text colour to use. An accent under
268
+ * 4.5:1 against that side's ink is discarded for the plain message bubble fill
269
+ * (`#1e2330` sent, `#f1f0ee` received), so a card without a usable colour reads
270
+ * like the text bubbles around it.
271
+ *
272
+ * Each column is a synthetic base colour chosen to probe the derivation, not a
273
+ * colour taken from the hero — in production `accent_color` is extracted from
274
+ * the attachment image itself, so a photograph's own hue is what arrives here.
275
+ * Every column paints on both sides: the derivation moves lightness, never hue,
276
+ * so a base is never refused for contrast.
269
277
  *
270
278
  * The bottom rows cover the states where the accent is deliberately ignored:
271
279
  * `classic` has no image to extract from, and `loading` has no image yet.
@@ -108,41 +108,64 @@ describe('LinkAttachment.Sent', () => {
108
108
  })
109
109
  })
110
110
 
111
- describe('LinkAttachment accent chin (MES-1349)', () => {
112
- const ACCENT = '#738883'
111
+ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
112
+ const ACCENT = '#7008E7'
113
+ // The near-white dominant colour from the MES-1388 report. Painted raw it put
114
+ // white ink on 1.09:1; derived, it is legible on both sides.
115
+ const PALE_ACCENT = '#FCF5E3'
113
116
  const CARD_PROPS = {
114
117
  title: 'Yoga on Ice with Brie Nala',
115
118
  url: 'tr.ee/briewebsite',
116
119
  thumbnailUrl: 'https://cdn.example.com/yoga.jpg',
117
120
  }
118
121
 
119
- it('paints the card with the accent and flips the chin text to near-black', async () => {
122
+ const surfaceOf = (element: HTMLElement) => element.style.backgroundColor
123
+
124
+ it('derives a dark surface for the sender and inks it white', async () => {
120
125
  renderWithProviders(
121
126
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
122
127
  )
123
- // White on #738883 measures 3.76:1 and fails AA, so the rule must pick
124
- // the dark candidate even though the Figma mock draws white.
125
- expect(await screen.findByRole('link')).toHaveStyle({
126
- backgroundColor: ACCENT,
127
- })
128
- expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
129
- expect(screen.getByText(CARD_PROPS.url)).toHaveStyle({ color: '#040403' })
128
+ const root = await screen.findByRole('link')
129
+ // Not the raw accent: the hue is kept, the lightness is replaced with the
130
+ // stop this side's ink can clear. Painting the extracted colour verbatim is
131
+ // what MES-1388 was reported for.
132
+ expect(surfaceOf(root)).not.toBe('')
133
+ expect(root).not.toHaveStyle({ backgroundColor: ACCENT })
134
+ expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
130
135
  })
131
136
 
132
- it('picks light text on a dark accent', async () => {
137
+ it('derives a pale surface for the receiver and inks it near-black', async () => {
133
138
  renderWithProviders(
134
- <LinkAttachment.Sent {...CARD_PROPS} accentColor="#7008E7" />
139
+ <LinkAttachment.Received {...CARD_PROPS} accentColor={ACCENT} />
135
140
  )
136
- expect(await screen.findByText(CARD_PROPS.title)).toHaveStyle({
137
- color: '#FFFFFF',
138
- })
141
+ const root = await screen.findByRole('link')
142
+ expect(surfaceOf(root)).not.toBe('')
143
+ expect(root).not.toHaveStyle({ backgroundColor: ACCENT })
144
+ expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
145
+ })
146
+
147
+ it('colours both sides from one extracted colour, never rejecting it', async () => {
148
+ // The pale report colour and the vivid one both paint, on both sides — the
149
+ // feature adds colour to every card with an image rather than half of them.
150
+ for (const accentColor of [ACCENT, PALE_ACCENT]) {
151
+ const sent = renderWithProviders(
152
+ <LinkAttachment.Sent {...CARD_PROPS} accentColor={accentColor} />
153
+ )
154
+ expect(surfaceOf(await screen.findByRole('link'))).not.toBe('')
155
+ sent.unmount()
156
+
157
+ const received = renderWithProviders(
158
+ <LinkAttachment.Received {...CARD_PROPS} accentColor={accentColor} />
159
+ )
160
+ expect(surfaceOf(await screen.findByRole('link'))).not.toBe('')
161
+ received.unmount()
162
+ }
139
163
  })
140
164
 
141
165
  it('falls back to a self-contained fill, not a consumer theme token', async () => {
142
166
  renderWithProviders(<LinkAttachment.Sent {...CARD_PROPS} />)
143
167
  const root = await screen.findByRole('link')
144
- expect(root.style.backgroundColor).toBe('')
145
- expect(screen.getByText(CARD_PROPS.title).style.color).toBe('')
168
+ expect(surfaceOf(root)).toBe('')
146
169
 
147
170
  // The fill has to survive in consumers that do not share our Tailwind
148
171
  // theme. This package ships no utilities, so consumers generate our classes
@@ -164,7 +187,8 @@ describe('LinkAttachment accent chin (MES-1349)', () => {
164
187
  )
165
188
  const root = await screen.findByRole('link')
166
189
  expect(root.style.backgroundColor).toBe('')
167
- expect(screen.getByText(CARD_PROPS.title).style.color).toBe('')
190
+ // Ink still comes from the surface — here the sender's fallback fill.
191
+ expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
168
192
  })
169
193
 
170
194
  it('drops the accent when the hero image fails to load', async () => {
@@ -172,13 +196,13 @@ describe('LinkAttachment accent chin (MES-1349)', () => {
172
196
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
173
197
  )
174
198
  const root = await screen.findByRole('link')
175
- expect(root).toHaveStyle({ backgroundColor: ACCENT })
199
+ expect(root.style.backgroundColor).not.toBe('')
176
200
 
177
- // An accent derived from an image the viewer cannot see is a lie, so the
201
+ // A surface derived from an image the viewer cannot see is a lie, so the
178
202
  // card falls all the way back rather than keeping a colour with no source.
179
203
  fireEvent.error(screen.getByRole('img'))
180
204
  expect(root.style.backgroundColor).toBe('')
181
- expect(screen.getByText(CARD_PROPS.title).style.color).toBe('')
205
+ expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
182
206
  })
183
207
 
184
208
  it('drops the accent when a playable video poster fails to load', () => {
@@ -198,50 +222,51 @@ describe('LinkAttachment accent chin (MES-1349)', () => {
198
222
  const shell = container.querySelector(
199
223
  '[class*="select-none"]'
200
224
  ) as HTMLElement
201
- expect(shell).toHaveStyle({ backgroundColor: ACCENT })
225
+ expect(shell.style.backgroundColor).not.toBe('')
202
226
  expect(container.querySelector('video')).toHaveAttribute('poster')
203
227
 
204
228
  fireEvent.error(container.querySelector('img') as HTMLImageElement)
205
229
 
206
230
  expect(shell.style.backgroundColor).toBe('')
207
- expect(screen.getByText(CARD_PROPS.title).style.color).toBe('')
231
+ expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
208
232
  // The failed poster is dropped with the accent, so the player falls back to
209
233
  // its own first frame instead of retrying a 404.
210
234
  expect(container.querySelector('video')).not.toHaveAttribute('poster')
211
235
  })
212
236
 
213
- it('drops the accent on a playable audio card so the audio fill survives', async () => {
214
- // The hero collapses to the native player, so the `<img>` never mounts and
215
- // `onImageError` can never fire — the gate has to be up front. `CardShell`
216
- // applies `accentStyle` as an inline `style`, which would otherwise outrank
217
- // `AUDIO_BG_CLASS` and ship a coloured card with no visible source image.
218
- // Reachable through Stream, which maps any playable link attachment to
219
- // `featured` (`linkCardPropsFromStreamAttachment`).
237
+ it('derives audio artwork into the surface, and inks the flat fill when there is none', async () => {
238
+ // MES-1349 refused the accent here because the hero collapses to the native
239
+ // player and the artwork is never shown. The colour is derived now, so it
240
+ // cannot mislead about legibility, and artwork is what distinguishes one
241
+ // track from another — so it paints. Reachable through Stream, which maps
242
+ // any playable link attachment to `featured`.
220
243
  const AUDIO = {
221
244
  mimeType: 'audio/mpeg',
222
245
  sourceUrl: 'https://cdn.example.com/clip.mp3',
223
246
  }
224
247
 
225
- const { container, unmount } = renderWithProviders(
248
+ const withArtwork = renderWithProviders(
226
249
  <LinkAttachment.Sent {...CARD_PROPS} {...AUDIO} accentColor={ACCENT} />
227
250
  )
228
- const sent = container.firstElementChild as HTMLElement
229
- expect(sent.style.backgroundColor).toBe('')
230
- expect(sent.className).toContain(AUDIO_BG_CLASS)
231
- expect(screen.getByText(CARD_PROPS.title).style.color).toBe('')
251
+ const painted = withArtwork.container.firstElementChild as HTMLElement
252
+ expect(painted.style.backgroundColor).not.toBe('')
232
253
  expect(screen.queryByRole('img')).toBeNull()
233
- unmount()
254
+ withArtwork.unmount()
234
255
 
235
- renderWithProviders(
236
- <LinkAttachment.Received
256
+ // No artwork: the flat audio fill stays, and this is the MES-1388 bug —
257
+ // `AUDIO_BG_CLASS` is near-white and lands on a *sent* card, whose variant
258
+ // ink is white. Ink must follow the fill it was given, not the variant.
259
+ const bare = renderWithProviders(
260
+ <LinkAttachment.Sent
237
261
  {...CARD_PROPS}
238
262
  {...AUDIO}
239
- accentColor={ACCENT}
263
+ thumbnailUrl={undefined}
240
264
  />
241
265
  )
242
- const received = await screen.findByTestId('link-attachment')
243
- expect(received.style.backgroundColor).toBe('')
244
- expect(received.className).toContain(AUDIO_BG_CLASS)
266
+ const flat = bare.container.firstElementChild as HTMLElement
267
+ expect(flat.style.backgroundColor).toBe('')
268
+ expect(flat.className).toContain(AUDIO_BG_CLASS)
269
+ expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
245
270
  })
246
271
 
247
272
  it('drops the accent when the featured hero has no image to show', async () => {
@@ -259,20 +284,25 @@ describe('LinkAttachment accent chin (MES-1349)', () => {
259
284
  // Variant fill, asserted by shape not literal — see the self-contained-fill
260
285
  // test above for why this must stay an arbitrary value (MES-1388).
261
286
  expect(root.className).toMatch(/bg-\[/)
262
- expect(screen.getByText(CARD_PROPS.title).style.color).toBe('')
287
+ expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
263
288
  })
264
289
 
265
290
  it('normalizes a hashless accent so the background actually paints', async () => {
266
- // `parseHex` accepts `738883`; `background-color: 738883` is invalid CSS
267
- // and the DOM drops it, while the chin still took `#040403` from the parsed
268
- // colour — 1.31:1 near-black on the dark fallback fill.
291
+ // The extractor returns a bare hex often enough that dropping it would lose
292
+ // the colour silently — `background-color: 7008E7` is invalid CSS (MES-1349).
293
+ const hashless = renderWithProviders(
294
+ <LinkAttachment.Sent {...CARD_PROPS} accentColor="7008E7" />
295
+ )
296
+ const derived = (await screen.findByRole('link')).style.backgroundColor
297
+ expect(derived).not.toBe('')
298
+ hashless.unmount()
299
+
269
300
  renderWithProviders(
270
- <LinkAttachment.Sent {...CARD_PROPS} accentColor="738883" />
301
+ <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
302
+ )
303
+ expect((await screen.findByRole('link')).style.backgroundColor).toBe(
304
+ derived
271
305
  )
272
- const root = await screen.findByRole('link')
273
- expect(root.style.backgroundColor).not.toBe('')
274
- expect(root).toHaveStyle({ backgroundColor: ACCENT })
275
- expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
276
306
  })
277
307
 
278
308
  it('lets a loading status outrank the audio hero on all three cards', () => {
@@ -318,8 +348,12 @@ describe('LinkAttachment accent chin (MES-1349)', () => {
318
348
  groupPosition="end"
319
349
  />
320
350
  )
321
- expect(await screen.findByTestId('link-card-tail')).toHaveStyle({
322
- backgroundColor: ACCENT,
351
+ // The tail paints outside the shell's own box, so it takes the surface as an
352
+ // inline colour — it has to be the *derived* one or the tail and the card
353
+ // would disagree.
354
+ const card = await screen.findByRole('link')
355
+ expect(screen.getByTestId('link-card-tail')).toHaveStyle({
356
+ backgroundColor: card.style.backgroundColor,
323
357
  })
324
358
  unmount()
325
359
 
@@ -147,6 +147,7 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
147
147
  />
148
148
  )}
149
149
  <CardBody
150
+ chinTextColor={chin.chinTextColor}
150
151
  variant="dark"
151
152
  title={title}
152
153
  placeholderTitle={placeholderTitle}
@@ -155,7 +156,6 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
155
156
  appIcon={appIcon}
156
157
  cta={cta}
157
158
  trailingAction={editButton}
158
- chinTextColor={chin.chinTextColor}
159
159
  isLoading={chin.isLoading}
160
160
  />
161
161
  </CardShell>
@@ -140,13 +140,13 @@ const ReceivedCard: React.FC<ReceivedCardProps> = ({
140
140
  />
141
141
  )}
142
142
  <CardBody
143
+ chinTextColor={chin.chinTextColor}
143
144
  variant="light"
144
145
  title={title}
145
146
  description={description}
146
147
  url={url}
147
148
  appIcon={appIcon}
148
149
  cta={wrappedCta}
149
- chinTextColor={chin.chinTextColor}
150
150
  isLoading={chin.isLoading}
151
151
  />
152
152
  </CardShell>
@@ -121,6 +121,7 @@ const SentCard: React.FC<SentCardProps> = ({
121
121
  />
122
122
  )}
123
123
  <CardBody
124
+ chinTextColor={chin.chinTextColor}
124
125
  variant="dark"
125
126
  title={title}
126
127
  placeholderTitle={placeholderTitle}
@@ -128,7 +129,6 @@ const SentCard: React.FC<SentCardProps> = ({
128
129
  url={url}
129
130
  appIcon={appIcon}
130
131
  cta={cta}
131
- chinTextColor={chin.chinTextColor}
132
132
  isLoading={chin.isLoading}
133
133
  />
134
134
  </CardShell>
@@ -14,6 +14,14 @@ export interface CardBodyProps {
14
14
  description?: string
15
15
  /** Footer URL shown below the description. Ignored when `cta` is set. */
16
16
  url?: string
17
+ /**
18
+ * Ink for both chin lines, from `chinContrast.inkFor` — measured against the
19
+ * surface this card actually paints, which may be a derived accent, the audio
20
+ * strip, or the variant fallback. Replaces the variant's own `text-*` classes
21
+ * (including their opacity) on both lines, so a card whose surface is not the
22
+ * variant default still reads (MES-1388).
23
+ */
24
+ chinTextColor?: string
17
25
  /**
18
26
  * Optional 16x16 brand badge rendered before the title (used by Link Apps:
19
27
  * Spotify, TikTok, FAQ, Form, etc.).
@@ -23,13 +31,6 @@ export interface CardBodyProps {
23
31
  cta?: LinkAttachmentCta
24
32
  /** Trailing action rendered on the right of the title/description block. */
25
33
  trailingAction?: React.ReactNode
26
- /**
27
- * Resolved chin text colour for an accent-painted card, from
28
- * `chinContrast.chinTextColor`. When set it replaces the variant's own
29
- * `text-*` classes (including their opacity) on both lines, because the
30
- * WCAG proof only holds for the two full-opacity candidates.
31
- */
32
- chinTextColor?: string
33
34
  /** Renders two neutral pulsing bars in place of the text. */
34
35
  isLoading?: boolean
35
36
  }
@@ -69,6 +70,7 @@ const SKELETON_BAR_CLASS_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
69
70
  */
70
71
  const CardBody: React.FC<CardBodyProps> = ({
71
72
  variant,
73
+ chinTextColor,
72
74
  title,
73
75
  placeholderTitle,
74
76
  description,
@@ -76,7 +78,6 @@ const CardBody: React.FC<CardBodyProps> = ({
76
78
  appIcon,
77
79
  cta,
78
80
  trailingAction,
79
- chinTextColor,
80
81
  isLoading = false,
81
82
  }) => {
82
83
  const isDark = variant === 'dark'
@@ -107,8 +108,6 @@ const CardBody: React.FC<CardBodyProps> = ({
107
108
  chinTextColor == null && SECONDARY_CLASS_BY_VARIANT[variant]
108
109
  )
109
110
 
110
- const chinTextStyle = chinTextColor ? { color: chinTextColor } : undefined
111
-
112
111
  if (isLoading) {
113
112
  const barClass = classNames(
114
113
  'animate-pulse rounded',
@@ -126,6 +125,8 @@ const CardBody: React.FC<CardBodyProps> = ({
126
125
  )
127
126
  }
128
127
 
128
+ const chinTextStyle = chinTextColor ? { color: chinTextColor } : undefined
129
+
129
130
  return (
130
131
  <div className="shrink-0 px-4 py-3">
131
132
  <div className="flex items-end gap-3">
@@ -110,7 +110,13 @@ const CardShell: React.FC<CardShellProps> = ({
110
110
  // dropped, the chin rendered transparent, and the own-message `color: white`
111
111
  // left the title and URL invisible (MES-1388). An arbitrary value is
112
112
  // generated from the class name alone and cannot depend on consumer theme.
113
- bgClassName ?? (variant === 'dark' ? 'bg-[#1e2330]' : 'bg-white'),
113
+ //
114
+ // Both values are the plain message bubble's own fill, so a card with no
115
+ // usable accent matches the text bubbles around it: `#1e2330` is
116
+ // `--str-chat__own-message-bubble-background-color` and `#f1f0ee` is
117
+ // `--str-chat__message-bubble-background-color`. Mirrored in
118
+ // `chinContrast.BUBBLE_BG_BY_VARIANT`, which the bubble tail reads.
119
+ bgClassName ?? (variant === 'dark' ? 'bg-[#1e2330]' : 'bg-[#f1f0ee]'),
114
120
  // `focus-ring` is a design-system utility from the component-library
115
121
  // tailwind preset — outline-none + a black 2px focus-visible ring
116
122
  // with offset, so keyboard users can see the focused card.
@@ -70,7 +70,16 @@ export const isPlayableMedia = (mimeType?: string, sourceUrl?: string) => {
70
70
  * Background colour the LinkAttachment cards switch to when the source is
71
71
  * audio — flat neutral around the native `<audio>` chrome regardless of
72
72
  * the dark / light variant.
73
+ *
74
+ * The hex is exported alongside the class because the card's ink is chosen by
75
+ * measuring contrast against whatever surface it lands on, and contrast needs
76
+ * channels rather than a class name. They are written out twice on purpose:
77
+ * Tailwind only generates utilities it can find as complete literals when it
78
+ * scans `dist`, so building the class from the constant
79
+ * (`` `bg-[${AUDIO_BG}]` ``) would emit nothing at all. `chinContrast.test.ts`
80
+ * asserts the two stay in step.
73
81
  */
82
+ export const AUDIO_BG = '#F2F3F4'
74
83
  export const AUDIO_BG_CLASS = 'bg-[#F2F3F4]'
75
84
 
76
85
  /**
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { accentSurface } from './accentSurface'
4
+ import {
5
+ contrastRatio,
6
+ inkFor,
7
+ INK_ON_DARK,
8
+ INK_ON_LIGHT,
9
+ WCAG_AA_NORMAL_TEXT,
10
+ } from './chinContrast'
11
+
12
+ /** OKLab a/b of a hex — hue angle and chroma both come off this pair. */
13
+ function oklabAb(hex: string): [number, number] {
14
+ const [red, green, blue] = [1, 3, 5].map((index) => {
15
+ const channel = Number.parseInt(hex.slice(index, index + 2), 16) / 255
16
+ return channel <= 0.04045
17
+ ? channel / 12.92
18
+ : ((channel + 0.055) / 1.055) ** 2.4
19
+ })
20
+ const long = Math.cbrt(
21
+ 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue
22
+ )
23
+ const medium = Math.cbrt(
24
+ 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue
25
+ )
26
+ const short = Math.cbrt(
27
+ 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue
28
+ )
29
+ return [
30
+ 1.9779984951 * long - 2.428592205 * medium + 0.4505937099 * short,
31
+ 0.0259040371 * long + 0.7827717662 * medium - 0.808675766 * short,
32
+ ]
33
+ }
34
+
35
+ function hueOf(hex: string): number {
36
+ const [a, b] = oklabAb(hex)
37
+ return ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360
38
+ }
39
+
40
+ function chromaOf(hex: string): number {
41
+ return Math.hypot(...oklabAb(hex))
42
+ }
43
+
44
+ /** Hues far enough apart to be visibly different colours. */
45
+ const SAMPLES = [
46
+ '#7008E7', // violet — chroma well outside sRGB at both stops
47
+ '#2FCB5F', // green
48
+ '#FFF099', // pale yellow
49
+ '#00B6FF', // cyan
50
+ '#738883', // the Figma mock's desaturated green
51
+ '#FCF5E3', // the near-white from the MES-1388 report
52
+ '#1e2330', // already as dark as the sender fallback
53
+ '#767676', // fully neutral, zero chroma
54
+ ]
55
+
56
+ describe('accentSurface', () => {
57
+ it('always produces a surface its own ink can clear AA on', () => {
58
+ // The whole point of deriving rather than admitting: no extracted colour is
59
+ // ever rejected for contrast, and none is ever illegible.
60
+ for (const sample of SAMPLES) {
61
+ for (const [variant, ink] of [
62
+ ['dark', INK_ON_DARK],
63
+ ['light', INK_ON_LIGHT],
64
+ ] as const) {
65
+ const surface = accentSurface(sample, variant)
66
+ expect(surface, `${sample} on ${variant}`).toBeDefined()
67
+ expect(
68
+ contrastRatio(surface as string, ink),
69
+ `${sample} on ${variant}`
70
+ ).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TEXT)
71
+ // And the ink the card will actually pick agrees with that side.
72
+ expect(inkFor(surface as string)).toBe(ink)
73
+ }
74
+ }
75
+ })
76
+
77
+ it('keeps the image hue so different links stay distinguishable', () => {
78
+ for (const sample of SAMPLES) {
79
+ // Hue is only meaningful above a little chroma: `#FCF5E3` and `#767676`
80
+ // are near-neutral, where the angle is numerically unstable and invisible.
81
+ if (chromaOf(sample) < 0.03) continue
82
+ const expected = hueOf(sample)
83
+ for (const variant of ['dark', 'light'] as const) {
84
+ const surface = accentSurface(sample, variant) as string
85
+ const drift = Math.abs(hueOf(surface) - expected)
86
+ expect(
87
+ Math.min(drift, 360 - drift),
88
+ `${sample} ${variant}`
89
+ ).toBeLessThan(2)
90
+ }
91
+ }
92
+ })
93
+
94
+ it('separates the two sides, so each is legible under its own ink', () => {
95
+ // Sent lands dark, received pale — the same link, the same hue, opposite
96
+ // ends of the lightness axis because the inks are opposites.
97
+ for (const sample of SAMPLES) {
98
+ const sent = accentSurface(sample, 'dark') as string
99
+ const received = accentSurface(sample, 'light') as string
100
+ expect(contrastRatio(sent, INK_ON_DARK)).toBeGreaterThan(
101
+ contrastRatio(sent, INK_ON_LIGHT)
102
+ )
103
+ expect(contrastRatio(received, INK_ON_LIGHT)).toBeGreaterThan(
104
+ contrastRatio(received, INK_ON_DARK)
105
+ )
106
+ }
107
+ })
108
+
109
+ it('holds AA at every hue, at chroma beyond what sRGB can show', () => {
110
+ // Sweeps the failure mode that a fixed lightness stop could plausibly have:
111
+ // a hue whose maximum chroma pushes the derived colour out of gamut.
112
+ for (let hue = 0; hue < 360; hue += 15) {
113
+ const vivid = `#${[0, 120, 240]
114
+ .map((offset) =>
115
+ Math.round(127 + 127 * Math.cos(((hue + offset) * Math.PI) / 180))
116
+ .toString(16)
117
+ .padStart(2, '0')
118
+ )
119
+ .join('')}`
120
+ for (const [variant, ink] of [
121
+ ['dark', INK_ON_DARK],
122
+ ['light', INK_ON_LIGHT],
123
+ ] as const) {
124
+ const surface = accentSurface(vivid, variant)
125
+ expect(
126
+ contrastRatio(surface as string, ink),
127
+ `hue ${hue} on ${variant}`
128
+ ).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TEXT)
129
+ }
130
+ }
131
+ })
132
+
133
+ it('accepts the shapes the extractor and CSS disagree about', () => {
134
+ // `accent_color` arrives from the ugc-kit query as a bare hex often enough
135
+ // that a missing `#` must not silently drop the colour.
136
+ expect(accentSurface('7008E7', 'dark')).toBe(
137
+ accentSurface('#7008E7', 'dark')
138
+ )
139
+ expect(accentSurface(' #7008E7 ', 'dark')).toBe(
140
+ accentSurface('#7008E7', 'dark')
141
+ )
142
+ expect(accentSurface('#70E', 'dark')).toBeDefined()
143
+ })
144
+
145
+ it('has no surface when there was no colour to derive from', () => {
146
+ expect(accentSurface(undefined, 'dark')).toBeUndefined()
147
+ expect(accentSurface('', 'dark')).toBeUndefined()
148
+ expect(accentSurface('rebeccapurple', 'dark')).toBeUndefined()
149
+ expect(accentSurface('#ff', 'light')).toBeUndefined()
150
+ })
151
+ })