@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.
- package/dist/{Card-BI3bA2Cj.js → Card-DcyixIfP.js} +2 -2
- package/dist/{Card-BI3bA2Cj.js.map → Card-DcyixIfP.js.map} +1 -1
- package/dist/{Card-iXdD3NF2.cjs → Card-_fDH4x4V.cjs} +2 -2
- package/dist/{Card-iXdD3NF2.cjs.map → Card-_fDH4x4V.cjs.map} +1 -1
- package/dist/{index-BlHbSVwK.js → index-DSQieSNh.js} +881 -798
- package/dist/index-DSQieSNh.js.map +1 -0
- package/dist/index-quV01XX7.cjs +2 -0
- package/dist/index-quV01XX7.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/components/LinkAttachment/LinkAttachment.stories.tsx +17 -9
- package/src/components/LinkAttachment/LinkAttachment.test.tsx +87 -53
- package/src/components/LinkAttachment/components/Composer/Card.tsx +1 -1
- package/src/components/LinkAttachment/components/Received/Card.tsx +1 -1
- package/src/components/LinkAttachment/components/Sent/Card.tsx +1 -1
- package/src/components/LinkAttachment/components/_shared/CardBody.tsx +11 -10
- package/src/components/LinkAttachment/components/_shared/CardShell.tsx +7 -1
- package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +9 -0
- package/src/components/LinkAttachment/components/_shared/accentSurface.test.ts +151 -0
- package/src/components/LinkAttachment/components/_shared/accentSurface.ts +211 -0
- package/src/components/LinkAttachment/components/_shared/chinContrast.test.ts +64 -124
- package/src/components/LinkAttachment/components/_shared/chinContrast.ts +68 -71
- package/src/components/LinkAttachment/components/_shared/useChinPalette.ts +35 -31
- package/src/components/LinkAttachment/types.ts +2 -2
- package/dist/index-BlHbSVwK.js.map +0 -1
- package/dist/index-F3RRurYR.cjs +0 -2
- 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
|
-
|
|
5
|
-
CHIN_TEXT_DARK,
|
|
6
|
-
CHIN_TEXT_LIGHT,
|
|
7
|
-
WCAG_AA_NORMAL_TEXT,
|
|
8
|
-
chinTextColor,
|
|
5
|
+
BUBBLE_BG_BY_VARIANT,
|
|
9
6
|
contrastRatio,
|
|
10
|
-
|
|
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('#
|
|
17
|
-
expect(relativeLuminance('#000000')).toBeCloseTo(0,
|
|
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
|
|
33
|
-
expect(relativeLuminance('#fff')).
|
|
34
|
-
expect(relativeLuminance('
|
|
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('
|
|
38
|
-
for (const
|
|
39
|
-
expect(relativeLuminance(
|
|
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('
|
|
46
|
-
expect(contrastRatio('#000000', '#
|
|
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
|
|
51
|
-
expect(contrastRatio('#
|
|
52
|
-
contrastRatio(
|
|
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('
|
|
57
|
-
expect(contrastRatio('nope', '#
|
|
58
|
-
expect(contrastRatio('#
|
|
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('
|
|
63
|
-
it('
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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('
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
expect(
|
|
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('
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
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('
|
|
157
|
-
expect(
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
122
|
-
|
|
123
|
-
if (Number.isNaN(
|
|
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
|
-
|
|
126
|
-
|
|
121
|
+
return againstDarkInk >= contrastRatio(background, INK_ON_DARK)
|
|
122
|
+
? INK_ON_LIGHT
|
|
123
|
+
: INK_ON_DARK
|
|
127
124
|
}
|