@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.
- package/dist/{Card-Ch6N2pkc.js → Card-CmlaSw7i.js} +2 -2
- package/dist/{Card-Ch6N2pkc.js.map → Card-CmlaSw7i.js.map} +1 -1
- package/dist/{Card-CF1TZXD5.cjs → Card-EWN07XIl.cjs} +2 -2
- package/dist/{Card-CF1TZXD5.cjs.map → Card-EWN07XIl.cjs.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/index-CN3errpB.js +7828 -0
- package/dist/index-CN3errpB.js.map +1 -0
- package/dist/index-CcitzVoW.cjs +5 -0
- package/dist/index-CcitzVoW.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +10 -30
- package/dist/index.js +1 -1
- package/dist/testing.d.ts +5 -24
- package/package.json +6 -4
- package/src/components/LinkAttachment/LinkAttachment.stories.tsx +27 -44
- package/src/components/LinkAttachment/LinkAttachment.test.tsx +39 -47
- package/src/components/LinkAttachment/components/Composer/Card.tsx +1 -1
- package/src/components/LinkAttachment/components/Received/Card.tsx +2 -2
- package/src/components/LinkAttachment/components/Sent/Card.tsx +2 -2
- package/src/components/LinkAttachment/components/_shared/BubbleTail.tsx +6 -16
- package/src/components/LinkAttachment/components/_shared/CardBody.tsx +5 -4
- package/src/components/LinkAttachment/components/_shared/CardShell.tsx +9 -22
- package/src/components/LinkAttachment/components/_shared/accentSurface.test.ts +135 -0
- package/src/components/LinkAttachment/components/_shared/accentSurface.ts +95 -0
- package/src/components/LinkAttachment/components/_shared/chinContrast.test.ts +95 -0
- package/src/components/LinkAttachment/components/_shared/chinContrast.ts +79 -0
- package/src/components/LinkAttachment/components/_shared/hex.ts +7 -7
- package/src/components/LinkAttachment/components/_shared/useChinPalette.ts +27 -36
- package/src/components/LinkAttachment/types.ts +5 -6
- package/src/stream-custom-data.ts +7 -24
- package/src/styles.css +0 -39
- package/dist/index-BKkCNnea.js +0 -5677
- package/dist/index-BKkCNnea.js.map +0 -1
- package/dist/index-Ct_Jgy8P.cjs +0 -2
- 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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
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
|
-
//
|
|
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
|
|
2
|
+
* The hex contract shared by `chinContrast` and `accentSurface`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
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
|
-
|
|
43
|
+
chinTextColor?: string
|
|
48
44
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// the
|
|
136
|
-
// card put white ink on
|
|
137
|
-
const
|
|
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
|
-
|
|
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.
|
|
81
|
-
*
|
|
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
|
|
89
|
-
* to load
|
|
90
|
-
*
|
|
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
|
|