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

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-Ch6N2pkc.js} +2 -2
  4. package/dist/{Card-CmlaSw7i.js.map → Card-Ch6N2pkc.js.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/index-BKkCNnea.js +5677 -0
  7. package/dist/index-BKkCNnea.js.map +1 -0
  8. package/dist/index-Ct_Jgy8P.cjs +2 -0
  9. package/dist/index-Ct_Jgy8P.cjs.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 +44 -27
  15. package/src/components/LinkAttachment/LinkAttachment.test.tsx +47 -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 +22 -9
  22. package/src/components/LinkAttachment/components/_shared/hex.ts +7 -7
  23. package/src/components/LinkAttachment/components/_shared/useChinPalette.ts +36 -27
  24. package/src/components/LinkAttachment/types.ts +6 -5
  25. package/src/styles.css +39 -0
  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-BKkCNnea.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-1785926144",
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-1785926144",
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. These are
465
+ * the out-of-gamut extremes where the CSS derivation and the old JS one diverge.
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,25 @@ 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 …)`. No JS colour maths this is exactly the render
510
+ * path a colour-library swap moves. A refused accent (`none` / `malformed`)
511
+ * publishes nothing and shows the fallback fill. The resolved surface hex is
512
+ * the browser's now, not JS's, so only the input is labelled.
502
513
  */
503
514
  const DerivedSwatch = ({
504
515
  accentColor,
@@ -507,17 +518,23 @@ const DerivedSwatch = ({
507
518
  accentColor?: string
508
519
  variant: LinkAttachmentVariant
509
520
  }) => {
510
- const surface = accentSurface(accentColor, variant)
511
- const painted = surface ?? BUBBLE_BG_BY_VARIANT[variant]
521
+ const accentHex =
522
+ accentColor != null && HEX_PATTERN.test(accentColor.trim())
523
+ ? toCssHex(accentColor)
524
+ : undefined
512
525
  return (
513
526
  <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) }}
527
+ 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]}`}
528
+ data-variant={variant}
529
+ style={
530
+ {
531
+ color: INK_BY_VARIANT[variant],
532
+ ...(accentHex ? { '--accent': accentHex } : {}),
533
+ } as React.CSSProperties
534
+ }
516
535
  >
517
536
  <span>Yoga on Ice</span>
518
- <span className="font-mono opacity-80">
519
- {surface ?? `fallback ${painted}`}
520
- </span>
537
+ <span className="font-mono opacity-80">{accentHex ?? 'fallback'}</span>
521
538
  </div>
522
539
  )
523
540
  }
@@ -527,12 +544,12 @@ const GridHead = ({ children }: { children: React.ReactNode }) => (
527
544
  )
528
545
 
529
546
  /**
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.
547
+ * Isolates the accent-chin surface: for every hue at maximum chroma and every
548
+ * neutral/edge input, the `dark` (Sent) and `light` (Received) surfaces CSS
549
+ * derives from the published `--accent`. One snapshot, no hero pixels — the
550
+ * dense diff target for the MES-1412 culori→CSS swap. In-gamut inputs match the
551
+ * old JS output exactly; the max-chroma hue-sweep rows are where the browser's
552
+ * gamut clip diverges from culori's `clampChroma`, and will visibly diff.
536
553
  */
537
554
  export const AccentDerivation: StoryFn = () => (
538
555
  <div className="min-h-screen w-full bg-[#F9F7F4] p-8">
@@ -119,28 +119,33 @@ 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 the unit tests assert the *contract* React owns:
124
+ // the raw extracted hex is published as `--accent` (and dropped when there is
125
+ // no visible hero), leaving the surface derivation to the browser. Pixel
126
+ // parity is covered by the `AccentDerivation` Chromatic story (MES-1412).
127
+ const accentOf = (element: HTMLElement) =>
128
+ element.style.getPropertyValue('--accent')
129
+
130
+ it('publishes the accent for the sender and inks the chin white', async () => {
125
131
  renderWithProviders(
126
132
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
127
133
  )
128
134
  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 })
135
+ expect(accentOf(root)).toBe(ACCENT)
136
+ // No inline background: the surface is derived from `--accent` in CSS, not
137
+ // painted verbatim (painting the raw accent is what MES-1388 reported).
138
+ expect(root.style.backgroundColor).toBe('')
134
139
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
135
140
  })
136
141
 
137
- it('derives a pale surface for the receiver and inks it near-black', async () => {
142
+ it('publishes the accent for the receiver and inks the chin near-black', async () => {
138
143
  renderWithProviders(
139
144
  <LinkAttachment.Received {...CARD_PROPS} accentColor={ACCENT} />
140
145
  )
141
146
  const root = await screen.findByRole('link')
142
- expect(surfaceOf(root)).not.toBe('')
143
- expect(root).not.toHaveStyle({ backgroundColor: ACCENT })
147
+ expect(accentOf(root)).toBe(ACCENT)
148
+ expect(root.style.backgroundColor).toBe('')
144
149
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
145
150
  })
146
151
 
@@ -151,13 +156,13 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
151
156
  const sent = renderWithProviders(
152
157
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={accentColor} />
153
158
  )
154
- expect(surfaceOf(await screen.findByRole('link'))).not.toBe('')
159
+ expect(accentOf(await screen.findByRole('link'))).toBe(accentColor)
155
160
  sent.unmount()
156
161
 
157
162
  const received = renderWithProviders(
158
163
  <LinkAttachment.Received {...CARD_PROPS} accentColor={accentColor} />
159
164
  )
160
- expect(surfaceOf(await screen.findByRole('link'))).not.toBe('')
165
+ expect(accentOf(await screen.findByRole('link'))).toBe(accentColor)
161
166
  received.unmount()
162
167
  }
163
168
  })
@@ -165,7 +170,7 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
165
170
  it('falls back to a self-contained fill, not a consumer theme token', async () => {
166
171
  renderWithProviders(<LinkAttachment.Sent {...CARD_PROPS} />)
167
172
  const root = await screen.findByRole('link')
168
- expect(surfaceOf(root)).toBe('')
173
+ expect(accentOf(root)).toBe('')
169
174
 
170
175
  // The fill has to survive in consumers that do not share our Tailwind
171
176
  // theme. This package ships no utilities, so consumers generate our classes
@@ -186,8 +191,8 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
186
191
  <LinkAttachment.Sent {...CARD_PROPS} accentColor="rebeccapurple" />
187
192
  )
188
193
  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.
194
+ expect(accentOf(root)).toBe('')
195
+ // Ink still comes from the side — here the sender's fallback fill.
191
196
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
192
197
  })
193
198
 
@@ -196,12 +201,12 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
196
201
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
197
202
  )
198
203
  const root = await screen.findByRole('link')
199
- expect(root.style.backgroundColor).not.toBe('')
204
+ expect(accentOf(root)).toBe(ACCENT)
200
205
 
201
206
  // A surface derived from an image the viewer cannot see is a lie, so the
202
207
  // card falls all the way back rather than keeping a colour with no source.
203
208
  fireEvent.error(screen.getByRole('img'))
204
- expect(root.style.backgroundColor).toBe('')
209
+ expect(accentOf(root)).toBe('')
205
210
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
206
211
  })
207
212
 
@@ -222,12 +227,12 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
222
227
  const shell = container.querySelector(
223
228
  '[class*="select-none"]'
224
229
  ) as HTMLElement
225
- expect(shell.style.backgroundColor).not.toBe('')
230
+ expect(shell.style.getPropertyValue('--accent')).toBe(ACCENT)
226
231
  expect(container.querySelector('video')).toHaveAttribute('poster')
227
232
 
228
233
  fireEvent.error(container.querySelector('img') as HTMLImageElement)
229
234
 
230
- expect(shell.style.backgroundColor).toBe('')
235
+ expect(shell.style.getPropertyValue('--accent')).toBe('')
231
236
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
232
237
  // The failed poster is dropped with the accent, so the player falls back to
233
238
  // its own first frame instead of retrying a 404.
@@ -252,7 +257,7 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
252
257
  <LinkAttachment.Sent {...CARD_PROPS} {...AUDIO} accentColor={ACCENT} />
253
258
  )
254
259
  const sent = container.firstElementChild as HTMLElement
255
- expect(sent.style.backgroundColor).toBe('')
260
+ expect(sent.style.getPropertyValue('--accent')).toBe('')
256
261
  expect(sent.className).toContain(AUDIO_BG_CLASS)
257
262
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
258
263
  expect(screen.queryByRole('img')).toBeNull()
@@ -266,7 +271,7 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
266
271
  />
267
272
  )
268
273
  const received = await screen.findByTestId('link-attachment')
269
- expect(received.style.backgroundColor).toBe('')
274
+ expect(received.style.getPropertyValue('--accent')).toBe('')
270
275
  expect(received.className).toContain(AUDIO_BG_CLASS)
271
276
  })
272
277
 
@@ -281,29 +286,27 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
281
286
  />
282
287
  )
283
288
  const root = await screen.findByRole('link')
284
- expect(root.style.backgroundColor).toBe('')
289
+ expect(accentOf(root)).toBe('')
285
290
  // Variant fill, asserted by shape not literal — see the self-contained-fill
286
291
  // test above for why this must stay an arbitrary value (MES-1388).
287
292
  expect(root.className).toMatch(/bg-\[/)
288
293
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
289
294
  })
290
295
 
291
- it('normalizes a hashless accent so the background actually paints', async () => {
296
+ it('normalizes a hashless accent so the derivation actually resolves', async () => {
292
297
  // 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).
298
+ // the colour silently — `oklch(from 7008E7 …)` never resolves (MES-1349), so
299
+ // the published `--accent` must carry the `#`.
294
300
  const hashless = renderWithProviders(
295
301
  <LinkAttachment.Sent {...CARD_PROPS} accentColor="7008E7" />
296
302
  )
297
- const derived = (await screen.findByRole('link')).style.backgroundColor
298
- expect(derived).not.toBe('')
303
+ expect(accentOf(await screen.findByRole('link'))).toBe('#7008E7')
299
304
  hashless.unmount()
300
305
 
301
306
  renderWithProviders(
302
307
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
303
308
  )
304
- expect((await screen.findByRole('link')).style.backgroundColor).toBe(
305
- derived
306
- )
309
+ expect(accentOf(await screen.findByRole('link'))).toBe('#7008E7')
307
310
  })
308
311
 
309
312
  it('lets a loading status outrank the audio hero on all three cards', () => {
@@ -349,13 +352,15 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
349
352
  groupPosition="end"
350
353
  />
351
354
  )
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.
355
+ // The tail paints outside the shell's own box, so it can't inherit the
356
+ // shell's computed background — it publishes the same `--accent` and derives
357
+ // the surface itself in CSS, or the tail and the card would disagree.
355
358
  const card = await screen.findByRole('link')
356
- expect(screen.getByTestId('link-card-tail')).toHaveStyle({
357
- backgroundColor: card.style.backgroundColor,
358
- })
359
+ const tail = screen.getByTestId('link-card-tail')
360
+ expect(tail.style.getPropertyValue('--accent')).toBe(
361
+ card.style.getPropertyValue('--accent')
362
+ )
363
+ expect(tail.style.getPropertyValue('--accent')).toBe(ACCENT)
359
364
  unmount()
360
365
 
361
366
  // Mid-run the following bubble owns the tail.
@@ -370,10 +375,13 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
370
375
  })
371
376
 
372
377
  it('falls back to the variant fill for the tail when there is no accent', async () => {
378
+ // No accent published, so no `--accent`: the tail's `[data-variant='dark']`
379
+ // rule in styles.css paints the sender's plain `#1e2330` bubble fill (the
380
+ // computed value isn't observable in jsdom — see the note on `accentOf`).
373
381
  renderWithProviders(<LinkAttachment.Sent {...CARD_PROPS} />)
374
- expect(await screen.findByTestId('link-card-tail')).toHaveStyle({
375
- backgroundColor: '#1e2330',
376
- })
382
+ const tail = await screen.findByTestId('link-card-tail')
383
+ expect(tail.style.getPropertyValue('--accent')).toBe('')
384
+ expect(tail).toHaveAttribute('data-variant', 'dark')
377
385
  })
378
386
 
379
387
  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`). `undefined` falls the
8
+ * tail back to this side's plain bubble fill. The tail paints outside the
9
+ * shell's box, so it can't inherit the shell's computed background — it runs
10
+ * the derivation itself off its own `--accent` (MES-1412).
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 (MES-1388).
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 in CSS (`oklch(from …)`) and
40
+ * paints it over the `bg-[…]` fallback where the browser supports it —
41
+ * MES-1412. `undefined` 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,18 +89,21 @@ 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 where the browser
105
+ // resolves `oklch(from …)`, and an accent that can't (or was never set)
106
+ // lands on this sane surface.
103
107
  //
104
108
  // MUST be an arbitrary value, not a theme token. This package ships no
105
109
  // Tailwind utilities — `dist/assets/index.css` is only the hand-written
@@ -115,7 +119,7 @@ const CardShell: React.FC<CardShellProps> = ({
115
119
  // usable accent matches the text bubbles around it: `#1e2330` is
116
120
  // `--str-chat__own-message-bubble-background-color` and `#f1f0ee` is
117
121
  // `--str-chat__message-bubble-background-color`. Mirrored in
118
- // `chinContrast.BUBBLE_BG_BY_VARIANT`, which the bubble tail reads.
122
+ // and mirrored by the bubble tail's own fills in `styles.css`.
119
123
  bgClassName ?? (variant === 'dark' ? 'bg-[#1e2330]' : 'bg-[#f1f0ee]'),
120
124
  // `focus-ring` is a design-system utility from the component-library
121
125
  // tailwind preset — outline-none + a black 2px focus-visible ring
@@ -127,6 +131,12 @@ const CardShell: React.FC<CardShellProps> = ({
127
131
  ? { role: 'status', 'aria-busy': true, 'aria-label': LOADING_LABEL }
128
132
  : undefined
129
133
 
134
+ // Published as a custom property, not a `background`: the `[style*='--accent']`
135
+ // rule in `styles.css` keys off its presence and derives the surface in CSS.
136
+ const accentStyle = accentHex
137
+ ? ({ '--accent': accentHex } as React.CSSProperties)
138
+ : undefined
139
+
130
140
  const corner = topRight ? (
131
141
  <div className="pointer-events-auto absolute right-3 top-3 z-10">
132
142
  {topRight}
@@ -142,6 +152,7 @@ const CardShell: React.FC<CardShellProps> = ({
142
152
  rel="noopener noreferrer"
143
153
  onClick={onClick}
144
154
  data-testid={dataTestId}
155
+ data-variant={variant}
145
156
  className={className}
146
157
  style={accentStyle}
147
158
  {...busyProps}
@@ -160,6 +171,7 @@ const CardShell: React.FC<CardShellProps> = ({
160
171
  onClick={onClick}
161
172
  aria-label={ariaLabel}
162
173
  data-testid={dataTestId}
174
+ data-variant={variant}
163
175
  className={classNames(className, 'text-left')}
164
176
  style={accentStyle}
165
177
  {...busyProps}
@@ -174,6 +186,7 @@ const CardShell: React.FC<CardShellProps> = ({
174
186
  <div
175
187
  ref={rootRef as React.Ref<HTMLDivElement>}
176
188
  data-testid={dataTestId}
189
+ data-variant={variant}
177
190
  className={className}
178
191
  style={accentStyle}
179
192
  {...busyProps}
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The hex contract shared by `chinContrast` and `accentSurface`.
2
+ * The hex contract for the accent colour `useChinPalette` publishes.
3
3
  *
4
- * `culori` does the parsing, and it accepts every CSS colour — including names
5
- * like `rebeccapurple` and formats with alpha. Every colour reaching these two
6
- * modules comes from `getDominantColorFromImageUrl` via a Stream attachment
7
- * field, or from a constant in this directory, so both narrow the input to a hex
8
- * before handing it over rather than widening their contract by accident.
4
+ * The extracted `accent_color` (from `getDominantColorFromImageUrl` via a Stream
5
+ * attachment field) is always a hex, but CSS `oklch(from …)` would resolve any
6
+ * colour name including `rebeccapurple` so the input is narrowed to a hex
7
+ * before it is published as `--accent`, rather than widening that contract by
8
+ * accident.
9
9
  */
10
10
 
11
11
  /**
@@ -19,7 +19,7 @@
19
19
  */
20
20
  export const HEX_PATTERN = /^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
21
21
 
22
- /** Normalizes a hex for CSS and for culori, adding the `#` the extractor omits. */
22
+ /** Normalizes a hex for CSS, adding the `#` the extractor omits. */
23
23
  export function toCssHex(hex: string): string {
24
24
  const trimmed = hex.trim()
25
25
  return trimmed.startsWith('#') ? trimmed : `#${trimmed}`