@linktr.ee/messaging-react 3.31.1 → 3.31.2-rc-1785926578

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 (33) hide show
  1. package/dist/{Card-EWN07XIl.cjs → Card-CF1TZXD5.cjs} +2 -2
  2. package/dist/{Card-EWN07XIl.cjs.map → Card-CF1TZXD5.cjs.map} +1 -1
  3. package/dist/{Card-CmlaSw7i.js → Card-CqYFVF6m.js} +2 -2
  4. package/dist/{Card-CmlaSw7i.js.map → Card-CqYFVF6m.js.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/index-Ct_Jgy8P.cjs +2 -0
  7. package/dist/index-Ct_Jgy8P.cjs.map +1 -0
  8. package/dist/index-ilHx7pIO.js +5672 -0
  9. package/dist/index-ilHx7pIO.js.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +6 -5
  12. package/dist/index.js +1 -1
  13. package/package.json +3 -5
  14. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +42 -27
  15. package/src/components/LinkAttachment/LinkAttachment.test.tsx +46 -39
  16. package/src/components/LinkAttachment/components/Composer/Card.tsx +1 -1
  17. package/src/components/LinkAttachment/components/Received/Card.tsx +2 -2
  18. package/src/components/LinkAttachment/components/Sent/Card.tsx +2 -2
  19. package/src/components/LinkAttachment/components/_shared/BubbleTail.tsx +16 -6
  20. package/src/components/LinkAttachment/components/_shared/CardBody.tsx +4 -5
  21. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +27 -19
  22. package/src/components/LinkAttachment/components/_shared/hex.ts +10 -12
  23. package/src/components/LinkAttachment/components/_shared/useChinPalette.ts +45 -48
  24. package/src/components/LinkAttachment/types.ts +6 -5
  25. package/src/styles.css +41 -4
  26. package/dist/index-CN3errpB.js +0 -7828
  27. package/dist/index-CN3errpB.js.map +0 -1
  28. package/dist/index-CcitzVoW.cjs +0 -5
  29. package/dist/index-CcitzVoW.cjs.map +0 -1
  30. package/src/components/LinkAttachment/components/_shared/accentSurface.test.ts +0 -135
  31. package/src/components/LinkAttachment/components/_shared/accentSurface.ts +0 -95
  32. package/src/components/LinkAttachment/components/_shared/chinContrast.test.ts +0 -95
  33. package/src/components/LinkAttachment/components/_shared/chinContrast.ts +0 -79
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-CcitzVoW.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-Ct_Jgy8P.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
@@ -696,16 +696,17 @@ export declare interface LinkAttachmentBaseProps {
696
696
  /**
697
697
  * Dominant colour extracted from `thumbnailUrl`, as a hex string
698
698
  * (`#RRGGBB` or `#RGB`). Paints the card and — when the card is the last
699
- * bubble in a same-author run — its tail. The card derives its own chin
700
- * text colour from this value for WCAG AA contrast; callers never pass a
699
+ * bubble in a same-author run — its tail. Its hue is kept while the lightness
700
+ * is swapped to a fixed per-side stop (`oklch(from …)` in `styles.css`); the
701
+ * stops clear WCAG AA at every hue by construction, so callers never pass a
701
702
  * text colour.
702
703
  *
703
704
  * Only painted while the hero image it was extracted from is actually on
704
705
  * screen. Omitted, unparseable, `layout: 'classic'`, no `thumbnailUrl`,
705
706
  * `status` other than `'ready'`, a playable-audio `mimeType` + `sourceUrl`
706
- * (whose hero is the native player rather than an image), or a hero that
707
- * fails to load, or the colour cannot clear AA against this side's ink =>
708
- * the card falls back to `BUBBLE_BG_BY_VARIANT[variant]`, the plain bubble fill.
707
+ * (whose hero is the native player rather than an image), or a hero that fails
708
+ * to load => the card keeps the plain bubble fill. Browsers without
709
+ * relative-colour-syntax support also keep that fill (progressive enhancement).
709
710
  */
710
711
  accentColor?: string;
711
712
  /**
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-CN3errpB.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-ilHx7pIO.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.31.1",
3
+ "version": "3.31.2-rc-1785926578",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -50,10 +50,9 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@linktr.ee/component-library": "11.8.6",
53
- "@linktr.ee/messaging-core": "^2.2.0",
53
+ "@linktr.ee/messaging-core": "2.4.0-rc-1785926578",
54
54
  "@linktr.ee/messaging-taxonomy": "^0.5.1",
55
- "@phosphor-icons/react": "^2.1.10",
56
- "culori": "^4.0.2"
55
+ "@phosphor-icons/react": "^2.1.10"
57
56
  },
58
57
  "devDependencies": {
59
58
  "@storybook/addon-essentials": "^8.5.0",
@@ -67,7 +66,6 @@
67
66
  "@testing-library/jest-dom": "^6.1.5",
68
67
  "@testing-library/react": "^14.1.2",
69
68
  "@testing-library/user-event": "^14.5.1",
70
- "@types/culori": "^4",
71
69
  "@types/react": "^18.3.24",
72
70
  "@types/react-dom": "^18.3.7",
73
71
  "@vitejs/plugin-react": "^4.2.1",
@@ -7,9 +7,8 @@ import {
7
7
  import type { Meta, StoryFn } from '@storybook/react'
8
8
  import React from 'react'
9
9
 
10
- import { accentSurface } from './components/_shared/accentSurface'
11
10
  import type { LinkAttachmentVariant } from './components/_shared/CardShell'
12
- import { BUBBLE_BG_BY_VARIANT, inkFor } from './components/_shared/chinContrast'
11
+ import { HEX_PATTERN, toCssHex } from './components/_shared/hex'
13
12
 
14
13
  import LinkAttachment from '.'
15
14
 
@@ -388,9 +387,9 @@ const AUDIO_PREVIEW = {
388
387
  * player. The artwork is still handed over as `thumbnailUrl` and there is
389
388
  * still no `<img>`, so `onImageError` can never clear the accent — the gate
390
389
  * has to be up front. These three cells are the regression that prompted the
391
- * rule: `accentStyle` is an inline `style` and outranks the `bg-[#F2F3F4]`
392
- * audio fill, so a purple card would ship around a native player with the
393
- * artwork nowhere in sight.
390
+ * rule: the derived surface outranks the `bg-[#F2F3F4]` audio fill, so a
391
+ * purple card would ship around a native player with the artwork nowhere in
392
+ * sight.
394
393
  * - **No `thumbnailUrl`** leaves the type-icon placeholder, which is not an
395
394
  * image the accent could have come from. Stream reaches this whenever a link
396
395
  * preview carries a playable clip but no `image_url`.
@@ -462,8 +461,8 @@ AccentWithoutVisibleHero.parameters = { chromatic: { disableSnapshot: false } }
462
461
 
463
462
  /**
464
463
  * Max-chroma sRGB colour for a hue — three phase-shifted cosines, so stepping
465
- * the hue walks the wheel at the most saturated triple each hue has. Mirrors
466
- * the generator `accentSurface.test.ts` sweeps.
464
+ * the hue walks the wheel at the most saturated triple each hue has. Many of
465
+ * these sit outside sRGB at the surface stops, where the browser gamut-maps.
467
466
  */
468
467
  const vividHex = (hue: number): string =>
469
468
  `#${[0, 120, 240]
@@ -479,7 +478,7 @@ const HUE_SWEEP = Array.from({ length: 24 }, (_, i) => ({
479
478
  accentColor: vividHex(i * 15),
480
479
  }))
481
480
 
482
- // Neutrals and near-limits where the AA gate and the ink flip actually decide
481
+ // Neutrals and near-limits where the ink and the fallback actually decide
483
482
  // something, plus the two inputs that must never derive a surface.
484
483
  const EDGE_SAMPLES: Array<{ label: string; accentColor?: string }> = [
485
484
  { label: 'mock #738883', accentColor: '#738883' },
@@ -492,13 +491,24 @@ const EDGE_SAMPLES: Array<{ label: string; accentColor?: string }> = [
492
491
  { label: 'malformed', accentColor: 'rebeccapurple' },
493
492
  ]
494
493
 
494
+ // Ink is fixed per side — white on the dark stop, near-black on the pale stop.
495
+ const INK_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
496
+ dark: '#FFFFFF',
497
+ light: '#040403',
498
+ }
499
+ // The plain bubble fill each side falls back to, as a self-contained arbitrary
500
+ // value (mirrors `CardShell`). The accent rule in `styles.css` paints over it.
501
+ const FALLBACK_BG_CLASS: Record<LinkAttachmentVariant, string> = {
502
+ dark: 'bg-[#1e2330]',
503
+ light: 'bg-[#f1f0ee]',
504
+ }
505
+
495
506
  /**
496
- * One derivation cell: the surface the card would paint for this accent and
497
- * variant, drawn in the ink `inkFor` picks against it. `accentSurface` and
498
- * `inkFor` are the only outputs `culori` feeds into the card, so this paints
499
- * exactly what a colour-library swap could move with the resolved hex printed
500
- * so a shift also reads as a text diff. A refused accent shows the variant's
501
- * fallback fill, tagged.
507
+ * One derivation cell driven by the *same* CSS the card uses: a
508
+ * `.messaging-link-card` publishing `--accent`, so `styles.css` derives the
509
+ * surface via `oklch(from …)`. A refused accent (`none` / `malformed`) publishes
510
+ * nothing and shows the fallback fill. The resolved surface hex lives only in
511
+ * the browser, so the cell labels its input.
502
512
  */
503
513
  const DerivedSwatch = ({
504
514
  accentColor,
@@ -507,17 +517,23 @@ const DerivedSwatch = ({
507
517
  accentColor?: string
508
518
  variant: LinkAttachmentVariant
509
519
  }) => {
510
- const surface = accentSurface(accentColor, variant)
511
- const painted = surface ?? BUBBLE_BG_BY_VARIANT[variant]
520
+ const accentHex =
521
+ accentColor != null && HEX_PATTERN.test(accentColor.trim())
522
+ ? toCssHex(accentColor)
523
+ : undefined
512
524
  return (
513
525
  <div
514
- className="flex h-12 flex-col justify-between rounded-md px-2 py-1.5 text-[11px] font-medium"
515
- style={{ backgroundColor: painted, color: inkFor(painted) }}
526
+ className={`messaging-link-card flex h-12 flex-col justify-between rounded-md px-2 py-1.5 text-[11px] font-medium ${FALLBACK_BG_CLASS[variant]}`}
527
+ data-variant={variant}
528
+ style={
529
+ {
530
+ color: INK_BY_VARIANT[variant],
531
+ ...(accentHex ? { '--accent': accentHex } : {}),
532
+ } as React.CSSProperties
533
+ }
516
534
  >
517
535
  <span>Yoga on Ice</span>
518
- <span className="font-mono opacity-80">
519
- {surface ?? `fallback ${painted}`}
520
- </span>
536
+ <span className="font-mono opacity-80">{accentHex ?? 'fallback'}</span>
521
537
  </div>
522
538
  )
523
539
  }
@@ -527,12 +543,11 @@ const GridHead = ({ children }: { children: React.ReactNode }) => (
527
543
  )
528
544
 
529
545
  /**
530
- * Isolates the colour maths behind the accent chin: for every hue at maximum
531
- * chroma and every neutral/edge input, the `dark` (Sent) and `light` (Received)
532
- * surfaces `accentSurface` derives, each in the ink `inkFor` returns for it.
533
- * One snapshot, no hero pixels a dense visual companion to the
534
- * `accentSurface` hue-sweep unit test, so any drift in the derivation shows up
535
- * as both a colour change and a hex text diff across adjacent rows.
546
+ * Isolates the accent-chin surface: for every hue at maximum chroma and every
547
+ * neutral/edge input, the `dark` (Sent) and `light` (Received) surfaces CSS
548
+ * derives from the published `--accent`. One snapshot, no hero pixels — the
549
+ * dense diff target for the derivation. In-gamut inputs (the neutral/edge rows)
550
+ * are stable; the max-chroma hue-sweep rows depend on the browser's gamut map.
536
551
  */
537
552
  export const AccentDerivation: StoryFn = () => (
538
553
  <div className="min-h-screen w-full bg-[#F9F7F4] p-8">
@@ -119,28 +119,32 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
119
119
  thumbnailUrl: 'https://cdn.example.com/yoga.jpg',
120
120
  }
121
121
 
122
- const surfaceOf = (element: HTMLElement) => element.style.backgroundColor
123
-
124
- it('derives a dark surface for the sender and inks it white', async () => {
122
+ // The colour derivation itself is CSS (`oklch(from )` in styles.css) and is
123
+ // not computed by jsdom, so these tests assert the contract React owns: the
124
+ // raw extracted hex is published as `--accent` (and dropped when there is no
125
+ // visible hero), leaving the surface derivation to the browser.
126
+ const accentOf = (element: HTMLElement) =>
127
+ element.style.getPropertyValue('--accent')
128
+
129
+ it('publishes the accent for the sender and inks the chin white', async () => {
125
130
  renderWithProviders(
126
131
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
127
132
  )
128
133
  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(accentOf(root)).toBe(ACCENT)
135
+ // No inline background: the surface is derived from `--accent` in CSS, the
136
+ // raw accent is never painted verbatim.
137
+ expect(root.style.backgroundColor).toBe('')
134
138
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
135
139
  })
136
140
 
137
- it('derives a pale surface for the receiver and inks it near-black', async () => {
141
+ it('publishes the accent for the receiver and inks the chin near-black', async () => {
138
142
  renderWithProviders(
139
143
  <LinkAttachment.Received {...CARD_PROPS} accentColor={ACCENT} />
140
144
  )
141
145
  const root = await screen.findByRole('link')
142
- expect(surfaceOf(root)).not.toBe('')
143
- expect(root).not.toHaveStyle({ backgroundColor: ACCENT })
146
+ expect(accentOf(root)).toBe(ACCENT)
147
+ expect(root.style.backgroundColor).toBe('')
144
148
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
145
149
  })
146
150
 
@@ -151,13 +155,13 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
151
155
  const sent = renderWithProviders(
152
156
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={accentColor} />
153
157
  )
154
- expect(surfaceOf(await screen.findByRole('link'))).not.toBe('')
158
+ expect(accentOf(await screen.findByRole('link'))).toBe(accentColor)
155
159
  sent.unmount()
156
160
 
157
161
  const received = renderWithProviders(
158
162
  <LinkAttachment.Received {...CARD_PROPS} accentColor={accentColor} />
159
163
  )
160
- expect(surfaceOf(await screen.findByRole('link'))).not.toBe('')
164
+ expect(accentOf(await screen.findByRole('link'))).toBe(accentColor)
161
165
  received.unmount()
162
166
  }
163
167
  })
@@ -165,7 +169,7 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
165
169
  it('falls back to a self-contained fill, not a consumer theme token', async () => {
166
170
  renderWithProviders(<LinkAttachment.Sent {...CARD_PROPS} />)
167
171
  const root = await screen.findByRole('link')
168
- expect(surfaceOf(root)).toBe('')
172
+ expect(accentOf(root)).toBe('')
169
173
 
170
174
  // The fill has to survive in consumers that do not share our Tailwind
171
175
  // theme. This package ships no utilities, so consumers generate our classes
@@ -186,8 +190,8 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
186
190
  <LinkAttachment.Sent {...CARD_PROPS} accentColor="rebeccapurple" />
187
191
  )
188
192
  const root = await screen.findByRole('link')
189
- expect(root.style.backgroundColor).toBe('')
190
- // Ink still comes from the surface — here the sender's fallback fill.
193
+ expect(accentOf(root)).toBe('')
194
+ // Ink still comes from the side — here the sender's fallback fill.
191
195
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
192
196
  })
193
197
 
@@ -196,12 +200,12 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
196
200
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
197
201
  )
198
202
  const root = await screen.findByRole('link')
199
- expect(root.style.backgroundColor).not.toBe('')
203
+ expect(accentOf(root)).toBe(ACCENT)
200
204
 
201
205
  // A surface derived from an image the viewer cannot see is a lie, so the
202
206
  // card falls all the way back rather than keeping a colour with no source.
203
207
  fireEvent.error(screen.getByRole('img'))
204
- expect(root.style.backgroundColor).toBe('')
208
+ expect(accentOf(root)).toBe('')
205
209
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
206
210
  })
207
211
 
@@ -222,12 +226,12 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
222
226
  const shell = container.querySelector(
223
227
  '[class*="select-none"]'
224
228
  ) as HTMLElement
225
- expect(shell.style.backgroundColor).not.toBe('')
229
+ expect(shell.style.getPropertyValue('--accent')).toBe(ACCENT)
226
230
  expect(container.querySelector('video')).toHaveAttribute('poster')
227
231
 
228
232
  fireEvent.error(container.querySelector('img') as HTMLImageElement)
229
233
 
230
- expect(shell.style.backgroundColor).toBe('')
234
+ expect(shell.style.getPropertyValue('--accent')).toBe('')
231
235
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
232
236
  // The failed poster is dropped with the accent, so the player falls back to
233
237
  // its own first frame instead of retrying a 404.
@@ -252,7 +256,7 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
252
256
  <LinkAttachment.Sent {...CARD_PROPS} {...AUDIO} accentColor={ACCENT} />
253
257
  )
254
258
  const sent = container.firstElementChild as HTMLElement
255
- expect(sent.style.backgroundColor).toBe('')
259
+ expect(sent.style.getPropertyValue('--accent')).toBe('')
256
260
  expect(sent.className).toContain(AUDIO_BG_CLASS)
257
261
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
258
262
  expect(screen.queryByRole('img')).toBeNull()
@@ -266,7 +270,7 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
266
270
  />
267
271
  )
268
272
  const received = await screen.findByTestId('link-attachment')
269
- expect(received.style.backgroundColor).toBe('')
273
+ expect(received.style.getPropertyValue('--accent')).toBe('')
270
274
  expect(received.className).toContain(AUDIO_BG_CLASS)
271
275
  })
272
276
 
@@ -281,29 +285,27 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
281
285
  />
282
286
  )
283
287
  const root = await screen.findByRole('link')
284
- expect(root.style.backgroundColor).toBe('')
288
+ expect(accentOf(root)).toBe('')
285
289
  // Variant fill, asserted by shape not literal — see the self-contained-fill
286
290
  // test above for why this must stay an arbitrary value (MES-1388).
287
291
  expect(root.className).toMatch(/bg-\[/)
288
292
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
289
293
  })
290
294
 
291
- it('normalizes a hashless accent so the background actually paints', async () => {
295
+ it('normalizes a hashless accent so the derivation actually resolves', async () => {
292
296
  // The extractor returns a bare hex often enough that dropping it would lose
293
- // the colour silently — `background-color: 7008E7` is invalid CSS (MES-1349).
297
+ // the colour silently — `oklch(from 7008E7 …)` never resolves, so the
298
+ // published `--accent` must carry the `#`.
294
299
  const hashless = renderWithProviders(
295
300
  <LinkAttachment.Sent {...CARD_PROPS} accentColor="7008E7" />
296
301
  )
297
- const derived = (await screen.findByRole('link')).style.backgroundColor
298
- expect(derived).not.toBe('')
302
+ expect(accentOf(await screen.findByRole('link'))).toBe('#7008E7')
299
303
  hashless.unmount()
300
304
 
301
305
  renderWithProviders(
302
306
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
303
307
  )
304
- expect((await screen.findByRole('link')).style.backgroundColor).toBe(
305
- derived
306
- )
308
+ expect(accentOf(await screen.findByRole('link'))).toBe('#7008E7')
307
309
  })
308
310
 
309
311
  it('lets a loading status outrank the audio hero on all three cards', () => {
@@ -349,13 +351,15 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
349
351
  groupPosition="end"
350
352
  />
351
353
  )
352
- // The tail paints outside the shell's own box, so it takes the surface as an
353
- // inline colour — it has to be the *derived* one or the tail and the card
354
- // would disagree.
354
+ // The tail paints outside the shell's own box, so it can't inherit the
355
+ // shell's computed background — it publishes the same `--accent` and derives
356
+ // the surface itself in CSS, or the tail and the card would disagree.
355
357
  const card = await screen.findByRole('link')
356
- expect(screen.getByTestId('link-card-tail')).toHaveStyle({
357
- backgroundColor: card.style.backgroundColor,
358
- })
358
+ const tail = screen.getByTestId('link-card-tail')
359
+ expect(tail.style.getPropertyValue('--accent')).toBe(
360
+ card.style.getPropertyValue('--accent')
361
+ )
362
+ expect(tail.style.getPropertyValue('--accent')).toBe(ACCENT)
359
363
  unmount()
360
364
 
361
365
  // Mid-run the following bubble owns the tail.
@@ -370,10 +374,13 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
370
374
  })
371
375
 
372
376
  it('falls back to the variant fill for the tail when there is no accent', async () => {
377
+ // No accent published, so no `--accent`: the tail's `[data-variant='dark']`
378
+ // rule in styles.css paints the sender's plain `#1e2330` bubble fill (the
379
+ // computed value isn't observable in jsdom — see the note on `accentOf`).
373
380
  renderWithProviders(<LinkAttachment.Sent {...CARD_PROPS} />)
374
- expect(await screen.findByTestId('link-card-tail')).toHaveStyle({
375
- backgroundColor: '#1e2330',
376
- })
381
+ const tail = await screen.findByTestId('link-card-tail')
382
+ expect(tail.style.getPropertyValue('--accent')).toBe('')
383
+ expect(tail).toHaveAttribute('data-variant', 'dark')
377
384
  })
378
385
 
379
386
  it('exposes the loading skeleton as a labelled busy region', async () => {
@@ -130,7 +130,7 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
130
130
  <CardShell
131
131
  variant="dark"
132
132
  fixedHeight={!isClassic}
133
- accentStyle={chin.accentStyle}
133
+ accentHex={chin.accentHex}
134
134
  busy={chin.isLoading}
135
135
  topRight={isClassic ? dismissButton : undefined}
136
136
  >
@@ -108,7 +108,7 @@ const ReceivedCard: React.FC<ReceivedCardProps> = ({
108
108
 
109
109
  return (
110
110
  <BubbleTail
111
- color={chin.tailColor}
111
+ accentHex={chin.accentHex}
112
112
  side="receiver"
113
113
  // Only the last card in a same-author run carries the tail; the audio
114
114
  // player card is not a bubble, so it never does.
@@ -123,7 +123,7 @@ const ReceivedCard: React.FC<ReceivedCardProps> = ({
123
123
  onClick={shellOnClick}
124
124
  ariaLabel={title ?? 'Open attachment preview'}
125
125
  fixedHeight={layout === 'featured' && audioBg == null}
126
- accentStyle={chin.accentStyle}
126
+ accentHex={chin.accentHex}
127
127
  busy={chin.isLoading}
128
128
  bgClassName={audioBg}
129
129
  data-testid="link-attachment"
@@ -92,7 +92,7 @@ const SentCard: React.FC<SentCardProps> = ({
92
92
 
93
93
  return (
94
94
  <BubbleTail
95
- color={chin.tailColor}
95
+ accentHex={chin.accentHex}
96
96
  side="sender"
97
97
  // Only the last card in a same-author run carries the tail; the audio
98
98
  // player card is not a bubble, so it never does.
@@ -105,7 +105,7 @@ const SentCard: React.FC<SentCardProps> = ({
105
105
  href={shellHref}
106
106
  onClick={shellOnClick}
107
107
  fixedHeight={layout === 'featured' && !isAudio}
108
- accentStyle={chin.accentStyle}
108
+ accentHex={chin.accentHex}
109
109
  busy={chin.isLoading}
110
110
  bgClassName={isAudio ? AUDIO_BG_CLASS : undefined}
111
111
  >
@@ -3,11 +3,13 @@ import React from 'react'
3
3
 
4
4
  export interface BubbleTailProps {
5
5
  /**
6
- * Hex the tail paints in the card's accent colour, or its variant
7
- * fallback fill. Inline rather than a class because the accent is a
8
- * runtime value extracted from the attachment image.
6
+ * Raw extracted accent colour, published as `--accent` so the tail derives
7
+ * the same surface as the card in CSS (`styles.css`), or `undefined` to fall
8
+ * back to this side's plain bubble fill. The tail paints outside the shell's
9
+ * box and can't inherit its background, so it runs the derivation off its own
10
+ * `--accent`.
9
11
  */
10
- color: string
12
+ accentHex?: string
11
13
  /** Sender tails hang off the right edge, receiver tails mirror to the left. */
12
14
  side: 'sender' | 'receiver'
13
15
  /**
@@ -30,13 +32,20 @@ export interface BubbleTailProps {
30
32
  * tinted from the text-bubble surface would clash with a coloured card.
31
33
  */
32
34
  const BubbleTail: React.FC<BubbleTailProps> = ({
33
- color,
35
+ accentHex,
34
36
  side,
35
37
  visible,
36
38
  children,
37
39
  }) => {
38
40
  if (!visible) return <>{children}</>
39
41
 
42
+ // Sender tails sit on the dark stop, receiver tails on the pale one — the
43
+ // same mapping the cards use (`SentCard` is `dark`, `ReceivedCard` `light`).
44
+ const variant = side === 'sender' ? 'dark' : 'light'
45
+ const accentStyle = accentHex
46
+ ? ({ '--accent': accentHex } as React.CSSProperties)
47
+ : undefined
48
+
40
49
  return (
41
50
  // `isolate` keeps the tail's negative z-index from escaping behind the
42
51
  // thread background; `w-fit` stops the wrapper from stretching in the
@@ -46,7 +55,8 @@ const BubbleTail: React.FC<BubbleTailProps> = ({
46
55
  <span
47
56
  aria-hidden
48
57
  data-testid="link-card-tail"
49
- style={{ backgroundColor: color }}
58
+ data-variant={variant}
59
+ style={accentStyle}
50
60
  className={classNames(
51
61
  'messaging-link-card-tail',
52
62
  side === 'receiver' && 'messaging-link-card-tail--received'
@@ -15,11 +15,10 @@ export interface CardBodyProps {
15
15
  /** Footer URL shown below the description. Ignored when `cta` is set. */
16
16
  url?: string
17
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).
18
+ * Ink for both chin lines, from `useChinPalette` — fixed per surface (white on
19
+ * the dark stop, near-black on the pale stop and the audio strip). Replaces the
20
+ * variant's own `text-*` classes (including their opacity) on both lines, so a
21
+ * card whose surface is not the variant default still reads.
23
22
  */
24
23
  chinTextColor?: string
25
24
  /**
@@ -35,11 +35,12 @@ export interface CardShellProps {
35
35
  */
36
36
  bgClassName?: string
37
37
  /**
38
- * Inline background for the dynamic accent colour extracted from the
39
- * attachment image. Wins over `bgClassName` / the variant default when
40
- * set Tailwind cannot express a runtime hex.
38
+ * Raw extracted accent colour, published as the `--accent` custom property.
39
+ * `styles.css` derives the card surface from it (`oklch(from …)`) and paints
40
+ * it over the `bg-[…]` fallback where the browser supports it. `undefined`
41
+ * keeps the variant / `bgClassName` fill.
41
42
  */
42
- accentStyle?: React.CSSProperties
43
+ accentHex?: string
43
44
  /**
44
45
  * Locks the card to Figma's 250px box and makes it a flex column, so the
45
46
  * hero absorbs whatever height the chin leaves. Only the `featured` layout
@@ -88,34 +89,32 @@ const CardShell: React.FC<CardShellProps> = ({
88
89
  rootRef,
89
90
  topRight,
90
91
  bgClassName,
91
- accentStyle,
92
+ accentHex,
92
93
  fixedHeight = false,
93
94
  busy = false,
94
95
  'data-testid': dataTestId,
95
96
  }) => {
96
97
  const isInteractive = href != null || onClick != null
97
98
  const className = classNames(
99
+ 'messaging-link-card',
98
100
  SHELL_CLASS,
99
101
  fixedHeight ? FIXED_HEIGHT_CLASS : 'block',
100
- // Kept underneath `accentStyle` rather than swapped out: the inline
101
- // background wins on specificity, and leaving the class in place means
102
- // an accent that somehow doesn't paint still lands on a sane surface.
102
+ // The plain fallback fill, kept underneath the `--accent` derivation: the
103
+ // `.messaging-link-card[style*='--accent']` rule in `styles.css` outranks
104
+ // this single class, so a published accent paints over it and an unset one
105
+ // lands on this surface.
103
106
  //
104
- // MUST be an arbitrary value, not a theme token. This package ships no
105
- // Tailwind utilities `dist/assets/index.css` is only the hand-written
106
- // `.str-chat`/`.mes-*` CSS so consumers generate our classes by scanning
107
- // `messaging-react/dist` against *their own* theme. `shade` comes from the
108
- // component-library preset that this package builds with; frontyard builds
109
- // on the Arbor preset and has no such colour, so `bg-shade` was silently
110
- // dropped, the chin rendered transparent, and the own-message `color: white`
111
- // left the title and URL invisible (MES-1388). An arbitrary value is
112
- // generated from the class name alone and cannot depend on consumer theme.
107
+ // Must be an arbitrary value, not a theme token: this package ships no
108
+ // Tailwind utilities, so consumers generate our classes by scanning
109
+ // `messaging-react/dist` against their own theme. A theme colour a consumer
110
+ // lacks compiles to nothing and leaves the chin transparent; an arbitrary
111
+ // value is generated from the class name alone.
113
112
  //
114
113
  // Both values are the plain message bubble's own fill, so a card with no
115
114
  // usable accent matches the text bubbles around it: `#1e2330` is
116
115
  // `--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.
116
+ // `--str-chat__message-bubble-background-color`, mirrored by the bubble
117
+ // tail's own fills in `styles.css`.
119
118
  bgClassName ?? (variant === 'dark' ? 'bg-[#1e2330]' : 'bg-[#f1f0ee]'),
120
119
  // `focus-ring` is a design-system utility from the component-library
121
120
  // tailwind preset — outline-none + a black 2px focus-visible ring
@@ -127,6 +126,12 @@ const CardShell: React.FC<CardShellProps> = ({
127
126
  ? { role: 'status', 'aria-busy': true, 'aria-label': LOADING_LABEL }
128
127
  : undefined
129
128
 
129
+ // Published as a custom property, not a `background`: the `[style*='--accent']`
130
+ // rule in `styles.css` keys off its presence and derives the surface in CSS.
131
+ const accentStyle = accentHex
132
+ ? ({ '--accent': accentHex } as React.CSSProperties)
133
+ : undefined
134
+
130
135
  const corner = topRight ? (
131
136
  <div className="pointer-events-auto absolute right-3 top-3 z-10">
132
137
  {topRight}
@@ -142,6 +147,7 @@ const CardShell: React.FC<CardShellProps> = ({
142
147
  rel="noopener noreferrer"
143
148
  onClick={onClick}
144
149
  data-testid={dataTestId}
150
+ data-variant={variant}
145
151
  className={className}
146
152
  style={accentStyle}
147
153
  {...busyProps}
@@ -160,6 +166,7 @@ const CardShell: React.FC<CardShellProps> = ({
160
166
  onClick={onClick}
161
167
  aria-label={ariaLabel}
162
168
  data-testid={dataTestId}
169
+ data-variant={variant}
163
170
  className={classNames(className, 'text-left')}
164
171
  style={accentStyle}
165
172
  {...busyProps}
@@ -174,6 +181,7 @@ const CardShell: React.FC<CardShellProps> = ({
174
181
  <div
175
182
  ref={rootRef as React.Ref<HTMLDivElement>}
176
183
  data-testid={dataTestId}
184
+ data-variant={variant}
177
185
  className={className}
178
186
  style={accentStyle}
179
187
  {...busyProps}