@linktr.ee/messaging-react 3.31.1-rc-1785925535 → 3.31.1

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 (35) hide show
  1. package/dist/{Card-Ch6N2pkc.js → Card-CmlaSw7i.js} +2 -2
  2. package/dist/{Card-Ch6N2pkc.js.map → Card-CmlaSw7i.js.map} +1 -1
  3. package/dist/{Card-CF1TZXD5.cjs → Card-EWN07XIl.cjs} +2 -2
  4. package/dist/{Card-CF1TZXD5.cjs.map → Card-EWN07XIl.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/index-CN3errpB.js +7828 -0
  7. package/dist/index-CN3errpB.js.map +1 -0
  8. package/dist/index-CcitzVoW.cjs +5 -0
  9. package/dist/index-CcitzVoW.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +10 -30
  12. package/dist/index.js +1 -1
  13. package/dist/testing.d.ts +5 -24
  14. package/package.json +6 -4
  15. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +27 -44
  16. package/src/components/LinkAttachment/LinkAttachment.test.tsx +39 -47
  17. package/src/components/LinkAttachment/components/Composer/Card.tsx +1 -1
  18. package/src/components/LinkAttachment/components/Received/Card.tsx +2 -2
  19. package/src/components/LinkAttachment/components/Sent/Card.tsx +2 -2
  20. package/src/components/LinkAttachment/components/_shared/BubbleTail.tsx +6 -16
  21. package/src/components/LinkAttachment/components/_shared/CardBody.tsx +5 -4
  22. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +9 -22
  23. package/src/components/LinkAttachment/components/_shared/accentSurface.test.ts +135 -0
  24. package/src/components/LinkAttachment/components/_shared/accentSurface.ts +95 -0
  25. package/src/components/LinkAttachment/components/_shared/chinContrast.test.ts +95 -0
  26. package/src/components/LinkAttachment/components/_shared/chinContrast.ts +79 -0
  27. package/src/components/LinkAttachment/components/_shared/hex.ts +7 -7
  28. package/src/components/LinkAttachment/components/_shared/useChinPalette.ts +27 -36
  29. package/src/components/LinkAttachment/types.ts +5 -6
  30. package/src/stream-custom-data.ts +7 -24
  31. package/src/styles.css +0 -39
  32. package/dist/index-BKkCNnea.js +0 -5677
  33. package/dist/index-BKkCNnea.js.map +0 -1
  34. package/dist/index-Ct_Jgy8P.cjs +0 -2
  35. package/dist/index-Ct_Jgy8P.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-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;
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;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.d.ts CHANGED
@@ -696,17 +696,16 @@ 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. 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
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
702
701
  * text colour.
703
702
  *
704
703
  * Only painted while the hero image it was extracted from is actually on
705
704
  * screen. Omitted, unparseable, `layout: 'classic'`, no `thumbnailUrl`,
706
705
  * `status` other than `'ready'`, a playable-audio `mimeType` + `sourceUrl`
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).
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.
710
709
  */
711
710
  accentColor?: string;
712
711
  /**
@@ -1787,32 +1786,13 @@ declare module 'stream-chat' {
1787
1786
  */
1788
1787
  accent_color?: string;
1789
1788
  }
1790
- interface CustomChannelData {
1789
+ interface CustomChannelData extends LinktreeChannelData {
1791
1790
  /**
1792
- * Whether the channel has at least one visitor-originated message.
1793
- * Legacy channels may omit this field.
1794
- */
1795
- has_visitor_message?: boolean;
1796
- /**
1797
- * Priority score assigned by backend classification for spotlighting.
1798
- */
1799
- priority_score?: number;
1800
- /**
1801
- * Indicates a paid message is pending creator reply.
1802
- */
1803
- pending_earn_message_reply?: boolean;
1804
- /**
1805
- * Indicates DM agent has deferred and creator follow-up is needed.
1806
- */
1807
- agent_deferred?: boolean;
1808
- /**
1809
- * Whether DM agent auto-replies are paused for this channel.
1791
+ * Whether DM agent auto-replies are paused for this channel. Writer-only
1792
+ * field not read by other clients, so it stays local rather than in the
1793
+ * shared {@link LinktreeChannelData} contract.
1810
1794
  */
1811
1795
  chatbot_paused?: boolean;
1812
- /**
1813
- * Customer classification tag for this conversation (e.g. CUSTOMER_PAID).
1814
- */
1815
- customer_tag?: string;
1816
1796
  }
1817
1797
  interface CustomMemberData {
1818
1798
  /**
@@ -1820,7 +1800,7 @@ declare module 'stream-chat' {
1820
1800
  */
1821
1801
  is_follower?: boolean;
1822
1802
  }
1823
- interface CustomMessageData {
1803
+ interface CustomMessageData extends LinktreeMessageData {
1824
1804
  /**
1825
1805
  * Message metadata from backend.
1826
1806
  * Contains type and payment information.
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-BKkCNnea.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-CN3errpB.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/dist/testing.d.ts CHANGED
@@ -124,32 +124,13 @@ declare module 'stream-chat' {
124
124
  */
125
125
  accent_color?: string;
126
126
  }
127
- interface CustomChannelData {
127
+ interface CustomChannelData extends LinktreeChannelData {
128
128
  /**
129
- * Whether the channel has at least one visitor-originated message.
130
- * Legacy channels may omit this field.
131
- */
132
- has_visitor_message?: boolean;
133
- /**
134
- * Priority score assigned by backend classification for spotlighting.
135
- */
136
- priority_score?: number;
137
- /**
138
- * Indicates a paid message is pending creator reply.
139
- */
140
- pending_earn_message_reply?: boolean;
141
- /**
142
- * Indicates DM agent has deferred and creator follow-up is needed.
143
- */
144
- agent_deferred?: boolean;
145
- /**
146
- * Whether DM agent auto-replies are paused for this channel.
129
+ * Whether DM agent auto-replies are paused for this channel. Writer-only
130
+ * field not read by other clients, so it stays local rather than in the
131
+ * shared {@link LinktreeChannelData} contract.
147
132
  */
148
133
  chatbot_paused?: boolean;
149
- /**
150
- * Customer classification tag for this conversation (e.g. CUSTOMER_PAID).
151
- */
152
- customer_tag?: string;
153
134
  }
154
135
  interface CustomMemberData {
155
136
  /**
@@ -157,7 +138,7 @@ declare module 'stream-chat' {
157
138
  */
158
139
  is_follower?: boolean;
159
140
  }
160
- interface CustomMessageData {
141
+ interface CustomMessageData extends LinktreeMessageData {
161
142
  /**
162
143
  * Message metadata from backend.
163
144
  * Contains type and payment information.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.31.1-rc-1785925535",
3
+ "version": "3.31.1",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -50,9 +50,10 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@linktr.ee/component-library": "11.8.6",
53
- "@linktr.ee/messaging-core": "2.4.0-rc-1785925535",
54
- "@linktr.ee/messaging-taxonomy": "^0.5.0",
55
- "@phosphor-icons/react": "^2.1.10"
53
+ "@linktr.ee/messaging-core": "^2.2.0",
54
+ "@linktr.ee/messaging-taxonomy": "^0.5.1",
55
+ "@phosphor-icons/react": "^2.1.10",
56
+ "culori": "^4.0.2"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@storybook/addon-essentials": "^8.5.0",
@@ -66,6 +67,7 @@
66
67
  "@testing-library/jest-dom": "^6.1.5",
67
68
  "@testing-library/react": "^14.1.2",
68
69
  "@testing-library/user-event": "^14.5.1",
70
+ "@types/culori": "^4",
69
71
  "@types/react": "^18.3.24",
70
72
  "@types/react-dom": "^18.3.7",
71
73
  "@vitejs/plugin-react": "^4.2.1",
@@ -7,8 +7,9 @@ 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'
10
11
  import type { LinkAttachmentVariant } from './components/_shared/CardShell'
11
- import { HEX_PATTERN, toCssHex } from './components/_shared/hex'
12
+ import { BUBBLE_BG_BY_VARIANT, inkFor } from './components/_shared/chinContrast'
12
13
 
13
14
  import LinkAttachment from '.'
14
15
 
@@ -387,9 +388,9 @@ const AUDIO_PREVIEW = {
387
388
  * player. The artwork is still handed over as `thumbnailUrl` and there is
388
389
  * still no `<img>`, so `onImageError` can never clear the accent — the gate
389
390
  * has to be up front. These three cells are the regression that prompted the
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.
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.
393
394
  * - **No `thumbnailUrl`** leaves the type-icon placeholder, which is not an
394
395
  * image the accent could have come from. Stream reaches this whenever a link
395
396
  * preview carries a playable clip but no `image_url`.
@@ -461,8 +462,8 @@ AccentWithoutVisibleHero.parameters = { chromatic: { disableSnapshot: false } }
461
462
 
462
463
  /**
463
464
  * Max-chroma sRGB colour for a hue — three phase-shifted cosines, so stepping
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.
465
+ * the hue walks the wheel at the most saturated triple each hue has. Mirrors
466
+ * the generator `accentSurface.test.ts` sweeps.
466
467
  */
467
468
  const vividHex = (hue: number): string =>
468
469
  `#${[0, 120, 240]
@@ -478,7 +479,7 @@ const HUE_SWEEP = Array.from({ length: 24 }, (_, i) => ({
478
479
  accentColor: vividHex(i * 15),
479
480
  }))
480
481
 
481
- // Neutrals and near-limits where the ink and the fallback actually decide
482
+ // Neutrals and near-limits where the AA gate and the ink flip actually decide
482
483
  // something, plus the two inputs that must never derive a surface.
483
484
  const EDGE_SAMPLES: Array<{ label: string; accentColor?: string }> = [
484
485
  { label: 'mock #738883', accentColor: '#738883' },
@@ -491,25 +492,13 @@ const EDGE_SAMPLES: Array<{ label: string; accentColor?: string }> = [
491
492
  { label: 'malformed', accentColor: 'rebeccapurple' },
492
493
  ]
493
494
 
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
-
506
495
  /**
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.
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.
513
502
  */
514
503
  const DerivedSwatch = ({
515
504
  accentColor,
@@ -518,23 +507,17 @@ const DerivedSwatch = ({
518
507
  accentColor?: string
519
508
  variant: LinkAttachmentVariant
520
509
  }) => {
521
- const accentHex =
522
- accentColor != null && HEX_PATTERN.test(accentColor.trim())
523
- ? toCssHex(accentColor)
524
- : undefined
510
+ const surface = accentSurface(accentColor, variant)
511
+ const painted = surface ?? BUBBLE_BG_BY_VARIANT[variant]
525
512
  return (
526
513
  <div
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
- }
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) }}
535
516
  >
536
517
  <span>Yoga on Ice</span>
537
- <span className="font-mono opacity-80">{accentHex ?? 'fallback'}</span>
518
+ <span className="font-mono opacity-80">
519
+ {surface ?? `fallback ${painted}`}
520
+ </span>
538
521
  </div>
539
522
  )
540
523
  }
@@ -544,12 +527,12 @@ const GridHead = ({ children }: { children: React.ReactNode }) => (
544
527
  )
545
528
 
546
529
  /**
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.
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.
553
536
  */
554
537
  export const AccentDerivation: StoryFn = () => (
555
538
  <div className="min-h-screen w-full bg-[#F9F7F4] p-8">
@@ -119,33 +119,28 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
119
119
  thumbnailUrl: 'https://cdn.example.com/yoga.jpg',
120
120
  }
121
121
 
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 () => {
122
+ const surfaceOf = (element: HTMLElement) => element.style.backgroundColor
123
+
124
+ it('derives a dark surface for the sender and inks it white', async () => {
131
125
  renderWithProviders(
132
126
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
133
127
  )
134
128
  const root = await screen.findByRole('link')
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('')
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 })
139
134
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
140
135
  })
141
136
 
142
- it('publishes the accent for the receiver and inks the chin near-black', async () => {
137
+ it('derives a pale surface for the receiver and inks it near-black', async () => {
143
138
  renderWithProviders(
144
139
  <LinkAttachment.Received {...CARD_PROPS} accentColor={ACCENT} />
145
140
  )
146
141
  const root = await screen.findByRole('link')
147
- expect(accentOf(root)).toBe(ACCENT)
148
- expect(root.style.backgroundColor).toBe('')
142
+ expect(surfaceOf(root)).not.toBe('')
143
+ expect(root).not.toHaveStyle({ backgroundColor: ACCENT })
149
144
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
150
145
  })
151
146
 
@@ -156,13 +151,13 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
156
151
  const sent = renderWithProviders(
157
152
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={accentColor} />
158
153
  )
159
- expect(accentOf(await screen.findByRole('link'))).toBe(accentColor)
154
+ expect(surfaceOf(await screen.findByRole('link'))).not.toBe('')
160
155
  sent.unmount()
161
156
 
162
157
  const received = renderWithProviders(
163
158
  <LinkAttachment.Received {...CARD_PROPS} accentColor={accentColor} />
164
159
  )
165
- expect(accentOf(await screen.findByRole('link'))).toBe(accentColor)
160
+ expect(surfaceOf(await screen.findByRole('link'))).not.toBe('')
166
161
  received.unmount()
167
162
  }
168
163
  })
@@ -170,7 +165,7 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
170
165
  it('falls back to a self-contained fill, not a consumer theme token', async () => {
171
166
  renderWithProviders(<LinkAttachment.Sent {...CARD_PROPS} />)
172
167
  const root = await screen.findByRole('link')
173
- expect(accentOf(root)).toBe('')
168
+ expect(surfaceOf(root)).toBe('')
174
169
 
175
170
  // The fill has to survive in consumers that do not share our Tailwind
176
171
  // theme. This package ships no utilities, so consumers generate our classes
@@ -191,8 +186,8 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
191
186
  <LinkAttachment.Sent {...CARD_PROPS} accentColor="rebeccapurple" />
192
187
  )
193
188
  const root = await screen.findByRole('link')
194
- expect(accentOf(root)).toBe('')
195
- // Ink still comes from the side — here the sender's fallback fill.
189
+ expect(root.style.backgroundColor).toBe('')
190
+ // Ink still comes from the surface — here the sender's fallback fill.
196
191
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
197
192
  })
198
193
 
@@ -201,12 +196,12 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
201
196
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
202
197
  )
203
198
  const root = await screen.findByRole('link')
204
- expect(accentOf(root)).toBe(ACCENT)
199
+ expect(root.style.backgroundColor).not.toBe('')
205
200
 
206
201
  // A surface derived from an image the viewer cannot see is a lie, so the
207
202
  // card falls all the way back rather than keeping a colour with no source.
208
203
  fireEvent.error(screen.getByRole('img'))
209
- expect(accentOf(root)).toBe('')
204
+ expect(root.style.backgroundColor).toBe('')
210
205
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
211
206
  })
212
207
 
@@ -227,12 +222,12 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
227
222
  const shell = container.querySelector(
228
223
  '[class*="select-none"]'
229
224
  ) as HTMLElement
230
- expect(shell.style.getPropertyValue('--accent')).toBe(ACCENT)
225
+ expect(shell.style.backgroundColor).not.toBe('')
231
226
  expect(container.querySelector('video')).toHaveAttribute('poster')
232
227
 
233
228
  fireEvent.error(container.querySelector('img') as HTMLImageElement)
234
229
 
235
- expect(shell.style.getPropertyValue('--accent')).toBe('')
230
+ expect(shell.style.backgroundColor).toBe('')
236
231
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
237
232
  // The failed poster is dropped with the accent, so the player falls back to
238
233
  // its own first frame instead of retrying a 404.
@@ -257,7 +252,7 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
257
252
  <LinkAttachment.Sent {...CARD_PROPS} {...AUDIO} accentColor={ACCENT} />
258
253
  )
259
254
  const sent = container.firstElementChild as HTMLElement
260
- expect(sent.style.getPropertyValue('--accent')).toBe('')
255
+ expect(sent.style.backgroundColor).toBe('')
261
256
  expect(sent.className).toContain(AUDIO_BG_CLASS)
262
257
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
263
258
  expect(screen.queryByRole('img')).toBeNull()
@@ -271,7 +266,7 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
271
266
  />
272
267
  )
273
268
  const received = await screen.findByTestId('link-attachment')
274
- expect(received.style.getPropertyValue('--accent')).toBe('')
269
+ expect(received.style.backgroundColor).toBe('')
275
270
  expect(received.className).toContain(AUDIO_BG_CLASS)
276
271
  })
277
272
 
@@ -286,27 +281,29 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
286
281
  />
287
282
  )
288
283
  const root = await screen.findByRole('link')
289
- expect(accentOf(root)).toBe('')
284
+ expect(root.style.backgroundColor).toBe('')
290
285
  // Variant fill, asserted by shape not literal — see the self-contained-fill
291
286
  // test above for why this must stay an arbitrary value (MES-1388).
292
287
  expect(root.className).toMatch(/bg-\[/)
293
288
  expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
294
289
  })
295
290
 
296
- it('normalizes a hashless accent so the derivation actually resolves', async () => {
291
+ it('normalizes a hashless accent so the background actually paints', async () => {
297
292
  // The extractor returns a bare hex often enough that dropping it would lose
298
- // the colour silently — `oklch(from 7008E7 …)` never resolves (MES-1349), so
299
- // the published `--accent` must carry the `#`.
293
+ // the colour silently — `background-color: 7008E7` is invalid CSS (MES-1349).
300
294
  const hashless = renderWithProviders(
301
295
  <LinkAttachment.Sent {...CARD_PROPS} accentColor="7008E7" />
302
296
  )
303
- expect(accentOf(await screen.findByRole('link'))).toBe('#7008E7')
297
+ const derived = (await screen.findByRole('link')).style.backgroundColor
298
+ expect(derived).not.toBe('')
304
299
  hashless.unmount()
305
300
 
306
301
  renderWithProviders(
307
302
  <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
308
303
  )
309
- expect(accentOf(await screen.findByRole('link'))).toBe('#7008E7')
304
+ expect((await screen.findByRole('link')).style.backgroundColor).toBe(
305
+ derived
306
+ )
310
307
  })
311
308
 
312
309
  it('lets a loading status outrank the audio hero on all three cards', () => {
@@ -352,15 +349,13 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
352
349
  groupPosition="end"
353
350
  />
354
351
  )
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.
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.
358
355
  const card = await screen.findByRole('link')
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)
356
+ expect(screen.getByTestId('link-card-tail')).toHaveStyle({
357
+ backgroundColor: card.style.backgroundColor,
358
+ })
364
359
  unmount()
365
360
 
366
361
  // Mid-run the following bubble owns the tail.
@@ -375,13 +370,10 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
375
370
  })
376
371
 
377
372
  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`).
381
373
  renderWithProviders(<LinkAttachment.Sent {...CARD_PROPS} />)
382
- const tail = await screen.findByTestId('link-card-tail')
383
- expect(tail.style.getPropertyValue('--accent')).toBe('')
384
- expect(tail).toHaveAttribute('data-variant', 'dark')
374
+ expect(await screen.findByTestId('link-card-tail')).toHaveStyle({
375
+ backgroundColor: '#1e2330',
376
+ })
385
377
  })
386
378
 
387
379
  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
- accentHex={chin.accentHex}
133
+ accentStyle={chin.accentStyle}
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
- accentHex={chin.accentHex}
111
+ color={chin.tailColor}
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
- accentHex={chin.accentHex}
126
+ accentStyle={chin.accentStyle}
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
- accentHex={chin.accentHex}
95
+ color={chin.tailColor}
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
- accentHex={chin.accentHex}
108
+ accentStyle={chin.accentStyle}
109
109
  busy={chin.isLoading}
110
110
  bgClassName={isAudio ? AUDIO_BG_CLASS : undefined}
111
111
  >
@@ -3,13 +3,11 @@ import React from 'react'
3
3
 
4
4
  export interface BubbleTailProps {
5
5
  /**
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).
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.
11
9
  */
12
- accentHex?: string
10
+ color: string
13
11
  /** Sender tails hang off the right edge, receiver tails mirror to the left. */
14
12
  side: 'sender' | 'receiver'
15
13
  /**
@@ -32,20 +30,13 @@ export interface BubbleTailProps {
32
30
  * tinted from the text-bubble surface would clash with a coloured card.
33
31
  */
34
32
  const BubbleTail: React.FC<BubbleTailProps> = ({
35
- accentHex,
33
+ color,
36
34
  side,
37
35
  visible,
38
36
  children,
39
37
  }) => {
40
38
  if (!visible) return <>{children}</>
41
39
 
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
-
49
40
  return (
50
41
  // `isolate` keeps the tail's negative z-index from escaping behind the
51
42
  // thread background; `w-fit` stops the wrapper from stretching in the
@@ -55,8 +46,7 @@ const BubbleTail: React.FC<BubbleTailProps> = ({
55
46
  <span
56
47
  aria-hidden
57
48
  data-testid="link-card-tail"
58
- data-variant={variant}
59
- style={accentStyle}
49
+ style={{ backgroundColor: color }}
60
50
  className={classNames(
61
51
  'messaging-link-card-tail',
62
52
  side === 'receiver' && 'messaging-link-card-tail--received'
@@ -15,10 +15,11 @@ 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 `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).
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).
22
23
  */
23
24
  chinTextColor?: string
24
25
  /**