@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
@@ -35,12 +35,11 @@ export interface CardShellProps {
35
35
  */
36
36
  bgClassName?: string
37
37
  /**
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.
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.
42
41
  */
43
- accentHex?: string
42
+ accentStyle?: React.CSSProperties
44
43
  /**
45
44
  * Locks the card to Figma's 250px box and makes it a flex column, so the
46
45
  * hero absorbs whatever height the chin leaves. Only the `featured` layout
@@ -89,21 +88,18 @@ const CardShell: React.FC<CardShellProps> = ({
89
88
  rootRef,
90
89
  topRight,
91
90
  bgClassName,
92
- accentHex,
91
+ accentStyle,
93
92
  fixedHeight = false,
94
93
  busy = false,
95
94
  'data-testid': dataTestId,
96
95
  }) => {
97
96
  const isInteractive = href != null || onClick != null
98
97
  const className = classNames(
99
- 'messaging-link-card',
100
98
  SHELL_CLASS,
101
99
  fixedHeight ? FIXED_HEIGHT_CLASS : 'block',
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.
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.
107
103
  //
108
104
  // MUST be an arbitrary value, not a theme token. This package ships no
109
105
  // Tailwind utilities — `dist/assets/index.css` is only the hand-written
@@ -119,7 +115,7 @@ const CardShell: React.FC<CardShellProps> = ({
119
115
  // usable accent matches the text bubbles around it: `#1e2330` is
120
116
  // `--str-chat__own-message-bubble-background-color` and `#f1f0ee` is
121
117
  // `--str-chat__message-bubble-background-color`. Mirrored in
122
- // and mirrored by the bubble tail's own fills in `styles.css`.
118
+ // `chinContrast.BUBBLE_BG_BY_VARIANT`, which the bubble tail reads.
123
119
  bgClassName ?? (variant === 'dark' ? 'bg-[#1e2330]' : 'bg-[#f1f0ee]'),
124
120
  // `focus-ring` is a design-system utility from the component-library
125
121
  // tailwind preset — outline-none + a black 2px focus-visible ring
@@ -131,12 +127,6 @@ const CardShell: React.FC<CardShellProps> = ({
131
127
  ? { role: 'status', 'aria-busy': true, 'aria-label': LOADING_LABEL }
132
128
  : undefined
133
129
 
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
-
140
130
  const corner = topRight ? (
141
131
  <div className="pointer-events-auto absolute right-3 top-3 z-10">
142
132
  {topRight}
@@ -152,7 +142,6 @@ const CardShell: React.FC<CardShellProps> = ({
152
142
  rel="noopener noreferrer"
153
143
  onClick={onClick}
154
144
  data-testid={dataTestId}
155
- data-variant={variant}
156
145
  className={className}
157
146
  style={accentStyle}
158
147
  {...busyProps}
@@ -171,7 +160,6 @@ const CardShell: React.FC<CardShellProps> = ({
171
160
  onClick={onClick}
172
161
  aria-label={ariaLabel}
173
162
  data-testid={dataTestId}
174
- data-variant={variant}
175
163
  className={classNames(className, 'text-left')}
176
164
  style={accentStyle}
177
165
  {...busyProps}
@@ -186,7 +174,6 @@ const CardShell: React.FC<CardShellProps> = ({
186
174
  <div
187
175
  ref={rootRef as React.Ref<HTMLDivElement>}
188
176
  data-testid={dataTestId}
189
- data-variant={variant}
190
177
  className={className}
191
178
  style={accentStyle}
192
179
  {...busyProps}
@@ -0,0 +1,135 @@
1
+ import { converter } from 'culori'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { accentSurface } from './accentSurface'
5
+ import {
6
+ contrastRatio,
7
+ inkFor,
8
+ INK_ON_DARK,
9
+ INK_ON_LIGHT,
10
+ WCAG_AA_NORMAL_TEXT,
11
+ } from './chinContrast'
12
+
13
+ const toOklch = converter('oklch')
14
+
15
+ /** Hue is only meaningful above a little chroma, so tests gate on this. */
16
+ function chromaOf(hex: string): number {
17
+ return toOklch(hex)?.c ?? 0
18
+ }
19
+
20
+ function hueOf(hex: string): number {
21
+ return toOklch(hex)?.h ?? 0
22
+ }
23
+
24
+ /** Hues far enough apart to be visibly different colours. */
25
+ const SAMPLES = [
26
+ '#7008E7', // violet — chroma well outside sRGB at both stops
27
+ '#2FCB5F', // green
28
+ '#FFF099', // pale yellow
29
+ '#00B6FF', // cyan
30
+ '#738883', // the Figma mock's desaturated green
31
+ '#FCF5E3', // the near-white from the MES-1388 report
32
+ '#1e2330', // already as dark as the sender fallback
33
+ '#767676', // fully neutral, zero chroma
34
+ ]
35
+
36
+ describe('accentSurface', () => {
37
+ it('always produces a surface its own ink can clear AA on', () => {
38
+ // The whole point of deriving rather than admitting: no extracted colour is
39
+ // ever rejected for contrast, and none is ever illegible.
40
+ for (const sample of SAMPLES) {
41
+ for (const [variant, ink] of [
42
+ ['dark', INK_ON_DARK],
43
+ ['light', INK_ON_LIGHT],
44
+ ] as const) {
45
+ const surface = accentSurface(sample, variant)
46
+ expect(surface, `${sample} on ${variant}`).toBeDefined()
47
+ expect(
48
+ contrastRatio(surface as string, ink),
49
+ `${sample} on ${variant}`
50
+ ).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TEXT)
51
+ // And the ink the card will actually pick agrees with that side.
52
+ expect(inkFor(surface as string)).toBe(ink)
53
+ }
54
+ }
55
+ })
56
+
57
+ it('keeps the image hue so different links stay distinguishable', () => {
58
+ for (const sample of SAMPLES) {
59
+ // Hue is only meaningful above a little chroma: `#FCF5E3` and `#767676`
60
+ // are near-neutral, where the angle is numerically unstable and invisible.
61
+ if (chromaOf(sample) < 0.03) continue
62
+ const expected = hueOf(sample)
63
+ for (const variant of ['dark', 'light'] as const) {
64
+ const surface = accentSurface(sample, variant) as string
65
+ const drift = Math.abs(hueOf(surface) - expected)
66
+ // Tight on purpose. `clampChroma` holds hue while it desaturates, so
67
+ // this stays under 2° — the same fidelity as the hand-rolled OKLCH pass
68
+ // it replaced. Swapping to culori's `toGamut` would drift up to 12° and
69
+ // trip this, which is exactly the regression it is here to catch.
70
+ expect(
71
+ Math.min(drift, 360 - drift),
72
+ `${sample} ${variant}`
73
+ ).toBeLessThan(2)
74
+ }
75
+ }
76
+ })
77
+
78
+ it('separates the two sides, so each is legible under its own ink', () => {
79
+ // Sent lands dark, received pale — the same link, the same hue, opposite
80
+ // ends of the lightness axis because the inks are opposites.
81
+ for (const sample of SAMPLES) {
82
+ const sent = accentSurface(sample, 'dark') as string
83
+ const received = accentSurface(sample, 'light') as string
84
+ expect(contrastRatio(sent, INK_ON_DARK)).toBeGreaterThan(
85
+ contrastRatio(sent, INK_ON_LIGHT)
86
+ )
87
+ expect(contrastRatio(received, INK_ON_LIGHT)).toBeGreaterThan(
88
+ contrastRatio(received, INK_ON_DARK)
89
+ )
90
+ }
91
+ })
92
+
93
+ it('holds AA at every hue, at chroma beyond what sRGB can show', () => {
94
+ // Sweeps the failure mode that a fixed lightness stop could plausibly have:
95
+ // a hue whose maximum chroma pushes the derived colour out of gamut.
96
+ for (let hue = 0; hue < 360; hue += 15) {
97
+ const vivid = `#${[0, 120, 240]
98
+ .map((offset) =>
99
+ Math.round(127 + 127 * Math.cos(((hue + offset) * Math.PI) / 180))
100
+ .toString(16)
101
+ .padStart(2, '0')
102
+ )
103
+ .join('')}`
104
+ for (const [variant, ink] of [
105
+ ['dark', INK_ON_DARK],
106
+ ['light', INK_ON_LIGHT],
107
+ ] as const) {
108
+ const surface = accentSurface(vivid, variant)
109
+ expect(
110
+ contrastRatio(surface as string, ink),
111
+ `hue ${hue} on ${variant}`
112
+ ).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TEXT)
113
+ }
114
+ }
115
+ })
116
+
117
+ it('accepts the shapes the extractor and CSS disagree about', () => {
118
+ // `accent_color` arrives from the ugc-kit query as a bare hex often enough
119
+ // that a missing `#` must not silently drop the colour.
120
+ expect(accentSurface('7008E7', 'dark')).toBe(
121
+ accentSurface('#7008E7', 'dark')
122
+ )
123
+ expect(accentSurface(' #7008E7 ', 'dark')).toBe(
124
+ accentSurface('#7008E7', 'dark')
125
+ )
126
+ expect(accentSurface('#70E', 'dark')).toBeDefined()
127
+ })
128
+
129
+ it('has no surface when there was no colour to derive from', () => {
130
+ expect(accentSurface(undefined, 'dark')).toBeUndefined()
131
+ expect(accentSurface('', 'dark')).toBeUndefined()
132
+ expect(accentSurface('rebeccapurple', 'dark')).toBeUndefined()
133
+ expect(accentSurface('#ff', 'light')).toBeUndefined()
134
+ })
135
+ })
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Turns the colour extracted from an attachment image into a card surface that
3
+ * the card's own text can survive.
4
+ *
5
+ * `ugc-kit`'s `ImageAnalysis` (`getDominantColorFromImageUrl`) answers only
6
+ * "what colour is this image?" — it has no notion of contrast or readability.
7
+ * Painting that colour straight onto the card is what broke: the extracted
8
+ * colour is whatever the photo happened to be, so roughly half of them left the
9
+ * title illegible against the side's ink, and refusing those threw away the
10
+ * colour the feature exists to add (MES-1349, MES-1388).
11
+ *
12
+ * So the hue is kept and the *lightness* is replaced with a fixed stop chosen
13
+ * per side — dark behind the sender's white ink, pale behind the receiver's
14
+ * near-black. This is the approach already used in `linktr.ee-profiles`
15
+ * (`ui/src/utils/rgbToTheme.ts`), whose `getPaletteFromColor` generates a
16
+ * 16-stop OKLCH ramp from one colour and has callers pick a background stop and
17
+ * a text stop from it. We need exactly one stop per side rather than a whole
18
+ * theme, so the ramp is collapsed to a single lightness target.
19
+ *
20
+ * OKLCH rather than HSL because its lightness axis is perceptually uniform: one
21
+ * L value gives the same *apparent* lightness — and therefore the same contrast
22
+ * — across every hue. In HSL, `50%` lightness yellow and `50%` blue differ by
23
+ * several stops of contrast, so no single target would hold.
24
+ *
25
+ * The conversion, the sRGB gamut mapping and the WCAG ratio are all `culori`'s.
26
+ * An earlier revision of this module hand-rolled them (~90 lines of OKLab
27
+ * matrices and a bisecting chroma search); `culori` is zero-dependency and ships
28
+ * a real CJS build, so it is safe for both frontyard's Jest and profiles' build.
29
+ */
30
+ import { clampChroma, converter, formatHex, wcagContrast } from 'culori'
31
+
32
+ import type { LinkAttachmentVariant } from './CardShell'
33
+ import { INK_ON_DARK, INK_ON_LIGHT, WCAG_AA_NORMAL_TEXT } from './chinContrast'
34
+ import { HEX_PATTERN, toCssHex } from './hex'
35
+
36
+ /**
37
+ * Perceived-lightness stop each side paints at, in OKLCH L (0..1) — profiles'
38
+ * `color12` / `color2` equivalent. Both sit far enough from their ink to clear
39
+ * AA at any hue and chroma: swept across every hue at maximum chroma the worst
40
+ * case is comfortably above the 4.5:1 floor (see `accentSurface.test.ts`).
41
+ */
42
+ const SURFACE_LIGHTNESS: Record<LinkAttachmentVariant, number> = {
43
+ dark: 0.3,
44
+ light: 0.9,
45
+ }
46
+
47
+ /** Ink each side draws, so the derived surface can be checked against it. */
48
+ const INK_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
49
+ dark: INK_ON_DARK,
50
+ light: INK_ON_LIGHT,
51
+ }
52
+
53
+ const toOklch = converter('oklch')
54
+
55
+ /**
56
+ * The card surface for an extracted colour: the image's hue at this side's
57
+ * lightness stop, or `undefined` when there was no colour to work from.
58
+ *
59
+ * The input is validated as a hex rather than handed to `culori` directly.
60
+ * `culori` accepts every CSS colour, including names like `rebeccapurple`, and
61
+ * `accent_color` is always a hex from `getDominantColorFromImageUrl` — widening
62
+ * that contract by accident would let a caller pass something no other part of
63
+ * this module can reason about. The bare-hex case *is* supported, because the
64
+ * extractor omits the `#` often enough that dropping it would silently lose the
65
+ * colour (MES-1349).
66
+ *
67
+ * The AA check at the end is a guard, not a branch anyone expects to take: the
68
+ * lightness stops were chosen to clear it everywhere. It exists so that a future
69
+ * change to `SURFACE_LIGHTNESS` or to the ink cannot quietly reintroduce an
70
+ * illegible card — it degrades to the plain bubble fill instead.
71
+ */
72
+ export function accentSurface(
73
+ accentColor: string | undefined,
74
+ variant: LinkAttachmentVariant
75
+ ): string | undefined {
76
+ if (accentColor == null || !HEX_PATTERN.test(accentColor.trim())) {
77
+ return undefined
78
+ }
79
+
80
+ const base = toOklch(toCssHex(accentColor))
81
+ if (!base) return undefined
82
+
83
+ // `clampChroma`, not `toGamut`: both bring an out-of-sRGB colour back into
84
+ // range, but `toGamut` follows the CSS Color 4 path and drifts hue by up to
85
+ // 12° on saturated bases, while `clampChroma` reduces chroma at constant hue
86
+ // and lightness — 2.8° worst case. Vivid hues desaturate rather than shift,
87
+ // which is what keeps the card recognisably the image's colour.
88
+ const surface = formatHex(
89
+ clampChroma({ ...base, l: SURFACE_LIGHTNESS[variant] }, 'oklch')
90
+ )
91
+
92
+ return wcagContrast(surface, INK_BY_VARIANT[variant]) >= WCAG_AA_NORMAL_TEXT
93
+ ? surface
94
+ : undefined
95
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { AUDIO_BG, AUDIO_BG_CLASS } from './CardThumbnail'
4
+ import {
5
+ BUBBLE_BG_BY_VARIANT,
6
+ contrastRatio,
7
+ inkFor,
8
+ INK_ON_DARK,
9
+ INK_ON_LIGHT,
10
+ WCAG_AA_NORMAL_TEXT,
11
+ } from './chinContrast'
12
+
13
+ describe('contrastRatio', () => {
14
+ // The arithmetic is culori's. What this module owns is the input contract:
15
+ // which strings are colours, and what happens to the ones that are not.
16
+ it('matches the WCAG maximum for black on white', () => {
17
+ expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 2)
18
+ })
19
+
20
+ it('is symmetric and self-referential', () => {
21
+ expect(contrastRatio('#1e2330', '#ffffff')).toBeCloseTo(
22
+ contrastRatio('#ffffff', '#1e2330'),
23
+ 5
24
+ )
25
+ expect(contrastRatio('#1e2330', '#1e2330')).toBeCloseTo(1, 5)
26
+ })
27
+
28
+ it('accepts short hex and the missing hash the extractor omits', () => {
29
+ const canonical = contrastRatio('#ffffff', '#1e2330')
30
+ for (const white of ['#fff', 'ffffff', ' #fff ']) {
31
+ expect(contrastRatio(white, '#1e2330')).toBeCloseTo(canonical, 5)
32
+ }
33
+ })
34
+
35
+ it('is NaN for anything that is not a hex, including CSS colour names', () => {
36
+ // culori would happily resolve `rebeccapurple`; this module deliberately
37
+ // does not, because every colour reaching it comes from a hex extractor.
38
+ for (const input of ['', '#ff', '#gggggg', 'rebeccapurple', '#ffffff00']) {
39
+ expect(contrastRatio(input, '#ffffff')).toBeNaN()
40
+ expect(contrastRatio('#ffffff', input)).toBeNaN()
41
+ }
42
+ })
43
+ })
44
+
45
+ describe('the fallback fills stay legible', () => {
46
+ it('clears AA on both sides, so a card with no accent is always readable', () => {
47
+ for (const variant of ['dark', 'light'] as const) {
48
+ const fill = BUBBLE_BG_BY_VARIANT[variant]
49
+ const ink = inkFor(fill)
50
+ expect(ink).toBeDefined()
51
+ expect(contrastRatio(fill, ink as string)).toBeGreaterThanOrEqual(
52
+ WCAG_AA_NORMAL_TEXT
53
+ )
54
+ }
55
+ })
56
+
57
+ it('uses the plain message bubble fills', () => {
58
+ // Keep in sync with `--str-chat__own-message-bubble-background-color` and
59
+ // `--str-chat__message-bubble-background-color` in styles.css.
60
+ expect(BUBBLE_BG_BY_VARIANT).toEqual({
61
+ dark: '#1e2330',
62
+ light: '#f1f0ee',
63
+ })
64
+ })
65
+ })
66
+
67
+ describe('inkFor', () => {
68
+ it('inks a dark surface light and a light surface dark', () => {
69
+ expect(inkFor('#1e2330')).toBe(INK_ON_DARK)
70
+ expect(inkFor('#f1f0ee')).toBe(INK_ON_LIGHT)
71
+ })
72
+
73
+ it('inks the audio strip dark, which is the MES-1388 regression', () => {
74
+ // `AUDIO_BG_CLASS` is imposed on the shell of any variant, so a sent audio
75
+ // card drew the dark variant's white ink on near-white at ~1.13:1.
76
+ expect(contrastRatio(AUDIO_BG, INK_ON_DARK)).toBeLessThan(
77
+ WCAG_AA_NORMAL_TEXT
78
+ )
79
+ const ink = inkFor(AUDIO_BG)
80
+ expect(ink).toBe(INK_ON_LIGHT)
81
+ expect(contrastRatio(AUDIO_BG, ink as string)).toBeGreaterThanOrEqual(
82
+ WCAG_AA_NORMAL_TEXT
83
+ )
84
+ })
85
+
86
+ it('keeps the audio hex and its tailwind class in step', () => {
87
+ // Tailwind only emits utilities it can find as complete literals, so the
88
+ // class cannot be built from the constant — hence the pair.
89
+ expect(AUDIO_BG_CLASS).toBe(`bg-[${AUDIO_BG}]`)
90
+ })
91
+
92
+ it('has no ink for an unparseable surface', () => {
93
+ expect(inkFor('rebeccapurple')).toBeUndefined()
94
+ })
95
+ })
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Ink and fallback fills for the `LinkAttachment` card, whose background is a
3
+ * colour derived from the attachment image (see `accentSurface`).
4
+ *
5
+ * The card's text is chosen by measuring contrast against whatever surface the
6
+ * card actually paints. Ink used to be fixed per variant, which left any
7
+ * background the card imposed on *itself* unchecked: `AUDIO_BG_CLASS`
8
+ * (`#F2F3F4`) is applied on either variant, so a sent audio card drew white ink
9
+ * on near-white at 1.13:1 and the title was invisible (MES-1388).
10
+ *
11
+ * The ratio itself is `culori`'s `wcagContrast`. A previous revision computed
12
+ * relative luminance here by hand; the only thing this module adds now is the
13
+ * decision of *which* ink, and the hex validation that keeps the surface a hex
14
+ * rather than any CSS colour.
15
+ */
16
+ import { wcagContrast } from 'culori'
17
+
18
+ import type { LinkAttachmentVariant } from './CardShell'
19
+ import { HEX_PATTERN, toCssHex } from './hex'
20
+
21
+ /** WCAG 2.1 SC 1.4.3 (AA), normal text. Both chin lines are normal text. */
22
+ export const WCAG_AA_NORMAL_TEXT = 4.5
23
+
24
+ /**
25
+ * Fill each side falls back to when no accent is paintable: the plain message
26
+ * bubble's own background, so a link card without a usable colour is indistinct
27
+ * from the text bubbles around it rather than a third colour nobody chose.
28
+ *
29
+ * These are the literal values behind `--str-chat__own-message-bubble-background-color`
30
+ * and `--str-chat__message-bubble-background-color` in `styles.css`. Duplicated
31
+ * as hex because the bubble tail paints *outside* the shell's box via an inline
32
+ * style, and because the contrast arithmetic needs a colour, not a `var()`.
33
+ * Keep in sync with those two custom properties.
34
+ */
35
+ export const BUBBLE_BG_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
36
+ dark: '#1e2330',
37
+ light: '#f1f0ee',
38
+ }
39
+
40
+ /** Ink candidates for a surface the card cannot refuse. */
41
+ export const INK_ON_DARK = '#FFFFFF'
42
+ /** The near-black used across the Figma frames. */
43
+ export const INK_ON_LIGHT = '#040403'
44
+
45
+ /**
46
+ * WCAG contrast ratio between two hex colours, or `NaN` if either is not a hex.
47
+ *
48
+ * The hex guard is deliberate: `culori` resolves any CSS colour, and every
49
+ * colour reaching this module comes from an image extractor or from the two
50
+ * constants above. `NaN` rather than a throw so callers can branch on
51
+ * `Number.isNaN` and fall back.
52
+ */
53
+ export function contrastRatio(a: string, b: string): number {
54
+ if (typeof a !== 'string' || typeof b !== 'string') return Number.NaN
55
+ if (!HEX_PATTERN.test(a.trim()) || !HEX_PATTERN.test(b.trim())) {
56
+ return Number.NaN
57
+ }
58
+
59
+ return wcagContrast(toCssHex(a), toCssHex(b))
60
+ }
61
+
62
+ /**
63
+ * Ink for a surface the card did not choose — the audio strip's flat neutral, or
64
+ * a variant fallback fill — picking whichever candidate scores the higher ratio
65
+ * against it. `undefined` when the surface is not a hex, so callers keep the
66
+ * variant's own `text-*` classes.
67
+ *
68
+ * This is the rule the accent derivation cannot cover. A derived accent suits
69
+ * its side's ink by construction; an imposed background has to be met the other
70
+ * way round, by moving the ink. Only decoration can be refused (MES-1388).
71
+ */
72
+ export function inkFor(background: string): string | undefined {
73
+ const againstDarkInk = contrastRatio(background, INK_ON_LIGHT)
74
+ if (Number.isNaN(againstDarkInk)) return undefined
75
+
76
+ return againstDarkInk >= contrastRatio(background, INK_ON_DARK)
77
+ ? INK_ON_LIGHT
78
+ : INK_ON_DARK
79
+ }
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The hex contract for the accent colour `useChinPalette` publishes.
2
+ * The hex contract shared by `chinContrast` and `accentSurface`.
3
3
  *
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.
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.
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, adding the `#` the extractor omits. */
22
+ /** Normalizes a hex for CSS and for culori, 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}`
@@ -5,19 +5,10 @@ import React from 'react'
5
5
 
6
6
  import type { LinkAttachmentLayout, LinkAttachmentStatus } from '../../types'
7
7
 
8
+ import { accentSurface } from './accentSurface'
8
9
  import type { LinkAttachmentVariant } from './CardShell'
9
- import { isPlayableAudio } from './CardThumbnail'
10
- import { HEX_PATTERN, toCssHex } from './hex'
11
-
12
- /** White on the dark stop, near-black on the light stop and the audio strip. */
13
- const INK_ON_DARK = '#FFFFFF'
14
- /** The near-black used across the Figma frames. */
15
- const INK_ON_LIGHT = '#040403'
16
-
17
- const INK_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
18
- dark: INK_ON_DARK,
19
- light: INK_ON_LIGHT,
20
- }
10
+ import { AUDIO_BG, isPlayableAudio } from './CardThumbnail'
11
+ import { BUBBLE_BG_BY_VARIANT, inkFor } from './chinContrast'
21
12
 
22
13
  export interface ChinPaletteInput {
23
14
  variant: LinkAttachmentVariant
@@ -40,17 +31,21 @@ export interface ChinPalette {
40
31
  */
41
32
  thumbnailUrl?: string
42
33
  /**
43
- * Raw extracted colour to publish as the `--accent` custom property on the
44
- * shell and the tail, or `undefined` when there is no paintable accent. The
45
- * hue-preserving lightness swap is done in CSS (`styles.css`) — see MES-1412.
34
+ * Inline background for the card shell, or `undefined` to keep the variant's
35
+ * own `bg-*` class.
36
+ */
37
+ accentStyle?: React.CSSProperties
38
+ /**
39
+ * Ink for both chin lines, chosen against the surface the card actually
40
+ * paints — the derived accent, the audio strip, or the fallback fill. Always
41
+ * defined for a parseable surface.
46
42
  */
47
- accentHex?: string
43
+ chinTextColor?: string
48
44
  /**
49
- * Ink for both chin lines: white on the sender's dark stop, near-black on the
50
- * receiver's pale stop and on the audio strip. Fixed per surface, so no
51
- * runtime contrast measurement is needed (the stops clear AA by construction).
45
+ * Colour the bubble tail paints in, always defined: the accent when one is
46
+ * paintable, otherwise this side's plain bubble fill.
52
47
  */
53
- chinTextColor: string
48
+ tailColor: string
54
49
  /** True while the card renders as a skeleton. */
55
50
  isLoading: boolean
56
51
  /** Hand to `CardThumbnail` so a broken hero drops the accent with it. */
@@ -119,27 +114,23 @@ export function useChinPalette({
119
114
  const isAudio = isPlayableAudio(mimeType, sourceUrl)
120
115
  const hasHero =
121
116
  layout === 'featured' && !!thumbnailUrl && !isAudio && !heroFailed
122
- const canDerive = hasHero && status === 'ready'
123
- // Only a validated hex is published: `accent_color` is always a hex from the
124
- // extractor, and CSS would resolve any colour name — narrowing keeps the input
125
- // contract the same one the old `accentSurface` enforced. The bare-hex case is
126
- // supported because the extractor omits the `#` often enough that dropping it
127
- // would silently lose the colour (MES-1349).
128
- const accentHex =
129
- canDerive && accentColor != null && HEX_PATTERN.test(accentColor.trim())
130
- ? toCssHex(accentColor)
117
+ const surface =
118
+ hasHero && status === 'ready'
119
+ ? accentSurface(accentColor, variant)
131
120
  : undefined
132
121
 
133
- // The accent surface and the sender's dark fallback both take white ink; the
134
- // pale receiver fallback and the imposed audio strip both take near-black. So
135
- // the ink is fixed per surface and needs no runtime contrast — a sent audio
136
- // card put white ink on the strip's near-white at 1.13:1 before (MES-1388).
137
- const chinTextColor = isAudio ? INK_ON_LIGHT : INK_BY_VARIANT[variant]
122
+ // Whatever the card ends up painted with, legible or not, the ink is chosen
123
+ // against it. The accent is derived to suit this side's ink, but the audio
124
+ // strip and the fallback fill are imposed and cannot be refused — a sent
125
+ // audio card put white ink on `AUDIO_BG`'s near-white at 1.13:1 (MES-1388).
126
+ const painted =
127
+ surface ?? (isAudio ? AUDIO_BG : BUBBLE_BG_BY_VARIANT[variant])
138
128
 
139
129
  return {
140
130
  thumbnailUrl: heroFailed ? undefined : thumbnailUrl,
141
- accentHex,
142
- chinTextColor,
131
+ accentStyle: surface ? { backgroundColor: surface } : undefined,
132
+ chinTextColor: inkFor(painted),
133
+ tailColor: painted,
143
134
  isLoading: status === 'loading',
144
135
  onImageError,
145
136
  }
@@ -77,17 +77,16 @@ export interface LinkAttachmentBaseProps {
77
77
  /**
78
78
  * Dominant colour extracted from `thumbnailUrl`, as a hex string
79
79
  * (`#RRGGBB` or `#RGB`). Paints the card and — when the card is the last
80
- * bubble in a same-author run — its tail. Its hue is kept while the lightness
81
- * is swapped to a fixed per-side stop (`oklch(from …)` in `styles.css`); the
82
- * stops clear WCAG AA at every hue by construction, so callers never pass a
80
+ * bubble in a same-author run — its tail. The card derives its own chin
81
+ * text colour from this value for WCAG AA contrast; callers never pass a
83
82
  * text colour.
84
83
  *
85
84
  * Only painted while the hero image it was extracted from is actually on
86
85
  * screen. Omitted, unparseable, `layout: 'classic'`, no `thumbnailUrl`,
87
86
  * `status` other than `'ready'`, a playable-audio `mimeType` + `sourceUrl`
88
- * (whose hero is the native player rather than an image), or a hero that fails
89
- * to load => the card keeps the plain bubble fill. Browsers without
90
- * relative-colour-syntax support also keep that fill (progressive enhancement).
87
+ * (whose hero is the native player rather than an image), or a hero that
88
+ * fails to load, or the colour cannot clear AA against this side's ink =>
89
+ * the card falls back to `BUBBLE_BG_BY_VARIANT[variant]`, the plain bubble fill.
91
90
  */
92
91
  accentColor?: string
93
92