@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
package/dist/index.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-quV01XX7.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageBubble=e.MessageBubble;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.optimizeMessagingAttachmentUrl=e.optimizeMessagingAttachmentUrl;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
|
|
2
2
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.d.ts
CHANGED
|
@@ -693,8 +693,8 @@ export declare interface LinkAttachmentBaseProps {
|
|
|
693
693
|
* screen. Omitted, unparseable, `layout: 'classic'`, no `thumbnailUrl`,
|
|
694
694
|
* `status` other than `'ready'`, a playable-audio `mimeType` + `sourceUrl`
|
|
695
695
|
* (whose hero is the native player rather than an image), or a hero that
|
|
696
|
-
* fails to load
|
|
697
|
-
* `
|
|
696
|
+
* fails to load, or the colour cannot clear AA against this side's ink =>
|
|
697
|
+
* the card falls back to `BUBBLE_BG_BY_VARIANT[variant]`, the plain bubble fill.
|
|
698
698
|
*/
|
|
699
699
|
accentColor?: string;
|
|
700
700
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as c, i as u, j as L, k as h, l as p, m as d, n as v, p as C, q as A, s as k, t as F, u as b, v as P, w as f, x, o as y, y as z, z as B, B as S, D as q, E as D, G as E, H as V, I as w } from "./index-
|
|
1
|
+
import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as c, i as u, j as L, k as h, l as p, m as d, n as v, p as C, q as A, s as k, t as F, u as b, v as P, w as f, x, o as y, y as z, z as B, B as S, D as q, E as D, G as E, H as V, I as w } from "./index-DSQieSNh.js";
|
|
2
2
|
export {
|
|
3
3
|
e as ActionButton,
|
|
4
4
|
t as Avatar,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linktr.ee/messaging-react",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.0-rc-1785440433",
|
|
4
4
|
"description": "React messaging components built on messaging-core for web applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@linktr.ee/component-library": "11.8.6",
|
|
53
|
-
"@linktr.ee/messaging-core": "2.3.0-rc-
|
|
53
|
+
"@linktr.ee/messaging-core": "2.3.0-rc-1785440433",
|
|
54
54
|
"@linktr.ee/messaging-taxonomy": "^0.3.0",
|
|
55
55
|
"@phosphor-icons/react": "^2.1.10"
|
|
56
56
|
},
|
|
@@ -239,10 +239,10 @@ export const LinkApps: StoryFn = () => (
|
|
|
239
239
|
────────────────────────────────────────────────────────────────── */
|
|
240
240
|
|
|
241
241
|
const ACCENTS: Array<{ label: string; accentColor?: string }> = [
|
|
242
|
-
// The Figma mock's own chin colour.
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
242
|
+
// The Figma mock's own chin colour. Painted raw it measures 3.76:1 against
|
|
243
|
+
// the sender's white ink, which is why the mock cannot simply be reproduced;
|
|
244
|
+
// derived, its hue survives at a lightness both inks clear. The shipped sent
|
|
245
|
+
// card is therefore darker than the mock on purpose (MES-1388).
|
|
246
246
|
{ label: '#738883 (mock)', accentColor: '#738883' },
|
|
247
247
|
{ label: '#AA9F93', accentColor: '#AA9F93' },
|
|
248
248
|
{ label: '#FFF099', accentColor: '#FFF099' },
|
|
@@ -260,12 +260,20 @@ const ACCENT_CARD = {
|
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
/**
|
|
263
|
-
* The dominant-colour
|
|
263
|
+
* The dominant-colour card and its WCAG AA gate (MES-1349, corrected MES-1388).
|
|
264
264
|
*
|
|
265
|
-
* Each column is one extracted accent
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
265
|
+
* Each column is one extracted accent. Ink is *fixed per side* — `text-white`
|
|
266
|
+
* when sent, `text-black/90` when received — so contrast decides whether the
|
|
267
|
+
* accent may paint at all rather than which text colour to use. An accent under
|
|
268
|
+
* 4.5:1 against that side's ink is discarded for the plain message bubble fill
|
|
269
|
+
* (`#1e2330` sent, `#f1f0ee` received), so a card without a usable colour reads
|
|
270
|
+
* like the text bubbles around it.
|
|
271
|
+
*
|
|
272
|
+
* Each column is a synthetic base colour chosen to probe the derivation, not a
|
|
273
|
+
* colour taken from the hero — in production `accent_color` is extracted from
|
|
274
|
+
* the attachment image itself, so a photograph's own hue is what arrives here.
|
|
275
|
+
* Every column paints on both sides: the derivation moves lightness, never hue,
|
|
276
|
+
* so a base is never refused for contrast.
|
|
269
277
|
*
|
|
270
278
|
* The bottom rows cover the states where the accent is deliberately ignored:
|
|
271
279
|
* `classic` has no image to extract from, and `loading` has no image yet.
|
|
@@ -108,41 +108,64 @@ describe('LinkAttachment.Sent', () => {
|
|
|
108
108
|
})
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
-
describe('LinkAttachment accent chin (MES-1349)', () => {
|
|
112
|
-
const ACCENT = '#
|
|
111
|
+
describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
|
|
112
|
+
const ACCENT = '#7008E7'
|
|
113
|
+
// The near-white dominant colour from the MES-1388 report. Painted raw it put
|
|
114
|
+
// white ink on 1.09:1; derived, it is legible on both sides.
|
|
115
|
+
const PALE_ACCENT = '#FCF5E3'
|
|
113
116
|
const CARD_PROPS = {
|
|
114
117
|
title: 'Yoga on Ice with Brie Nala',
|
|
115
118
|
url: 'tr.ee/briewebsite',
|
|
116
119
|
thumbnailUrl: 'https://cdn.example.com/yoga.jpg',
|
|
117
120
|
}
|
|
118
121
|
|
|
119
|
-
|
|
122
|
+
const surfaceOf = (element: HTMLElement) => element.style.backgroundColor
|
|
123
|
+
|
|
124
|
+
it('derives a dark surface for the sender and inks it white', async () => {
|
|
120
125
|
renderWithProviders(
|
|
121
126
|
<LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
|
|
122
127
|
)
|
|
123
|
-
|
|
124
|
-
// the
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
expect(
|
|
129
|
-
expect(screen.getByText(CARD_PROPS.
|
|
128
|
+
const root = await screen.findByRole('link')
|
|
129
|
+
// Not the raw accent: the hue is kept, the lightness is replaced with the
|
|
130
|
+
// stop this side's ink can clear. Painting the extracted colour verbatim is
|
|
131
|
+
// what MES-1388 was reported for.
|
|
132
|
+
expect(surfaceOf(root)).not.toBe('')
|
|
133
|
+
expect(root).not.toHaveStyle({ backgroundColor: ACCENT })
|
|
134
|
+
expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
|
|
130
135
|
})
|
|
131
136
|
|
|
132
|
-
it('
|
|
137
|
+
it('derives a pale surface for the receiver and inks it near-black', async () => {
|
|
133
138
|
renderWithProviders(
|
|
134
|
-
<LinkAttachment.
|
|
139
|
+
<LinkAttachment.Received {...CARD_PROPS} accentColor={ACCENT} />
|
|
135
140
|
)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
})
|
|
141
|
+
const root = await screen.findByRole('link')
|
|
142
|
+
expect(surfaceOf(root)).not.toBe('')
|
|
143
|
+
expect(root).not.toHaveStyle({ backgroundColor: ACCENT })
|
|
144
|
+
expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('colours both sides from one extracted colour, never rejecting it', async () => {
|
|
148
|
+
// The pale report colour and the vivid one both paint, on both sides — the
|
|
149
|
+
// feature adds colour to every card with an image rather than half of them.
|
|
150
|
+
for (const accentColor of [ACCENT, PALE_ACCENT]) {
|
|
151
|
+
const sent = renderWithProviders(
|
|
152
|
+
<LinkAttachment.Sent {...CARD_PROPS} accentColor={accentColor} />
|
|
153
|
+
)
|
|
154
|
+
expect(surfaceOf(await screen.findByRole('link'))).not.toBe('')
|
|
155
|
+
sent.unmount()
|
|
156
|
+
|
|
157
|
+
const received = renderWithProviders(
|
|
158
|
+
<LinkAttachment.Received {...CARD_PROPS} accentColor={accentColor} />
|
|
159
|
+
)
|
|
160
|
+
expect(surfaceOf(await screen.findByRole('link'))).not.toBe('')
|
|
161
|
+
received.unmount()
|
|
162
|
+
}
|
|
139
163
|
})
|
|
140
164
|
|
|
141
165
|
it('falls back to a self-contained fill, not a consumer theme token', async () => {
|
|
142
166
|
renderWithProviders(<LinkAttachment.Sent {...CARD_PROPS} />)
|
|
143
167
|
const root = await screen.findByRole('link')
|
|
144
|
-
expect(root
|
|
145
|
-
expect(screen.getByText(CARD_PROPS.title).style.color).toBe('')
|
|
168
|
+
expect(surfaceOf(root)).toBe('')
|
|
146
169
|
|
|
147
170
|
// The fill has to survive in consumers that do not share our Tailwind
|
|
148
171
|
// theme. This package ships no utilities, so consumers generate our classes
|
|
@@ -164,7 +187,8 @@ describe('LinkAttachment accent chin (MES-1349)', () => {
|
|
|
164
187
|
)
|
|
165
188
|
const root = await screen.findByRole('link')
|
|
166
189
|
expect(root.style.backgroundColor).toBe('')
|
|
167
|
-
|
|
190
|
+
// Ink still comes from the surface — here the sender's fallback fill.
|
|
191
|
+
expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
|
|
168
192
|
})
|
|
169
193
|
|
|
170
194
|
it('drops the accent when the hero image fails to load', async () => {
|
|
@@ -172,13 +196,13 @@ describe('LinkAttachment accent chin (MES-1349)', () => {
|
|
|
172
196
|
<LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
|
|
173
197
|
)
|
|
174
198
|
const root = await screen.findByRole('link')
|
|
175
|
-
expect(root).
|
|
199
|
+
expect(root.style.backgroundColor).not.toBe('')
|
|
176
200
|
|
|
177
|
-
//
|
|
201
|
+
// A surface derived from an image the viewer cannot see is a lie, so the
|
|
178
202
|
// card falls all the way back rather than keeping a colour with no source.
|
|
179
203
|
fireEvent.error(screen.getByRole('img'))
|
|
180
204
|
expect(root.style.backgroundColor).toBe('')
|
|
181
|
-
expect(screen.getByText(CARD_PROPS.title)
|
|
205
|
+
expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
|
|
182
206
|
})
|
|
183
207
|
|
|
184
208
|
it('drops the accent when a playable video poster fails to load', () => {
|
|
@@ -198,50 +222,51 @@ describe('LinkAttachment accent chin (MES-1349)', () => {
|
|
|
198
222
|
const shell = container.querySelector(
|
|
199
223
|
'[class*="select-none"]'
|
|
200
224
|
) as HTMLElement
|
|
201
|
-
expect(shell).
|
|
225
|
+
expect(shell.style.backgroundColor).not.toBe('')
|
|
202
226
|
expect(container.querySelector('video')).toHaveAttribute('poster')
|
|
203
227
|
|
|
204
228
|
fireEvent.error(container.querySelector('img') as HTMLImageElement)
|
|
205
229
|
|
|
206
230
|
expect(shell.style.backgroundColor).toBe('')
|
|
207
|
-
expect(screen.getByText(CARD_PROPS.title)
|
|
231
|
+
expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
|
|
208
232
|
// The failed poster is dropped with the accent, so the player falls back to
|
|
209
233
|
// its own first frame instead of retrying a 404.
|
|
210
234
|
expect(container.querySelector('video')).not.toHaveAttribute('poster')
|
|
211
235
|
})
|
|
212
236
|
|
|
213
|
-
it('
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
// `featured` (`linkCardPropsFromStreamAttachment`).
|
|
237
|
+
it('derives audio artwork into the surface, and inks the flat fill when there is none', async () => {
|
|
238
|
+
// MES-1349 refused the accent here because the hero collapses to the native
|
|
239
|
+
// player and the artwork is never shown. The colour is derived now, so it
|
|
240
|
+
// cannot mislead about legibility, and artwork is what distinguishes one
|
|
241
|
+
// track from another — so it paints. Reachable through Stream, which maps
|
|
242
|
+
// any playable link attachment to `featured`.
|
|
220
243
|
const AUDIO = {
|
|
221
244
|
mimeType: 'audio/mpeg',
|
|
222
245
|
sourceUrl: 'https://cdn.example.com/clip.mp3',
|
|
223
246
|
}
|
|
224
247
|
|
|
225
|
-
const
|
|
248
|
+
const withArtwork = renderWithProviders(
|
|
226
249
|
<LinkAttachment.Sent {...CARD_PROPS} {...AUDIO} accentColor={ACCENT} />
|
|
227
250
|
)
|
|
228
|
-
const
|
|
229
|
-
expect(
|
|
230
|
-
expect(sent.className).toContain(AUDIO_BG_CLASS)
|
|
231
|
-
expect(screen.getByText(CARD_PROPS.title).style.color).toBe('')
|
|
251
|
+
const painted = withArtwork.container.firstElementChild as HTMLElement
|
|
252
|
+
expect(painted.style.backgroundColor).not.toBe('')
|
|
232
253
|
expect(screen.queryByRole('img')).toBeNull()
|
|
233
|
-
unmount()
|
|
254
|
+
withArtwork.unmount()
|
|
234
255
|
|
|
235
|
-
|
|
236
|
-
|
|
256
|
+
// No artwork: the flat audio fill stays, and this is the MES-1388 bug —
|
|
257
|
+
// `AUDIO_BG_CLASS` is near-white and lands on a *sent* card, whose variant
|
|
258
|
+
// ink is white. Ink must follow the fill it was given, not the variant.
|
|
259
|
+
const bare = renderWithProviders(
|
|
260
|
+
<LinkAttachment.Sent
|
|
237
261
|
{...CARD_PROPS}
|
|
238
262
|
{...AUDIO}
|
|
239
|
-
|
|
263
|
+
thumbnailUrl={undefined}
|
|
240
264
|
/>
|
|
241
265
|
)
|
|
242
|
-
const
|
|
243
|
-
expect(
|
|
244
|
-
expect(
|
|
266
|
+
const flat = bare.container.firstElementChild as HTMLElement
|
|
267
|
+
expect(flat.style.backgroundColor).toBe('')
|
|
268
|
+
expect(flat.className).toContain(AUDIO_BG_CLASS)
|
|
269
|
+
expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
|
|
245
270
|
})
|
|
246
271
|
|
|
247
272
|
it('drops the accent when the featured hero has no image to show', async () => {
|
|
@@ -259,20 +284,25 @@ describe('LinkAttachment accent chin (MES-1349)', () => {
|
|
|
259
284
|
// Variant fill, asserted by shape not literal — see the self-contained-fill
|
|
260
285
|
// test above for why this must stay an arbitrary value (MES-1388).
|
|
261
286
|
expect(root.className).toMatch(/bg-\[/)
|
|
262
|
-
expect(screen.getByText(CARD_PROPS.title)
|
|
287
|
+
expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
|
|
263
288
|
})
|
|
264
289
|
|
|
265
290
|
it('normalizes a hashless accent so the background actually paints', async () => {
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
291
|
+
// The extractor returns a bare hex often enough that dropping it would lose
|
|
292
|
+
// the colour silently — `background-color: 7008E7` is invalid CSS (MES-1349).
|
|
293
|
+
const hashless = renderWithProviders(
|
|
294
|
+
<LinkAttachment.Sent {...CARD_PROPS} accentColor="7008E7" />
|
|
295
|
+
)
|
|
296
|
+
const derived = (await screen.findByRole('link')).style.backgroundColor
|
|
297
|
+
expect(derived).not.toBe('')
|
|
298
|
+
hashless.unmount()
|
|
299
|
+
|
|
269
300
|
renderWithProviders(
|
|
270
|
-
<LinkAttachment.Sent {...CARD_PROPS} accentColor=
|
|
301
|
+
<LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
|
|
302
|
+
)
|
|
303
|
+
expect((await screen.findByRole('link')).style.backgroundColor).toBe(
|
|
304
|
+
derived
|
|
271
305
|
)
|
|
272
|
-
const root = await screen.findByRole('link')
|
|
273
|
-
expect(root.style.backgroundColor).not.toBe('')
|
|
274
|
-
expect(root).toHaveStyle({ backgroundColor: ACCENT })
|
|
275
|
-
expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
|
|
276
306
|
})
|
|
277
307
|
|
|
278
308
|
it('lets a loading status outrank the audio hero on all three cards', () => {
|
|
@@ -318,8 +348,12 @@ describe('LinkAttachment accent chin (MES-1349)', () => {
|
|
|
318
348
|
groupPosition="end"
|
|
319
349
|
/>
|
|
320
350
|
)
|
|
321
|
-
|
|
322
|
-
|
|
351
|
+
// The tail paints outside the shell's own box, so it takes the surface as an
|
|
352
|
+
// inline colour — it has to be the *derived* one or the tail and the card
|
|
353
|
+
// would disagree.
|
|
354
|
+
const card = await screen.findByRole('link')
|
|
355
|
+
expect(screen.getByTestId('link-card-tail')).toHaveStyle({
|
|
356
|
+
backgroundColor: card.style.backgroundColor,
|
|
323
357
|
})
|
|
324
358
|
unmount()
|
|
325
359
|
|
|
@@ -147,6 +147,7 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
|
|
|
147
147
|
/>
|
|
148
148
|
)}
|
|
149
149
|
<CardBody
|
|
150
|
+
chinTextColor={chin.chinTextColor}
|
|
150
151
|
variant="dark"
|
|
151
152
|
title={title}
|
|
152
153
|
placeholderTitle={placeholderTitle}
|
|
@@ -155,7 +156,6 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
|
|
|
155
156
|
appIcon={appIcon}
|
|
156
157
|
cta={cta}
|
|
157
158
|
trailingAction={editButton}
|
|
158
|
-
chinTextColor={chin.chinTextColor}
|
|
159
159
|
isLoading={chin.isLoading}
|
|
160
160
|
/>
|
|
161
161
|
</CardShell>
|
|
@@ -140,13 +140,13 @@ const ReceivedCard: React.FC<ReceivedCardProps> = ({
|
|
|
140
140
|
/>
|
|
141
141
|
)}
|
|
142
142
|
<CardBody
|
|
143
|
+
chinTextColor={chin.chinTextColor}
|
|
143
144
|
variant="light"
|
|
144
145
|
title={title}
|
|
145
146
|
description={description}
|
|
146
147
|
url={url}
|
|
147
148
|
appIcon={appIcon}
|
|
148
149
|
cta={wrappedCta}
|
|
149
|
-
chinTextColor={chin.chinTextColor}
|
|
150
150
|
isLoading={chin.isLoading}
|
|
151
151
|
/>
|
|
152
152
|
</CardShell>
|
|
@@ -121,6 +121,7 @@ const SentCard: React.FC<SentCardProps> = ({
|
|
|
121
121
|
/>
|
|
122
122
|
)}
|
|
123
123
|
<CardBody
|
|
124
|
+
chinTextColor={chin.chinTextColor}
|
|
124
125
|
variant="dark"
|
|
125
126
|
title={title}
|
|
126
127
|
placeholderTitle={placeholderTitle}
|
|
@@ -128,7 +129,6 @@ const SentCard: React.FC<SentCardProps> = ({
|
|
|
128
129
|
url={url}
|
|
129
130
|
appIcon={appIcon}
|
|
130
131
|
cta={cta}
|
|
131
|
-
chinTextColor={chin.chinTextColor}
|
|
132
132
|
isLoading={chin.isLoading}
|
|
133
133
|
/>
|
|
134
134
|
</CardShell>
|
|
@@ -14,6 +14,14 @@ export interface CardBodyProps {
|
|
|
14
14
|
description?: string
|
|
15
15
|
/** Footer URL shown below the description. Ignored when `cta` is set. */
|
|
16
16
|
url?: string
|
|
17
|
+
/**
|
|
18
|
+
* Ink for both chin lines, from `chinContrast.inkFor` — measured against the
|
|
19
|
+
* surface this card actually paints, which may be a derived accent, the audio
|
|
20
|
+
* strip, or the variant fallback. Replaces the variant's own `text-*` classes
|
|
21
|
+
* (including their opacity) on both lines, so a card whose surface is not the
|
|
22
|
+
* variant default still reads (MES-1388).
|
|
23
|
+
*/
|
|
24
|
+
chinTextColor?: string
|
|
17
25
|
/**
|
|
18
26
|
* Optional 16x16 brand badge rendered before the title (used by Link Apps:
|
|
19
27
|
* Spotify, TikTok, FAQ, Form, etc.).
|
|
@@ -23,13 +31,6 @@ export interface CardBodyProps {
|
|
|
23
31
|
cta?: LinkAttachmentCta
|
|
24
32
|
/** Trailing action rendered on the right of the title/description block. */
|
|
25
33
|
trailingAction?: React.ReactNode
|
|
26
|
-
/**
|
|
27
|
-
* Resolved chin text colour for an accent-painted card, from
|
|
28
|
-
* `chinContrast.chinTextColor`. When set it replaces the variant's own
|
|
29
|
-
* `text-*` classes (including their opacity) on both lines, because the
|
|
30
|
-
* WCAG proof only holds for the two full-opacity candidates.
|
|
31
|
-
*/
|
|
32
|
-
chinTextColor?: string
|
|
33
34
|
/** Renders two neutral pulsing bars in place of the text. */
|
|
34
35
|
isLoading?: boolean
|
|
35
36
|
}
|
|
@@ -69,6 +70,7 @@ const SKELETON_BAR_CLASS_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
|
|
|
69
70
|
*/
|
|
70
71
|
const CardBody: React.FC<CardBodyProps> = ({
|
|
71
72
|
variant,
|
|
73
|
+
chinTextColor,
|
|
72
74
|
title,
|
|
73
75
|
placeholderTitle,
|
|
74
76
|
description,
|
|
@@ -76,7 +78,6 @@ const CardBody: React.FC<CardBodyProps> = ({
|
|
|
76
78
|
appIcon,
|
|
77
79
|
cta,
|
|
78
80
|
trailingAction,
|
|
79
|
-
chinTextColor,
|
|
80
81
|
isLoading = false,
|
|
81
82
|
}) => {
|
|
82
83
|
const isDark = variant === 'dark'
|
|
@@ -107,8 +108,6 @@ const CardBody: React.FC<CardBodyProps> = ({
|
|
|
107
108
|
chinTextColor == null && SECONDARY_CLASS_BY_VARIANT[variant]
|
|
108
109
|
)
|
|
109
110
|
|
|
110
|
-
const chinTextStyle = chinTextColor ? { color: chinTextColor } : undefined
|
|
111
|
-
|
|
112
111
|
if (isLoading) {
|
|
113
112
|
const barClass = classNames(
|
|
114
113
|
'animate-pulse rounded',
|
|
@@ -126,6 +125,8 @@ const CardBody: React.FC<CardBodyProps> = ({
|
|
|
126
125
|
)
|
|
127
126
|
}
|
|
128
127
|
|
|
128
|
+
const chinTextStyle = chinTextColor ? { color: chinTextColor } : undefined
|
|
129
|
+
|
|
129
130
|
return (
|
|
130
131
|
<div className="shrink-0 px-4 py-3">
|
|
131
132
|
<div className="flex items-end gap-3">
|
|
@@ -110,7 +110,13 @@ const CardShell: React.FC<CardShellProps> = ({
|
|
|
110
110
|
// dropped, the chin rendered transparent, and the own-message `color: white`
|
|
111
111
|
// left the title and URL invisible (MES-1388). An arbitrary value is
|
|
112
112
|
// generated from the class name alone and cannot depend on consumer theme.
|
|
113
|
-
|
|
113
|
+
//
|
|
114
|
+
// Both values are the plain message bubble's own fill, so a card with no
|
|
115
|
+
// usable accent matches the text bubbles around it: `#1e2330` is
|
|
116
|
+
// `--str-chat__own-message-bubble-background-color` and `#f1f0ee` is
|
|
117
|
+
// `--str-chat__message-bubble-background-color`. Mirrored in
|
|
118
|
+
// `chinContrast.BUBBLE_BG_BY_VARIANT`, which the bubble tail reads.
|
|
119
|
+
bgClassName ?? (variant === 'dark' ? 'bg-[#1e2330]' : 'bg-[#f1f0ee]'),
|
|
114
120
|
// `focus-ring` is a design-system utility from the component-library
|
|
115
121
|
// tailwind preset — outline-none + a black 2px focus-visible ring
|
|
116
122
|
// with offset, so keyboard users can see the focused card.
|
|
@@ -70,7 +70,16 @@ export const isPlayableMedia = (mimeType?: string, sourceUrl?: string) => {
|
|
|
70
70
|
* Background colour the LinkAttachment cards switch to when the source is
|
|
71
71
|
* audio — flat neutral around the native `<audio>` chrome regardless of
|
|
72
72
|
* the dark / light variant.
|
|
73
|
+
*
|
|
74
|
+
* The hex is exported alongside the class because the card's ink is chosen by
|
|
75
|
+
* measuring contrast against whatever surface it lands on, and contrast needs
|
|
76
|
+
* channels rather than a class name. They are written out twice on purpose:
|
|
77
|
+
* Tailwind only generates utilities it can find as complete literals when it
|
|
78
|
+
* scans `dist`, so building the class from the constant
|
|
79
|
+
* (`` `bg-[${AUDIO_BG}]` ``) would emit nothing at all. `chinContrast.test.ts`
|
|
80
|
+
* asserts the two stay in step.
|
|
73
81
|
*/
|
|
82
|
+
export const AUDIO_BG = '#F2F3F4'
|
|
74
83
|
export const AUDIO_BG_CLASS = 'bg-[#F2F3F4]'
|
|
75
84
|
|
|
76
85
|
/**
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { accentSurface } from './accentSurface'
|
|
4
|
+
import {
|
|
5
|
+
contrastRatio,
|
|
6
|
+
inkFor,
|
|
7
|
+
INK_ON_DARK,
|
|
8
|
+
INK_ON_LIGHT,
|
|
9
|
+
WCAG_AA_NORMAL_TEXT,
|
|
10
|
+
} from './chinContrast'
|
|
11
|
+
|
|
12
|
+
/** OKLab a/b of a hex — hue angle and chroma both come off this pair. */
|
|
13
|
+
function oklabAb(hex: string): [number, number] {
|
|
14
|
+
const [red, green, blue] = [1, 3, 5].map((index) => {
|
|
15
|
+
const channel = Number.parseInt(hex.slice(index, index + 2), 16) / 255
|
|
16
|
+
return channel <= 0.04045
|
|
17
|
+
? channel / 12.92
|
|
18
|
+
: ((channel + 0.055) / 1.055) ** 2.4
|
|
19
|
+
})
|
|
20
|
+
const long = Math.cbrt(
|
|
21
|
+
0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue
|
|
22
|
+
)
|
|
23
|
+
const medium = Math.cbrt(
|
|
24
|
+
0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue
|
|
25
|
+
)
|
|
26
|
+
const short = Math.cbrt(
|
|
27
|
+
0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue
|
|
28
|
+
)
|
|
29
|
+
return [
|
|
30
|
+
1.9779984951 * long - 2.428592205 * medium + 0.4505937099 * short,
|
|
31
|
+
0.0259040371 * long + 0.7827717662 * medium - 0.808675766 * short,
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hueOf(hex: string): number {
|
|
36
|
+
const [a, b] = oklabAb(hex)
|
|
37
|
+
return ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function chromaOf(hex: string): number {
|
|
41
|
+
return Math.hypot(...oklabAb(hex))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Hues far enough apart to be visibly different colours. */
|
|
45
|
+
const SAMPLES = [
|
|
46
|
+
'#7008E7', // violet — chroma well outside sRGB at both stops
|
|
47
|
+
'#2FCB5F', // green
|
|
48
|
+
'#FFF099', // pale yellow
|
|
49
|
+
'#00B6FF', // cyan
|
|
50
|
+
'#738883', // the Figma mock's desaturated green
|
|
51
|
+
'#FCF5E3', // the near-white from the MES-1388 report
|
|
52
|
+
'#1e2330', // already as dark as the sender fallback
|
|
53
|
+
'#767676', // fully neutral, zero chroma
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
describe('accentSurface', () => {
|
|
57
|
+
it('always produces a surface its own ink can clear AA on', () => {
|
|
58
|
+
// The whole point of deriving rather than admitting: no extracted colour is
|
|
59
|
+
// ever rejected for contrast, and none is ever illegible.
|
|
60
|
+
for (const sample of SAMPLES) {
|
|
61
|
+
for (const [variant, ink] of [
|
|
62
|
+
['dark', INK_ON_DARK],
|
|
63
|
+
['light', INK_ON_LIGHT],
|
|
64
|
+
] as const) {
|
|
65
|
+
const surface = accentSurface(sample, variant)
|
|
66
|
+
expect(surface, `${sample} on ${variant}`).toBeDefined()
|
|
67
|
+
expect(
|
|
68
|
+
contrastRatio(surface as string, ink),
|
|
69
|
+
`${sample} on ${variant}`
|
|
70
|
+
).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TEXT)
|
|
71
|
+
// And the ink the card will actually pick agrees with that side.
|
|
72
|
+
expect(inkFor(surface as string)).toBe(ink)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('keeps the image hue so different links stay distinguishable', () => {
|
|
78
|
+
for (const sample of SAMPLES) {
|
|
79
|
+
// Hue is only meaningful above a little chroma: `#FCF5E3` and `#767676`
|
|
80
|
+
// are near-neutral, where the angle is numerically unstable and invisible.
|
|
81
|
+
if (chromaOf(sample) < 0.03) continue
|
|
82
|
+
const expected = hueOf(sample)
|
|
83
|
+
for (const variant of ['dark', 'light'] as const) {
|
|
84
|
+
const surface = accentSurface(sample, variant) as string
|
|
85
|
+
const drift = Math.abs(hueOf(surface) - expected)
|
|
86
|
+
expect(
|
|
87
|
+
Math.min(drift, 360 - drift),
|
|
88
|
+
`${sample} ${variant}`
|
|
89
|
+
).toBeLessThan(2)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('separates the two sides, so each is legible under its own ink', () => {
|
|
95
|
+
// Sent lands dark, received pale — the same link, the same hue, opposite
|
|
96
|
+
// ends of the lightness axis because the inks are opposites.
|
|
97
|
+
for (const sample of SAMPLES) {
|
|
98
|
+
const sent = accentSurface(sample, 'dark') as string
|
|
99
|
+
const received = accentSurface(sample, 'light') as string
|
|
100
|
+
expect(contrastRatio(sent, INK_ON_DARK)).toBeGreaterThan(
|
|
101
|
+
contrastRatio(sent, INK_ON_LIGHT)
|
|
102
|
+
)
|
|
103
|
+
expect(contrastRatio(received, INK_ON_LIGHT)).toBeGreaterThan(
|
|
104
|
+
contrastRatio(received, INK_ON_DARK)
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('holds AA at every hue, at chroma beyond what sRGB can show', () => {
|
|
110
|
+
// Sweeps the failure mode that a fixed lightness stop could plausibly have:
|
|
111
|
+
// a hue whose maximum chroma pushes the derived colour out of gamut.
|
|
112
|
+
for (let hue = 0; hue < 360; hue += 15) {
|
|
113
|
+
const vivid = `#${[0, 120, 240]
|
|
114
|
+
.map((offset) =>
|
|
115
|
+
Math.round(127 + 127 * Math.cos(((hue + offset) * Math.PI) / 180))
|
|
116
|
+
.toString(16)
|
|
117
|
+
.padStart(2, '0')
|
|
118
|
+
)
|
|
119
|
+
.join('')}`
|
|
120
|
+
for (const [variant, ink] of [
|
|
121
|
+
['dark', INK_ON_DARK],
|
|
122
|
+
['light', INK_ON_LIGHT],
|
|
123
|
+
] as const) {
|
|
124
|
+
const surface = accentSurface(vivid, variant)
|
|
125
|
+
expect(
|
|
126
|
+
contrastRatio(surface as string, ink),
|
|
127
|
+
`hue ${hue} on ${variant}`
|
|
128
|
+
).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TEXT)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('accepts the shapes the extractor and CSS disagree about', () => {
|
|
134
|
+
// `accent_color` arrives from the ugc-kit query as a bare hex often enough
|
|
135
|
+
// that a missing `#` must not silently drop the colour.
|
|
136
|
+
expect(accentSurface('7008E7', 'dark')).toBe(
|
|
137
|
+
accentSurface('#7008E7', 'dark')
|
|
138
|
+
)
|
|
139
|
+
expect(accentSurface(' #7008E7 ', 'dark')).toBe(
|
|
140
|
+
accentSurface('#7008E7', 'dark')
|
|
141
|
+
)
|
|
142
|
+
expect(accentSurface('#70E', 'dark')).toBeDefined()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('has no surface when there was no colour to derive from', () => {
|
|
146
|
+
expect(accentSurface(undefined, 'dark')).toBeUndefined()
|
|
147
|
+
expect(accentSurface('', 'dark')).toBeUndefined()
|
|
148
|
+
expect(accentSurface('rebeccapurple', 'dark')).toBeUndefined()
|
|
149
|
+
expect(accentSurface('#ff', 'light')).toBeUndefined()
|
|
150
|
+
})
|
|
151
|
+
})
|