@linktr.ee/messaging-react 3.31.1 → 3.31.2-rc-1785926144

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 (33) hide show
  1. package/dist/{Card-EWN07XIl.cjs → Card-CF1TZXD5.cjs} +2 -2
  2. package/dist/{Card-EWN07XIl.cjs.map → Card-CF1TZXD5.cjs.map} +1 -1
  3. package/dist/{Card-CmlaSw7i.js → Card-Ch6N2pkc.js} +2 -2
  4. package/dist/{Card-CmlaSw7i.js.map → Card-Ch6N2pkc.js.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/index-BKkCNnea.js +5677 -0
  7. package/dist/index-BKkCNnea.js.map +1 -0
  8. package/dist/index-Ct_Jgy8P.cjs +2 -0
  9. package/dist/index-Ct_Jgy8P.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +6 -5
  12. package/dist/index.js +1 -1
  13. package/package.json +3 -5
  14. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +44 -27
  15. package/src/components/LinkAttachment/LinkAttachment.test.tsx +47 -39
  16. package/src/components/LinkAttachment/components/Composer/Card.tsx +1 -1
  17. package/src/components/LinkAttachment/components/Received/Card.tsx +2 -2
  18. package/src/components/LinkAttachment/components/Sent/Card.tsx +2 -2
  19. package/src/components/LinkAttachment/components/_shared/BubbleTail.tsx +16 -6
  20. package/src/components/LinkAttachment/components/_shared/CardBody.tsx +4 -5
  21. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +22 -9
  22. package/src/components/LinkAttachment/components/_shared/hex.ts +7 -7
  23. package/src/components/LinkAttachment/components/_shared/useChinPalette.ts +36 -27
  24. package/src/components/LinkAttachment/types.ts +6 -5
  25. package/src/styles.css +39 -0
  26. package/dist/index-CN3errpB.js +0 -7828
  27. package/dist/index-CN3errpB.js.map +0 -1
  28. package/dist/index-CcitzVoW.cjs +0 -5
  29. package/dist/index-CcitzVoW.cjs.map +0 -1
  30. package/src/components/LinkAttachment/components/_shared/accentSurface.test.ts +0 -135
  31. package/src/components/LinkAttachment/components/_shared/accentSurface.ts +0 -95
  32. package/src/components/LinkAttachment/components/_shared/chinContrast.test.ts +0 -95
  33. package/src/components/LinkAttachment/components/_shared/chinContrast.ts +0 -79
@@ -1,135 +0,0 @@
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
- })
@@ -1,95 +0,0 @@
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
- }
@@ -1,95 +0,0 @@
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
- })
@@ -1,79 +0,0 @@
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
- }