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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/{Card-BI3bA2Cj.js → Card-DcyixIfP.js} +2 -2
  2. package/dist/{Card-BI3bA2Cj.js.map → Card-DcyixIfP.js.map} +1 -1
  3. package/dist/{Card-iXdD3NF2.cjs → Card-_fDH4x4V.cjs} +2 -2
  4. package/dist/{Card-iXdD3NF2.cjs.map → Card-_fDH4x4V.cjs.map} +1 -1
  5. package/dist/{index-BlHbSVwK.js → index-DSQieSNh.js} +881 -798
  6. package/dist/index-DSQieSNh.js.map +1 -0
  7. package/dist/index-quV01XX7.cjs +2 -0
  8. package/dist/index-quV01XX7.cjs.map +1 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +1 -1
  12. package/package.json +2 -2
  13. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +17 -9
  14. package/src/components/LinkAttachment/LinkAttachment.test.tsx +87 -53
  15. package/src/components/LinkAttachment/components/Composer/Card.tsx +1 -1
  16. package/src/components/LinkAttachment/components/Received/Card.tsx +1 -1
  17. package/src/components/LinkAttachment/components/Sent/Card.tsx +1 -1
  18. package/src/components/LinkAttachment/components/_shared/CardBody.tsx +11 -10
  19. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +7 -1
  20. package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +9 -0
  21. package/src/components/LinkAttachment/components/_shared/accentSurface.test.ts +151 -0
  22. package/src/components/LinkAttachment/components/_shared/accentSurface.ts +211 -0
  23. package/src/components/LinkAttachment/components/_shared/chinContrast.test.ts +64 -124
  24. package/src/components/LinkAttachment/components/_shared/chinContrast.ts +68 -71
  25. package/src/components/LinkAttachment/components/_shared/useChinPalette.ts +35 -31
  26. package/src/components/LinkAttachment/types.ts +2 -2
  27. package/dist/index-BlHbSVwK.js.map +0 -1
  28. package/dist/index-F3RRurYR.cjs +0 -2
  29. package/dist/index-F3RRurYR.cjs.map +0 -1
@@ -0,0 +1,211 @@
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 maths is ported from that same profiles module rather than
26
+ * invented here; the toolkit has no colour dependency and adding one for two
27
+ * matrix multiplies is not worth the install.
28
+ */
29
+ import type { LinkAttachmentVariant } from './CardShell'
30
+ import {
31
+ contrastRatio,
32
+ INK_ON_DARK,
33
+ INK_ON_LIGHT,
34
+ WCAG_AA_NORMAL_TEXT,
35
+ } from './chinContrast'
36
+
37
+ /**
38
+ * Perceived-lightness stop each side paints at, in OKLCH L (0..1) — profiles'
39
+ * `color12` / `color2` equivalent. Both sit far enough from their ink to clear
40
+ * AA at any hue and chroma: swept across 72 hues at maximum chroma the worst
41
+ * case is 13.17:1 sent and 14.72:1 received, against a 4.5:1 floor.
42
+ */
43
+ const SURFACE_LIGHTNESS: Record<LinkAttachmentVariant, number> = {
44
+ dark: 0.3,
45
+ light: 0.9,
46
+ }
47
+
48
+ /** Ink each side draws, so the derived surface can be checked against it. */
49
+ const INK_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
50
+ dark: INK_ON_DARK,
51
+ light: INK_ON_LIGHT,
52
+ }
53
+
54
+ const SHORT_HEX_LENGTH = 3
55
+ const HEX_PATTERN = /^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
56
+
57
+ /** Channels are allowed a half-step outside 0..255 before rounding clips them. */
58
+ const GAMUT_EPSILON = 0.5
59
+ /** Bisection steps for the chroma search — 25 resolves well past 8-bit output. */
60
+ const GAMUT_STEPS = 25
61
+
62
+ type Rgb = [number, number, number]
63
+
64
+ function parseHex(hex: string): Rgb | undefined {
65
+ if (typeof hex !== 'string' || !HEX_PATTERN.test(hex.trim())) return undefined
66
+
67
+ const digits = hex.trim().replace(/^#/, '')
68
+ const expanded =
69
+ digits.length === SHORT_HEX_LENGTH
70
+ ? digits.replace(/./g, (digit) => digit + digit)
71
+ : digits
72
+
73
+ return [
74
+ Number.parseInt(expanded.slice(0, 2), 16),
75
+ Number.parseInt(expanded.slice(2, 4), 16),
76
+ Number.parseInt(expanded.slice(4, 6), 16),
77
+ ]
78
+ }
79
+
80
+ function toHex(rgb: Rgb): string {
81
+ return `#${rgb
82
+ .map((channel) =>
83
+ Math.round(Math.min(255, Math.max(0, channel)))
84
+ .toString(16)
85
+ .padStart(2, '0')
86
+ )
87
+ .join('')}`
88
+ }
89
+
90
+ /** sRGB transfer function and its inverse (gamma <-> linear light). */
91
+ function gammaToLinear(channel: number): number {
92
+ return channel <= 0.04045
93
+ ? channel / 12.92
94
+ : ((channel + 0.055) / 1.055) ** 2.4
95
+ }
96
+
97
+ function linearToGamma(channel: number): number {
98
+ return channel <= 0.0031308
99
+ ? 12.92 * channel
100
+ : 1.055 * channel ** (1 / 2.4) - 0.055
101
+ }
102
+
103
+ /** sRGB -> OKLCH, as `[lightness, chroma, hue degrees]`. */
104
+ function rgbToOklch(rgb: Rgb): [number, number, number] {
105
+ const red = gammaToLinear(rgb[0] / 255)
106
+ const green = gammaToLinear(rgb[1] / 255)
107
+ const blue = gammaToLinear(rgb[2] / 255)
108
+
109
+ const long = Math.cbrt(
110
+ 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue
111
+ )
112
+ const medium = Math.cbrt(
113
+ 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue
114
+ )
115
+ const short = Math.cbrt(
116
+ 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue
117
+ )
118
+
119
+ const lightness =
120
+ 0.2104542553 * long + 0.793617785 * medium - 0.0040720468 * short
121
+ const a = 1.9779984951 * long - 2.428592205 * medium + 0.4505937099 * short
122
+ const b = 0.0259040371 * long + 0.7827717662 * medium - 0.808675766 * short
123
+
124
+ return [
125
+ lightness,
126
+ Math.hypot(a, b),
127
+ ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360,
128
+ ]
129
+ }
130
+
131
+ /** OKLCH -> sRGB. Channels may land outside 0..255 when out of gamut. */
132
+ function oklchToRgb([lightness, chroma, hue]: [number, number, number]): Rgb {
133
+ const radians = (hue * Math.PI) / 180
134
+ const a = chroma * Math.cos(radians)
135
+ const b = chroma * Math.sin(radians)
136
+
137
+ const long = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3
138
+ const medium = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3
139
+ const short = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3
140
+
141
+ return [
142
+ 255 *
143
+ linearToGamma(
144
+ 4.0767416621 * long - 3.3077115913 * medium + 0.2309699292 * short
145
+ ),
146
+ 255 *
147
+ linearToGamma(
148
+ -1.2684380046 * long + 2.6097574011 * medium - 0.3413193965 * short
149
+ ),
150
+ 255 *
151
+ linearToGamma(
152
+ -0.0041960863 * long - 0.7034186147 * medium + 1.707614701 * short
153
+ ),
154
+ ]
155
+ }
156
+
157
+ function isInGamut(rgb: Rgb): boolean {
158
+ return rgb.every(
159
+ (channel) => channel >= -GAMUT_EPSILON && channel <= 255 + GAMUT_EPSILON
160
+ )
161
+ }
162
+
163
+ /**
164
+ * The card surface for an extracted colour: the image's hue at this side's
165
+ * lightness stop, or `undefined` when there was no colour to work from.
166
+ *
167
+ * Chroma is reduced — never the hue — when the requested lightness puts the
168
+ * colour outside sRGB, which is what stops a saturated yellow or green from
169
+ * clipping into a different colour on the way to 8-bit output. Vivid hues
170
+ * therefore desaturate rather than shift.
171
+ *
172
+ * The AA check at the end is a guard, not a branch anyone expects to take: the
173
+ * lightness stops were chosen to clear it everywhere. It exists so that a future
174
+ * change to `SURFACE_LIGHTNESS` or to the ink cannot quietly reintroduce an
175
+ * illegible card — it degrades to the plain bubble fill instead.
176
+ */
177
+ export function accentSurface(
178
+ accentColor: string | undefined,
179
+ variant: LinkAttachmentVariant
180
+ ): string | undefined {
181
+ if (accentColor == null) return undefined
182
+
183
+ const rgb = parseHex(accentColor)
184
+ if (!rgb) return undefined
185
+
186
+ const [, extractedChroma, hue] = rgbToOklch(rgb)
187
+ const lightness = SURFACE_LIGHTNESS[variant]
188
+
189
+ let chroma = 0
190
+ if (isInGamut(oklchToRgb([lightness, extractedChroma, hue]))) {
191
+ chroma = extractedChroma
192
+ } else {
193
+ // Largest chroma that still fits sRGB at this lightness and hue.
194
+ let low = 0
195
+ let high = extractedChroma
196
+ for (let step = 0; step < GAMUT_STEPS; step++) {
197
+ const middle = (low + high) / 2
198
+ if (isInGamut(oklchToRgb([lightness, middle, hue]))) {
199
+ chroma = middle
200
+ low = middle
201
+ } else {
202
+ high = middle
203
+ }
204
+ }
205
+ }
206
+
207
+ const surface = toHex(oklchToRgb([lightness, chroma, hue]))
208
+ return contrastRatio(surface, INK_BY_VARIANT[variant]) >= WCAG_AA_NORMAL_TEXT
209
+ ? surface
210
+ : undefined
211
+ }
@@ -1,162 +1,102 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
+ import { AUDIO_BG, AUDIO_BG_CLASS } from './CardThumbnail'
3
4
  import {
4
- CHIN_FALLBACK_BG,
5
- CHIN_TEXT_DARK,
6
- CHIN_TEXT_LIGHT,
7
- WCAG_AA_NORMAL_TEXT,
8
- chinTextColor,
5
+ BUBBLE_BG_BY_VARIANT,
9
6
  contrastRatio,
10
- paintableAccent,
7
+ inkFor,
8
+ INK_ON_DARK,
9
+ INK_ON_LIGHT,
11
10
  relativeLuminance,
11
+ WCAG_AA_NORMAL_TEXT,
12
12
  } from './chinContrast'
13
13
 
14
14
  describe('relativeLuminance', () => {
15
- it('anchors the sRGB endpoints', () => {
16
- expect(relativeLuminance('#FFFFFF')).toBeCloseTo(1, 10)
17
- expect(relativeLuminance('#000000')).toBeCloseTo(0, 10)
18
- })
19
-
20
- it('applies gamma expansion rather than a linear average', () => {
21
- // Mid-grey is ~21.6% luminance, not 50% — a Rec. 601 style weighted
22
- // average (the trap the app-local helpers fall into) would say ~0.5.
23
- expect(relativeLuminance('#808080')).toBeCloseTo(0.2159, 4)
24
- })
25
-
26
- it('weights green above red above blue', () => {
27
- expect(relativeLuminance('#00FF00')).toBeCloseTo(0.7152, 4)
28
- expect(relativeLuminance('#FF0000')).toBeCloseTo(0.2126, 4)
29
- expect(relativeLuminance('#0000FF')).toBeCloseTo(0.0722, 4)
15
+ it('anchors on the sRGB endpoints', () => {
16
+ expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 5)
17
+ expect(relativeLuminance('#000000')).toBeCloseTo(0, 5)
30
18
  })
31
19
 
32
- it('accepts shorthand hex and a missing leading hash', () => {
33
- expect(relativeLuminance('#fff')).toBe(relativeLuminance('#FFFFFF'))
34
- expect(relativeLuminance('738883')).toBe(relativeLuminance('#738883'))
20
+ it('accepts short hex and a missing leading hash', () => {
21
+ expect(relativeLuminance('#fff')).toBeCloseTo(1, 5)
22
+ expect(relativeLuminance('ffffff')).toBeCloseTo(1, 5)
23
+ expect(relativeLuminance(' #fff ')).toBeCloseTo(1, 5)
35
24
  })
36
25
 
37
- it('returns NaN for unparseable input', () => {
38
- for (const bad of ['', '#', '#12', '#12345', '#gggggg', 'rebeccapurple']) {
39
- expect(relativeLuminance(bad)).toBeNaN()
26
+ it('reports NaN for anything unparseable', () => {
27
+ for (const input of ['', '#ff', '#gggggg', 'rebeccapurple', '#ffffff00']) {
28
+ expect(relativeLuminance(input)).toBeNaN()
40
29
  }
41
30
  })
42
31
  })
43
32
 
44
33
  describe('contrastRatio', () => {
45
- it('is 21:1 for black on white and 1:1 for a colour on itself', () => {
46
- expect(contrastRatio('#000000', '#FFFFFF')).toBeCloseTo(21, 10)
47
- expect(contrastRatio('#738883', '#738883')).toBeCloseTo(1, 10)
34
+ it('matches the WCAG maximum for black on white', () => {
35
+ expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 2)
48
36
  })
49
37
 
50
- it('is symmetric in its arguments', () => {
51
- expect(contrastRatio('#738883', CHIN_TEXT_LIGHT)).toBe(
52
- contrastRatio(CHIN_TEXT_LIGHT, '#738883')
38
+ it('is symmetric and self-referential', () => {
39
+ expect(contrastRatio('#1e2330', '#ffffff')).toBeCloseTo(
40
+ contrastRatio('#ffffff', '#1e2330'),
41
+ 5
53
42
  )
43
+ expect(contrastRatio('#1e2330', '#1e2330')).toBeCloseTo(1, 5)
54
44
  })
55
45
 
56
- it('returns NaN when either input is unparseable', () => {
57
- expect(contrastRatio('nope', '#FFFFFF')).toBeNaN()
58
- expect(contrastRatio('#FFFFFF', 'nope')).toBeNaN()
46
+ it('propagates NaN when either side is unparseable', () => {
47
+ expect(contrastRatio('nope', '#ffffff')).toBeNaN()
48
+ expect(contrastRatio('#ffffff', 'nope')).toBeNaN()
59
49
  })
60
50
  })
61
51
 
62
- describe('chinTextColor', () => {
63
- it('picks dark text on light accents', () => {
64
- // #738883 is the Figma mock's own chin colour, where white measures
65
- // 3.76:1 and therefore fails AA — the rule deliberately flips it.
66
- expect(chinTextColor('#738883')).toBe(CHIN_TEXT_DARK)
67
- expect(chinTextColor('#AA9F93')).toBe(CHIN_TEXT_DARK)
68
- expect(chinTextColor('#FFF099')).toBe(CHIN_TEXT_DARK)
69
- expect(chinTextColor('#FFFFFF')).toBe(CHIN_TEXT_DARK)
70
- })
71
-
72
- it('picks light text on dark accents', () => {
73
- expect(chinTextColor(CHIN_FALLBACK_BG)).toBe(CHIN_TEXT_LIGHT)
74
- expect(chinTextColor('#7008E7')).toBe(CHIN_TEXT_LIGHT)
75
- expect(chinTextColor('#000000')).toBe(CHIN_TEXT_LIGHT)
76
- })
77
-
78
- it('falls back to light text for missing or malformed input', () => {
79
- expect(chinTextColor()).toBe(CHIN_TEXT_LIGHT)
80
- expect(chinTextColor(undefined)).toBe(CHIN_TEXT_LIGHT)
81
- expect(chinTextColor('')).toBe(CHIN_TEXT_LIGHT)
82
- expect(chinTextColor('#12345')).toBe(CHIN_TEXT_LIGHT)
83
- expect(chinTextColor('teal')).toBe(CHIN_TEXT_LIGHT)
84
- })
85
-
86
- it('clears AA for every accent, including the greys either side of the crossover', () => {
87
- // The invariant, not the individual verdicts: whichever colour the rule
88
- // returns must clear 4.5:1. `#767676` is the tightest point on the grey
89
- // axis (4.54 vs 4.52) and #737373 / #797979 straddle it.
90
- const accents = [
91
- '#738883',
92
- '#AA9F93',
93
- CHIN_FALLBACK_BG,
94
- '#767676',
95
- '#737373',
96
- '#797979',
97
- '#FFFFFF',
98
- '#000000',
99
- '#FF0000',
100
- '#00FF00',
101
- '#0000FF',
102
- '#FFF099',
103
- '#7008E7',
104
- '#F2F1EF',
105
- '#121110',
106
- ]
107
-
108
- for (const accent of accents) {
109
- expect(
110
- contrastRatio(accent, chinTextColor(accent))
111
- ).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TEXT)
52
+ describe('the fallback fills stay legible', () => {
53
+ it('clears AA on both sides, so a card with no accent is always readable', () => {
54
+ for (const variant of ['dark', 'light'] as const) {
55
+ const fill = BUBBLE_BG_BY_VARIANT[variant]
56
+ const ink = inkFor(fill)
57
+ expect(ink).toBeDefined()
58
+ expect(contrastRatio(fill, ink as string)).toBeGreaterThanOrEqual(
59
+ WCAG_AA_NORMAL_TEXT
60
+ )
112
61
  }
113
62
  })
114
63
 
115
- it('clears AA across the whole gamut at 17-step granularity', () => {
116
- const steps = [0, 17, 51, 85, 119, 136, 170, 204, 238, 255]
117
- const hex = (value: number) => value.toString(16).padStart(2, '0')
118
-
119
- for (const r of steps) {
120
- for (const g of steps) {
121
- for (const b of steps) {
122
- const accent = `#${hex(r)}${hex(g)}${hex(b)}`
123
- expect(
124
- contrastRatio(accent, chinTextColor(accent))
125
- ).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TEXT)
126
- }
127
- }
128
- }
64
+ it('uses the plain message bubble fills', () => {
65
+ // Keep in sync with `--str-chat__own-message-bubble-background-color` and
66
+ // `--str-chat__message-bubble-background-color` in styles.css.
67
+ expect(BUBBLE_BG_BY_VARIANT).toEqual({
68
+ dark: '#1e2330',
69
+ light: '#f1f0ee',
70
+ })
129
71
  })
72
+ })
130
73
 
131
- it('pairs the fallback chin with light text at 15.69:1', () => {
132
- expect(contrastRatio(CHIN_FALLBACK_BG, chinTextColor())).toBeCloseTo(
133
- 15.69,
134
- 2
135
- )
74
+ describe('inkFor', () => {
75
+ it('inks a dark surface light and a light surface dark', () => {
76
+ expect(inkFor('#1e2330')).toBe(INK_ON_DARK)
77
+ expect(inkFor('#f1f0ee')).toBe(INK_ON_LIGHT)
136
78
  })
137
- })
138
79
 
139
- describe('paintableAccent', () => {
140
- it('passes through anything parseable', () => {
141
- expect(paintableAccent('#738883')).toBe('#738883')
142
- expect(paintableAccent('#fff')).toBe('#fff')
80
+ it('inks the audio strip dark, which is the MES-1388 regression', () => {
81
+ // `AUDIO_BG_CLASS` is imposed on the shell of any variant, so a sent audio
82
+ // card drew the dark variant's white ink on near-white at ~1.13:1.
83
+ expect(contrastRatio(AUDIO_BG, INK_ON_DARK)).toBeLessThan(
84
+ WCAG_AA_NORMAL_TEXT
85
+ )
86
+ const ink = inkFor(AUDIO_BG)
87
+ expect(ink).toBe(INK_ON_LIGHT)
88
+ expect(contrastRatio(AUDIO_BG, ink as string)).toBeGreaterThanOrEqual(
89
+ WCAG_AA_NORMAL_TEXT
90
+ )
143
91
  })
144
92
 
145
- it('adds the leading hash that CSS requires when the input omits it', () => {
146
- // `parseHex` accepts a hashless value, `background-color: 738883` is
147
- // invalid CSS and gets dropped, and `chinTextColor` resolves against the
148
- // parsed colour regardless — so the two would disagree about what the
149
- // chin is sitting on. Normalizing on the way out keeps them in step.
150
- expect(paintableAccent('738883')).toBe('#738883')
151
- expect(paintableAccent('fff')).toBe('#fff')
152
- expect(paintableAccent(' 738883 ')).toBe('#738883')
153
- expect(paintableAccent(' #738883 ')).toBe('#738883')
93
+ it('keeps the audio hex and its tailwind class in step', () => {
94
+ // Tailwind only emits utilities it can find as complete literals, so the
95
+ // class cannot be built from the constant — hence the pair.
96
+ expect(AUDIO_BG_CLASS).toBe(`bg-[${AUDIO_BG}]`)
154
97
  })
155
98
 
156
- it('drops absent and malformed accents so callers fall back', () => {
157
- expect(paintableAccent()).toBeUndefined()
158
- expect(paintableAccent('')).toBeUndefined()
159
- expect(paintableAccent('#12345')).toBeUndefined()
160
- expect(paintableAccent('rgb(1,2,3)')).toBeUndefined()
99
+ it('has no ink for an unparseable surface', () => {
100
+ expect(inkFor('rebeccapurple')).toBeUndefined()
161
101
  })
162
102
  })
@@ -1,31 +1,47 @@
1
1
  /**
2
- * WCAG contrast arithmetic for the `LinkAttachment` card "chin" — the bottom
3
- * bar holding the title + URL, painted with a dominant colour extracted from
4
- * the attachment image.
2
+ * WCAG contrast arithmetic for the `LinkAttachment` card, whose background is a
3
+ * dominant colour extracted from the attachment image
4
+ * (`getDominantColorFromImageUrl`, via `ugc-kit`'s `ImageAnalysis`).
5
5
  *
6
- * Nothing here touches React or the DOM: given an accent hex it answers which
7
- * of the two design-system text colours to draw on it. Deliberately dependency
8
- * free — neither `@linktr.ee/design-tokens` nor the `@linktr.ee/component-library`
6
+ * The colour exists to make links visually distinguishable and to put something
7
+ * other than grey/blue in the message window. It is decoration, so it is only
8
+ * allowed to paint when the card's text stays legible on it: each side draws a
9
+ * *fixed* ink colour, and an accent that cannot clear AA against that ink is
10
+ * discarded in favour of the plain bubble fill. Contrast gates the accent here;
11
+ * it does not pick the text colour. An earlier revision did the opposite —
12
+ * it kept every parseable accent and flipped the text between white and
13
+ * near-black to suit it — which meant the accent could never be rejected, and
14
+ * a sent link and its received twin drew different ink from the surrounding
15
+ * conversation (MES-1388).
16
+ *
17
+ * Nothing here touches React or the DOM. Deliberately dependency free —
18
+ * neither `@linktr.ee/design-tokens` nor the `@linktr.ee/component-library`
9
19
  * tailwind preset ships a luminance or contrast helper, and the app-local
10
20
  * `getRelativeLuminance` in `mfe-appearance` thresholds on luminance > 0.5
11
21
  * rather than computing a ratio (and is unreachable from the toolkit anyway).
12
22
  */
23
+ import type { LinkAttachmentVariant } from './CardShell'
13
24
 
14
- /** Text candidate for dark accents. */
15
- export const CHIN_TEXT_LIGHT = '#FFFFFF'
16
- /** Text candidate for light accents — the near-black used across the Figma frames. */
17
- export const CHIN_TEXT_DARK = '#040403'
18
- /**
19
- * Chin fill used whenever no accent is paintable. Matches the `shade` colour
20
- * token in the component-library tailwind preset (and therefore the card's
21
- * pre-MES-1349 background), so the fallback is visually a no-op.
22
- */
23
- export const CHIN_FALLBACK_BG = '#1e2330'
24
25
  /** WCAG 2.1 SC 1.4.3 (AA), normal text. Both chin lines are normal text. */
25
26
  export const WCAG_AA_NORMAL_TEXT = 4.5
26
27
 
27
- const SHORT_HEX_LENGTH = 3
28
+ /**
29
+ * Fill each side falls back to when no accent is paintable: the plain message
30
+ * bubble's own background, so a link card without a usable colour is indistinct
31
+ * from the text bubbles around it rather than a third colour nobody chose.
32
+ *
33
+ * These are the literal values behind `--str-chat__own-message-bubble-background-color`
34
+ * and `--str-chat__message-bubble-background-color` in `styles.css`. Duplicated
35
+ * as hex because the bubble tail paints *outside* the shell's box via an inline
36
+ * style, and the contrast arithmetic below needs channels, not a `var()`.
37
+ * Keep in sync with those two custom properties.
38
+ */
39
+ export const BUBBLE_BG_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
40
+ dark: '#1e2330',
41
+ light: '#f1f0ee',
42
+ }
28
43
 
44
+ const SHORT_HEX_LENGTH = 3
29
45
  /** `#abc`, `#aabbcc`, or either without the leading `#`. */
30
46
  const HEX_PATTERN = /^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
31
47
 
@@ -51,6 +67,14 @@ function linearize(channel: number): number {
51
67
  return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4
52
68
  }
53
69
 
70
+ function luminanceOfRgb(rgb: [number, number, number]): number {
71
+ return (
72
+ 0.2126 * linearize(rgb[0]) +
73
+ 0.7152 * linearize(rgb[1]) +
74
+ 0.0722 * linearize(rgb[2])
75
+ )
76
+ }
77
+
54
78
  /**
55
79
  * WCAG 2.1 relative luminance of an sRGB hex colour, 0..1.
56
80
  * Returns `NaN` for anything unparseable so callers can branch on
@@ -59,69 +83,42 @@ function linearize(channel: number): number {
59
83
  export function relativeLuminance(hex: string): number {
60
84
  const rgb = parseHex(hex)
61
85
  if (!rgb) return Number.NaN
86
+ return luminanceOfRgb(rgb)
87
+ }
62
88
 
63
- return (
64
- 0.2126 * linearize(rgb[0]) +
65
- 0.7152 * linearize(rgb[1]) +
66
- 0.0722 * linearize(rgb[2])
67
- )
89
+ function ratioOfLuminances(a: number, b: number): number {
90
+ if (Number.isNaN(a) || Number.isNaN(b)) return Number.NaN
91
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
68
92
  }
69
93
 
70
- /**
71
- * WCAG 2.1 contrast ratio between two hex colours, 1..21. `NaN` when either
72
- * input is unparseable.
73
- */
94
+ /** WCAG contrast ratio between two hex colours, or `NaN` if either is unparseable. */
74
95
  export function contrastRatio(a: string, b: string): number {
75
- const luminanceA = relativeLuminance(a)
76
- const luminanceB = relativeLuminance(b)
77
- if (Number.isNaN(luminanceA) || Number.isNaN(luminanceB)) return Number.NaN
78
-
79
- const lighter = Math.max(luminanceA, luminanceB)
80
- const darker = Math.min(luminanceA, luminanceB)
81
-
82
- return (lighter + 0.05) / (darker + 0.05)
96
+ return ratioOfLuminances(relativeLuminance(a), relativeLuminance(b))
83
97
  }
84
98
 
85
- /**
86
- * Chin text colour for a given accent: whichever of `CHIN_TEXT_LIGHT` /
87
- * `CHIN_TEXT_DARK` scores the higher contrast ratio, falling back to
88
- * `CHIN_TEXT_LIGHT` when the accent is missing or unparseable (which pairs
89
- * with `CHIN_FALLBACK_BG` at 15.69:1).
90
- *
91
- * The rule is total: `CHIN_TEXT_DARK` clears 4.5:1 for every accent whose
92
- * luminance is >= 0.1804, `CHIN_TEXT_LIGHT` clears it for every accent
93
- * <= 0.1833, and those ranges overlap — so there is no sRGB colour that
94
- * fails against both candidates.
95
- */
96
- export function chinTextColor(
97
- accentColor?: string
98
- ): typeof CHIN_TEXT_LIGHT | typeof CHIN_TEXT_DARK {
99
- if (accentColor == null) return CHIN_TEXT_LIGHT
100
-
101
- const againstLight = contrastRatio(accentColor, CHIN_TEXT_LIGHT)
102
- if (Number.isNaN(againstLight)) return CHIN_TEXT_LIGHT
103
-
104
- return againstLight >= contrastRatio(accentColor, CHIN_TEXT_DARK)
105
- ? CHIN_TEXT_LIGHT
106
- : CHIN_TEXT_DARK
107
- }
99
+ /** Ink candidates for a surface the card cannot refuse. */
100
+ export const INK_ON_DARK = '#FFFFFF'
101
+ /** The near-black used across the Figma frames. */
102
+ export const INK_ON_LIGHT = '#040403'
108
103
 
109
104
  /**
110
- * The accent as an actually paintable CSS colour, or `undefined` when there is
111
- * nothing to paint (absent or unparseable). Single source of truth for
112
- * "does this card have an accent?" across the shell, hero and chin.
105
+ * Ink for a surface the card did not choose — the audio strip's flat neutral,
106
+ * or a variant fallback fill — picking whichever candidate scores the higher
107
+ * ratio against it. `undefined` when the surface is unparseable, so callers
108
+ * keep the variant's own `text-*` classes.
113
109
  *
114
- * Normalizes on the way out, because `parseHex` deliberately accepts a missing
115
- * leading `#` (and surrounding whitespace) and CSS does not. Returned raw,
116
- * `738883` made the inline shell background and the bubble tail silently
117
- * invalid while `chinTextColor` still resolved `#040403` against the parsed
118
- * colour — 1.31:1 near-black on the dark fallback fill, the precise failure
119
- * this module exists to prevent (MES-1349).
110
+ * This is the rule the accent gate cannot cover. `AUDIO_BG_CLASS` (`#F2F3F4`)
111
+ * is imposed on the shell of *any* variant, so a sent audio card drew the
112
+ * variant's white ink on near-white at 1.13:1 — the title and URL were
113
+ * invisible, exactly the symptom MES-1388 was reported for, on a second and
114
+ * unrelated code path to the `bg-shade` regression (both shipped in 3.18.0).
115
+ * Ink follows the surface it actually lands on; only decoration gets refused.
120
116
  */
121
- export function paintableAccent(accentColor?: string): string | undefined {
122
- if (accentColor == null) return undefined
123
- if (Number.isNaN(relativeLuminance(accentColor))) return undefined
117
+ export function inkFor(background: string): string | undefined {
118
+ const againstDarkInk = contrastRatio(background, INK_ON_LIGHT)
119
+ if (Number.isNaN(againstDarkInk)) return undefined
124
120
 
125
- const trimmed = accentColor.trim()
126
- return trimmed.startsWith('#') ? trimmed : `#${trimmed}`
121
+ return againstDarkInk >= contrastRatio(background, INK_ON_DARK)
122
+ ? INK_ON_LIGHT
123
+ : INK_ON_DARK
127
124
  }