@multiplatform.one/theme 7.4.1 → 7.6.0
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/README.md +2 -1
- package/package.json +5 -5
- package/src/audit/index.ts +19 -0
- package/src/audit/themeMatrix.spec.ts +274 -0
- package/src/audit/themeMatrix.ts +630 -0
- package/src/figma/figmaTokens.spec.ts +25 -21
- package/src/figma/figmaTokens.ts +2 -2
- package/src/font.ts +37 -25
- package/src/index.ts +12 -0
- package/src/keyboardFocusRing.ts +12 -6
- package/src/theme/FontKnobStyles.tsx +14 -31
- package/src/theme/Tint.tsx +3 -2
- package/src/theme/animations/css.ts +14 -1
- package/src/theme/colorRules.spec.ts +306 -2
- package/src/theme/colorRules.ts +118 -0
- package/src/theme/cornerSmoothing.native.ts +39 -2
- package/src/theme/cornerSmoothing.spec.ts +270 -3
- package/src/theme/cornerSmoothing.ts +147 -10
- package/src/theme/createDefaultThemeConfig.spec.ts +107 -1
- package/src/theme/createDefaultThemeConfig.ts +27 -8
- package/src/theme/defaults/builderOptions.ts +23 -10
- package/src/theme/defaults/categoryFonts.ts +24 -0
- package/src/theme/defaults/fonts.spec.ts +163 -10
- package/src/theme/defaults/fonts.theme.spec.tsx +139 -0
- package/src/theme/defaults/fonts.ts +214 -50
- package/src/theme/defaults/index.ts +1 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +0 -11
- package/src/theme/focusState.spec.ts +119 -1
- package/src/theme/focusState.ts +32 -7
- package/src/theme/fontCategoryMetrics.ts +57 -0
- package/src/theme/fontCategoryStacks.ts +37 -0
- package/src/theme/fontKnobStyles.spec.ts +15 -2
- package/src/theme/glyphPaint.spec.ts +26 -0
- package/src/theme/glyphPaint.ts +46 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/knobs.ts +18 -2
- package/src/theme/layoutTokens.spec.ts +3 -1
- package/src/theme/layoutTokens.ts +47 -4
- package/src/theme/mpo13Proof.spec.ts +3 -3
- package/src/theme/mpo19Proof.spec.ts +285 -0
- package/src/theme/presets.ts +1 -1
- package/src/theme/radiusClass.spec.ts +394 -0
- package/src/theme/radiusClass.ts +203 -4
- package/src/theme/radiusClassTable.generated.ts +121 -0
- package/src/theme/recipeInputs.ts +223 -36
- package/src/theme/recipes.ts +68 -4
- package/src/theme/resolveKnobs.spec.ts +155 -13
- package/src/theme/resolveKnobs.ts +193 -27
- package/src/theme/sizeLadder.spec.ts +84 -0
- package/src/theme/sizeRecipes.spec.ts +19 -6
- package/src/theme/sizeRecipes.ts +43 -13
- package/src/theme/theme.native.tsx +2 -0
- package/src/theme/theme.provider.spec.ts +9 -0
- package/src/theme/theme.tsx +6 -3
- package/src/theme/tint.spec.tsx +36 -35
- package/src/theme/useResolvedKnobs.ts +6 -3
- package/types/audit/index.d.ts +2 -0
- package/types/audit/index.d.ts.map +1 -1
- package/types/audit/themeMatrix.d.ts +172 -0
- package/types/audit/themeMatrix.d.ts.map +1 -0
- package/types/figma/figmaTokens.d.ts +1 -1
- package/types/figma/figmaTokens.d.ts.map +1 -1
- package/types/font.d.ts.map +1 -1
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -1
- package/types/keyboardFocusRing.d.ts +11 -5
- package/types/keyboardFocusRing.d.ts.map +1 -1
- package/types/theme/FontKnobStyles.d.ts +8 -12
- package/types/theme/FontKnobStyles.d.ts.map +1 -1
- package/types/theme/Tint.d.ts +2 -1
- package/types/theme/Tint.d.ts.map +1 -1
- package/types/theme/animations/css.d.ts +15 -2
- package/types/theme/animations/css.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +59 -1
- package/types/theme/colorRules.d.ts.map +1 -1
- package/types/theme/cornerSmoothing.d.ts +42 -20
- package/types/theme/cornerSmoothing.d.ts.map +1 -1
- package/types/theme/cornerSmoothing.native.d.ts +22 -2
- package/types/theme/cornerSmoothing.native.d.ts.map +1 -1
- package/types/theme/createDefaultThemeConfig.d.ts +21 -3
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/defaults/builderOptions.d.ts.map +1 -1
- package/types/theme/defaults/categoryFonts.d.ts +23 -0
- package/types/theme/defaults/categoryFonts.d.ts.map +1 -0
- package/types/theme/defaults/fonts.d.ts +140 -2
- package/types/theme/defaults/fonts.d.ts.map +1 -1
- package/types/theme/defaults/index.d.ts +1 -0
- package/types/theme/defaults/index.d.ts.map +1 -1
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
- package/types/theme/focusState.d.ts +32 -6
- package/types/theme/focusState.d.ts.map +1 -1
- package/types/theme/fontCategoryMetrics.d.ts +36 -0
- package/types/theme/fontCategoryMetrics.d.ts.map +1 -0
- package/types/theme/fontCategoryStacks.d.ts +24 -0
- package/types/theme/fontCategoryStacks.d.ts.map +1 -0
- package/types/theme/glyphPaint.d.ts +16 -0
- package/types/theme/glyphPaint.d.ts.map +1 -0
- package/types/theme/index.d.ts +4 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +10 -2
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/layoutTokens.d.ts +46 -4
- package/types/theme/layoutTokens.d.ts.map +1 -1
- package/types/theme/radiusClass.d.ts +96 -4
- package/types/theme/radiusClass.d.ts.map +1 -1
- package/types/theme/radiusClassTable.generated.d.ts +146 -0
- package/types/theme/radiusClassTable.generated.d.ts.map +1 -0
- package/types/theme/recipeInputs.d.ts +85 -14
- package/types/theme/recipeInputs.d.ts.map +1 -1
- package/types/theme/recipes.d.ts +78 -4
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +12 -1
- package/types/theme/resolveKnobs.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +27 -14
- package/types/theme/sizeRecipes.d.ts.map +1 -1
- package/types/theme/theme.d.ts.map +1 -1
- package/types/theme/theme.native.d.ts.map +1 -1
- package/types/theme/useResolvedKnobs.d.ts +2 -2
- package/types/theme/useResolvedKnobs.d.ts.map +1 -1
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
aaTextContrastRatio,
|
|
4
|
+
assertContrast,
|
|
5
5
|
contrastRatio,
|
|
6
6
|
findReadableStep,
|
|
7
|
+
measureContrast,
|
|
8
|
+
minContrastRatio,
|
|
7
9
|
normalizeColorScale,
|
|
10
|
+
normalizeToHex,
|
|
11
|
+
relativeLuminance,
|
|
12
|
+
resolveThemeContrast,
|
|
8
13
|
$color,
|
|
9
14
|
semanticGroups,
|
|
10
15
|
} from "./colorRules";
|
|
16
|
+
import { createThemesBuilder } from "./createThemes";
|
|
17
|
+
import { defaultAccentTheme } from "./defaults/accent";
|
|
18
|
+
import { defaultBaseTheme } from "./defaults/base";
|
|
19
|
+
import { defaultBuilderOptions } from "./defaults/builderOptions";
|
|
11
20
|
|
|
12
21
|
describe("normalizeToHex", () => {
|
|
13
22
|
it('normalizes "white" to #ffffff', () => {
|
|
@@ -193,3 +202,298 @@ describe("semanticGroups", () => {
|
|
|
193
202
|
expect(semanticGroups[semanticGroups.length - 1].to).toBe(12);
|
|
194
203
|
});
|
|
195
204
|
});
|
|
205
|
+
|
|
206
|
+
describe("measureContrast", () => {
|
|
207
|
+
it("reports 21:1 for black on white and passes the AA floor", () => {
|
|
208
|
+
const report = measureContrast({ foreground: "#000000", background: "#ffffff" });
|
|
209
|
+
expect(report).toMatchObject({
|
|
210
|
+
foreground: "#000000",
|
|
211
|
+
background: "#ffffff",
|
|
212
|
+
ratio: 21,
|
|
213
|
+
floor: aaTextContrastRatio,
|
|
214
|
+
pass: true,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("defaults the label to 'foreground on background'", () => {
|
|
219
|
+
expect(measureContrast({ foreground: "black", background: "white" }).label).toBe(
|
|
220
|
+
"black on white",
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("keeps a custom label and normalizes named/shorthand colors", () => {
|
|
225
|
+
const report = measureContrast({
|
|
226
|
+
foreground: "black",
|
|
227
|
+
background: "#fff",
|
|
228
|
+
label: "ink on paper",
|
|
229
|
+
});
|
|
230
|
+
expect(report.label).toBe("ink on paper");
|
|
231
|
+
expect(report.foreground).toBe("#000000");
|
|
232
|
+
expect(report.background).toBe("#ffffff");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("fails the MPO-48 dark accent toast pill (1.94:1)", () => {
|
|
236
|
+
const report = measureContrast({
|
|
237
|
+
foreground: "#46349d",
|
|
238
|
+
background: "#161519",
|
|
239
|
+
label: "MPO-48 dark accent toast action",
|
|
240
|
+
});
|
|
241
|
+
expect(report.ratio).toBe(1.94);
|
|
242
|
+
expect(report.floor).toBe(aaTextContrastRatio);
|
|
243
|
+
expect(report.pass).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("uses the reported 2-decimal ratio for the verdict", () => {
|
|
247
|
+
// #808080 on white is 3.9489…, reported 3.95 — pass at 3, miss at 4.5.
|
|
248
|
+
const at3 = measureContrast({
|
|
249
|
+
foreground: "#808080",
|
|
250
|
+
background: "#ffffff",
|
|
251
|
+
floor: minContrastRatio,
|
|
252
|
+
});
|
|
253
|
+
const atAa = measureContrast({ foreground: "#808080", background: "#ffffff" });
|
|
254
|
+
expect(at3.ratio).toBe(3.95);
|
|
255
|
+
expect(at3.pass).toBe(true);
|
|
256
|
+
expect(atAa.ratio).toBe(3.95);
|
|
257
|
+
expect(atAa.pass).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("throws on an unparseable foreground — never a skip", () => {
|
|
261
|
+
expect(() => measureContrast({ foreground: "potato", background: "#ffffff" })).toThrow(
|
|
262
|
+
/foreground is not an opaque color: potato/,
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("throws on an alpha background — never a skip", () => {
|
|
267
|
+
expect(() =>
|
|
268
|
+
measureContrast({ foreground: "#000000", background: "hsla(0, 0%, 100%, 0.2)" }),
|
|
269
|
+
).toThrow(/background is not an opaque color/);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("assertContrast", () => {
|
|
274
|
+
it("returns the reports when every pair clears its floor", () => {
|
|
275
|
+
const reports = assertContrast({ foreground: "black", background: "white" });
|
|
276
|
+
expect(reports).toHaveLength(1);
|
|
277
|
+
expect(reports[0].pass).toBe(true);
|
|
278
|
+
expect(reports[0].ratio).toBe(21);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("throws ONE error naming every miss, not the first", () => {
|
|
282
|
+
expect(() =>
|
|
283
|
+
assertContrast([
|
|
284
|
+
{ foreground: "black", background: "white", label: "ok" },
|
|
285
|
+
{ foreground: "#46349d", background: "#161519", label: "toast pill" },
|
|
286
|
+
{ foreground: "#808080", background: "#ffffff", label: "gray" },
|
|
287
|
+
]),
|
|
288
|
+
).toThrow(
|
|
289
|
+
/contrast floor missed \(2 of 3 pairs\):[\s\S]*toast pill: #46349d on #161519 = 1.94:1[\s\S]*gray: #808080 on #ffffff = 3.95:1/,
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("resolveThemeContrast", () => {
|
|
295
|
+
it("addresses a pair by theme name and keys", () => {
|
|
296
|
+
expect(
|
|
297
|
+
resolveThemeContrast(
|
|
298
|
+
{ light: { color: "#242226", background: "#ffffff" } },
|
|
299
|
+
{ theme: "light", foreground: "color", background: "background" },
|
|
300
|
+
),
|
|
301
|
+
).toEqual({
|
|
302
|
+
foreground: "#242226",
|
|
303
|
+
background: "#ffffff",
|
|
304
|
+
floor: undefined,
|
|
305
|
+
label: "light: color on background",
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("carries an optional floor through to the pair", () => {
|
|
310
|
+
expect(
|
|
311
|
+
resolveThemeContrast(
|
|
312
|
+
{ light: { color: "#fff", background: "#000" } },
|
|
313
|
+
{ theme: "light", foreground: "color", background: "background", floor: minContrastRatio },
|
|
314
|
+
).floor,
|
|
315
|
+
).toBe(minContrastRatio);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("throws on a missing theme rather than skipping", () => {
|
|
319
|
+
expect(() =>
|
|
320
|
+
resolveThemeContrast({}, { theme: "nope", foreground: "color", background: "background" }),
|
|
321
|
+
).toThrow("no such theme: nope");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("throws on a missing key rather than skipping", () => {
|
|
325
|
+
expect(() =>
|
|
326
|
+
resolveThemeContrast(
|
|
327
|
+
{ light: { color: "#000" } },
|
|
328
|
+
{ theme: "light", foreground: "color", background: "background" },
|
|
329
|
+
),
|
|
330
|
+
).toThrow("light has no background");
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("contrast floor on built themes (DG-A11Y-01 / DG-COL-01)", () => {
|
|
335
|
+
const themes = createThemesBuilder(
|
|
336
|
+
defaultBaseTheme,
|
|
337
|
+
defaultAccentTheme,
|
|
338
|
+
defaultBuilderOptions,
|
|
339
|
+
).themes() as Record<string, Record<string, string>>;
|
|
340
|
+
|
|
341
|
+
it("base text tiers clear AA on page surfaces in both schemes", () => {
|
|
342
|
+
const pairs = [];
|
|
343
|
+
for (const scheme of ["light", "dark"] as const) {
|
|
344
|
+
for (const foreground of ["color", "color11", "color12"]) {
|
|
345
|
+
for (const background of ["background", "color1", "color2"]) {
|
|
346
|
+
pairs.push(resolveThemeContrast(themes, { theme: scheme, foreground, background }));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const reports = assertContrast(pairs);
|
|
351
|
+
expect(reports.every((report) => report.pass)).toBe(true);
|
|
352
|
+
expect(reports).toHaveLength(18);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("accent Button color-on-background clears AA in both schemes", () => {
|
|
356
|
+
const reports = assertContrast(
|
|
357
|
+
(["light_accent_Button", "dark_accent_Button"] as const).map((theme) =>
|
|
358
|
+
resolveThemeContrast(themes, { theme, foreground: "color", background: "background" }),
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
expect(reports.map((report) => report.ratio)).toEqual([7.31, 7.61]);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("a colour pair below the floor FAILS", () => {
|
|
365
|
+
// MPO-48 measured the dark accent toast action pill on the page ground.
|
|
366
|
+
// Built: dark_accent.backgroundHover is #46349d; dark.background is #161519.
|
|
367
|
+
expect(() =>
|
|
368
|
+
assertContrast({
|
|
369
|
+
foreground: themes.dark_accent.backgroundHover,
|
|
370
|
+
background: themes.dark.background,
|
|
371
|
+
label: "MPO-48 dark accent toast action",
|
|
372
|
+
}),
|
|
373
|
+
).toThrow(/MPO-48 dark accent toast action: #46349d on #161519 = 1.94:1/);
|
|
374
|
+
|
|
375
|
+
// MPO-40: outlined warning $color11 on the warning page surface is 4.44:1.
|
|
376
|
+
expect(() =>
|
|
377
|
+
assertContrast(
|
|
378
|
+
resolveThemeContrast(themes, {
|
|
379
|
+
theme: "light_warning",
|
|
380
|
+
foreground: "color11",
|
|
381
|
+
background: "color1",
|
|
382
|
+
}),
|
|
383
|
+
),
|
|
384
|
+
).toThrow(/light_warning: color11 on color1: #9e6c00 on #fcfcf7 = 4.44:1/);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("current light_accent color-on-background clears the 3:1 non-text floor", () => {
|
|
388
|
+
// Parent accent is 3.96:1 — under AA, over the large-text/UI floor. The
|
|
389
|
+
// Button pair (asserted above) is the AA text contract; this pair is the
|
|
390
|
+
// trip-wire's control so the old pale-lavender step is the thing that fails.
|
|
391
|
+
const reports = assertContrast(
|
|
392
|
+
resolveThemeContrast(themes, {
|
|
393
|
+
theme: "light_accent",
|
|
394
|
+
foreground: "color",
|
|
395
|
+
background: "background",
|
|
396
|
+
floor: minContrastRatio,
|
|
397
|
+
}),
|
|
398
|
+
);
|
|
399
|
+
expect(reports[0].ratio).toBe(3.96);
|
|
400
|
+
expect(reports[0].pass).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("re-introducing the old accent lightPalette[0] fails the test", () => {
|
|
404
|
+
// The pale-lavender step that shipped a ~1.17:1 CTA. Swapping only [0]
|
|
405
|
+
// leaves Button (palette[3]) alone and drops light_accent.background.
|
|
406
|
+
const broken = createThemesBuilder(
|
|
407
|
+
defaultBaseTheme,
|
|
408
|
+
{
|
|
409
|
+
...defaultAccentTheme,
|
|
410
|
+
lightPalette: ["hsla(250, 50%, 95%, 1)", ...defaultAccentTheme.lightPalette.slice(1)],
|
|
411
|
+
},
|
|
412
|
+
defaultBuilderOptions,
|
|
413
|
+
).themes() as Record<string, Record<string, string>>;
|
|
414
|
+
|
|
415
|
+
expect(() =>
|
|
416
|
+
assertContrast(
|
|
417
|
+
resolveThemeContrast(broken, {
|
|
418
|
+
theme: "light_accent",
|
|
419
|
+
foreground: "color",
|
|
420
|
+
background: "background",
|
|
421
|
+
floor: minContrastRatio,
|
|
422
|
+
}),
|
|
423
|
+
),
|
|
424
|
+
).toThrow(/light_accent: color on background: #f9f8fc on #eeecf9 = 1.1:1/);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe("intent color11 on the base page surfaces (MPO-40)", () => {
|
|
429
|
+
const themes = createThemesBuilder(
|
|
430
|
+
defaultBaseTheme,
|
|
431
|
+
defaultAccentTheme,
|
|
432
|
+
defaultBuilderOptions,
|
|
433
|
+
).themes() as Record<string, Record<string, string>>;
|
|
434
|
+
|
|
435
|
+
const schemes = ["light", "dark"] as const;
|
|
436
|
+
const intents = ["error", "warning", "success"] as const;
|
|
437
|
+
// The surfaces an outlined control's TRANSPARENT frame can sit on. The
|
|
438
|
+
// intent sub-theme supplies the ink; the page behind it supplies the
|
|
439
|
+
// ground, so the pair is cross-theme and `resolveThemeContrast` (one
|
|
440
|
+
// theme) cannot address it.
|
|
441
|
+
const surfaces = ["background", "color1", "color2"] as const;
|
|
442
|
+
|
|
443
|
+
const intentInkPairs = schemes.flatMap((scheme) =>
|
|
444
|
+
intents.flatMap((intent) =>
|
|
445
|
+
surfaces.map((surface) => ({
|
|
446
|
+
foreground: themes[`${scheme}_${intent}`].color11,
|
|
447
|
+
background: themes[scheme][surface],
|
|
448
|
+
label: `${scheme}_${intent}: color11 on ${scheme}.${surface}`,
|
|
449
|
+
})),
|
|
450
|
+
),
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* MPO-40 measured the outlined intent label on the gallery harness ground
|
|
455
|
+
* and recorded warning 4.34:1 and success 4.48:1. That ground is the BASE
|
|
456
|
+
* theme's `color2` (#f9f9fa) — NOT `background`/`color1`, which are both
|
|
457
|
+
* #ffffff and where the same two pairs clear the floor at 4.57 and 4.72.
|
|
458
|
+
* The miss is therefore surface-specific: an outlined warning or success
|
|
459
|
+
* Button only goes unreadable once it sits on a color2 surface (a card, a
|
|
460
|
+
* striped row, the gallery ground). Measuring against the plain page is
|
|
461
|
+
* exactly how a check reports a pass on a control that is failing.
|
|
462
|
+
*
|
|
463
|
+
* The two misses are PINNED to their measured ratios rather than skipped.
|
|
464
|
+
* Raise the $color11 step and the recorded ratio stops matching, which
|
|
465
|
+
* fails here and forces the entry out of this table — an exemption that
|
|
466
|
+
* retires itself instead of one that rots.
|
|
467
|
+
*/
|
|
468
|
+
const pinnedMisses: Record<string, number> = {
|
|
469
|
+
"light_warning: color11 on light.color2": 4.34,
|
|
470
|
+
"light_success: color11 on light.color2": 4.48,
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
it("sweeps every intent x base surface x scheme — 18 pairs, none dropped", () => {
|
|
474
|
+
expect(intentInkPairs).toHaveLength(18);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("clears AA everywhere except the two pinned MPO-40 misses", () => {
|
|
478
|
+
const misses = intentInkPairs
|
|
479
|
+
.map(measureContrast)
|
|
480
|
+
.filter((report) => !report.pass)
|
|
481
|
+
.map((report) => [report.label, report.ratio] as const);
|
|
482
|
+
expect(Object.fromEntries(misses)).toEqual(pinnedMisses);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("the same warning/success ink clears the floor on background and color1", () => {
|
|
486
|
+
// Locks the surface-specificity so a future fix cannot be signed off by
|
|
487
|
+
// re-measuring against the white page.
|
|
488
|
+
const reports = assertContrast(
|
|
489
|
+
(["warning", "success"] as const).flatMap((intent) =>
|
|
490
|
+
(["background", "color1"] as const).map((surface) => ({
|
|
491
|
+
foreground: themes[`light_${intent}`].color11,
|
|
492
|
+
background: themes.light[surface],
|
|
493
|
+
label: `light_${intent}: color11 on light.${surface}`,
|
|
494
|
+
})),
|
|
495
|
+
),
|
|
496
|
+
);
|
|
497
|
+
expect(reports.map((report) => report.ratio)).toEqual([4.57, 4.57, 4.72, 4.72]);
|
|
498
|
+
});
|
|
499
|
+
});
|
package/src/theme/colorRules.ts
CHANGED
|
@@ -77,6 +77,124 @@ export const minContrastRatio = 3;
|
|
|
77
77
|
/** WCAG AA contrast floor for normal-size body/label text. */
|
|
78
78
|
export const aaTextContrastRatio = 4.5;
|
|
79
79
|
|
|
80
|
+
// ── Contrast floor assertion (DG-A11Y-01 / DG-COL-01) ─────────
|
|
81
|
+
//
|
|
82
|
+
// `aaTextContrastRatio` declared a floor that no code enforced: MPO-40's
|
|
83
|
+
// outlined warning $color11 (#9e6c00, 4.34:1) and success (#218358, 4.48:1)
|
|
84
|
+
// and MPO-48's dark accent toast action pill (#46349d on #161519, 1.94:1)
|
|
85
|
+
// all shipped through review, tests and a design pass. This is THE one
|
|
86
|
+
// enforcement — theme specs here, shc's contrast.spec.ts and MPO-18's
|
|
87
|
+
// theme matrix all call it instead of re-deriving the arithmetic.
|
|
88
|
+
|
|
89
|
+
/** A foreground/background pair held to a WCAG contrast floor. */
|
|
90
|
+
export interface ContrastPair {
|
|
91
|
+
/** Text/glyph color, in any format `normalizeToHex` accepts. */
|
|
92
|
+
foreground: string;
|
|
93
|
+
/** The fill the foreground sits on. */
|
|
94
|
+
background: string;
|
|
95
|
+
/** Floor the pair owes. Defaults to `aaTextContrastRatio` (4.5). */
|
|
96
|
+
floor?: number;
|
|
97
|
+
/** Names the pair in reports, e.g. "light_accent: color on background". */
|
|
98
|
+
label?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Measured outcome for one {@link ContrastPair}. */
|
|
102
|
+
export interface ContrastReport {
|
|
103
|
+
label: string;
|
|
104
|
+
/** The colors actually measured, as normalized 6-digit hex. */
|
|
105
|
+
foreground: string;
|
|
106
|
+
background: string;
|
|
107
|
+
/** WCAG 2.x ratio rounded to 2 decimals — the number a report reads. */
|
|
108
|
+
ratio: number;
|
|
109
|
+
floor: number;
|
|
110
|
+
pass: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Measure one pair against its floor. Throws when a color cannot be reduced
|
|
115
|
+
* to an opaque hex: an alpha color has no contrast of its own and the house
|
|
116
|
+
* ramps are fully opaque, so an unmeasurable pair is a genuine failure,
|
|
117
|
+
* never a skip. The verdict compares the REPORTED (2-decimal) ratio, so
|
|
118
|
+
* pass/fail always matches the printed number.
|
|
119
|
+
*/
|
|
120
|
+
export function measureContrast(pair: ContrastPair): ContrastReport {
|
|
121
|
+
const floor = pair.floor ?? aaTextContrastRatio;
|
|
122
|
+
const label = pair.label ?? `${pair.foreground} on ${pair.background}`;
|
|
123
|
+
const foreground = normalizeToHex(pair.foreground);
|
|
124
|
+
if (!foreground) {
|
|
125
|
+
throw new Error(`${label}: foreground is not an opaque color: ${pair.foreground}`);
|
|
126
|
+
}
|
|
127
|
+
const background = normalizeToHex(pair.background);
|
|
128
|
+
if (!background) {
|
|
129
|
+
throw new Error(`${label}: background is not an opaque color: ${pair.background}`);
|
|
130
|
+
}
|
|
131
|
+
const ratio =
|
|
132
|
+
Math.round(contrastRatio(relativeLuminance(foreground), relativeLuminance(background)) * 100) /
|
|
133
|
+
100;
|
|
134
|
+
return { label, foreground, background, ratio, floor, pass: ratio >= floor };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The contrast floor assertion. Measures every pair and throws ONE error
|
|
139
|
+
* naming every pair under its floor — a sweep reports the whole damage, not
|
|
140
|
+
* the first miss. Returns the reports when all pass, so callers can attach
|
|
141
|
+
* the measured numbers as proof.
|
|
142
|
+
*/
|
|
143
|
+
export function assertContrast(pairs: ContrastPair | ContrastPair[]): ContrastReport[] {
|
|
144
|
+
const reports = (Array.isArray(pairs) ? pairs : [pairs]).map(measureContrast);
|
|
145
|
+
const misses = reports.filter((report) => !report.pass);
|
|
146
|
+
if (misses.length > 0) {
|
|
147
|
+
const lines = misses.map(
|
|
148
|
+
(report) =>
|
|
149
|
+
`${report.label}: ${report.foreground} on ${report.background} = ` +
|
|
150
|
+
`${report.ratio}:1 — under the ${report.floor}:1 floor`,
|
|
151
|
+
);
|
|
152
|
+
throw new Error(
|
|
153
|
+
`contrast floor missed (${misses.length} of ${reports.length} pairs):\n${lines.join("\n")}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return reports;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Addresses a {@link ContrastPair} inside built themes by name and keys. */
|
|
160
|
+
export interface ThemeContrastPair {
|
|
161
|
+
/** Theme name, e.g. "light_accent" or "dark". */
|
|
162
|
+
theme: string;
|
|
163
|
+
/** Theme key carrying the text/glyph color, e.g. "color" or "color11". */
|
|
164
|
+
foreground: string;
|
|
165
|
+
/** Theme key carrying the fill, e.g. "background" or "color2". */
|
|
166
|
+
background: string;
|
|
167
|
+
/** Floor the pair owes. Defaults to `aaTextContrastRatio` (4.5). */
|
|
168
|
+
floor?: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve a pair out of BUILT themes — the record `createThemesBuilder`
|
|
173
|
+
* returns, the same objects handed to createTamagui. Asserting on built
|
|
174
|
+
* themes rather than source ramps is the point: the pairing that fails is
|
|
175
|
+
* produced layers away by the theme-builder template, so reading a palette
|
|
176
|
+
* file tells you nothing about what lands under a label. Addressed by theme
|
|
177
|
+
* name + key so a failure names the thing a designer would change; a missing
|
|
178
|
+
* theme or key throws rather than skips.
|
|
179
|
+
*/
|
|
180
|
+
export function resolveThemeContrast(
|
|
181
|
+
themes: Record<string, Record<string, string> | undefined>,
|
|
182
|
+
pair: ThemeContrastPair,
|
|
183
|
+
): ContrastPair {
|
|
184
|
+
const theme = themes[pair.theme];
|
|
185
|
+
if (!theme) throw new Error(`no such theme: ${pair.theme}`);
|
|
186
|
+
const foreground = theme[pair.foreground];
|
|
187
|
+
if (!foreground) throw new Error(`${pair.theme} has no ${pair.foreground}`);
|
|
188
|
+
const background = theme[pair.background];
|
|
189
|
+
if (!background) throw new Error(`${pair.theme} has no ${pair.background}`);
|
|
190
|
+
return {
|
|
191
|
+
foreground,
|
|
192
|
+
background,
|
|
193
|
+
floor: pair.floor,
|
|
194
|
+
label: `${pair.theme}: ${pair.foreground} on ${pair.background}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
80
198
|
/**
|
|
81
199
|
* Axiom 15 OPTICS — pick whichever candidate foreground carries more WCAG
|
|
82
200
|
* contrast on a given `fill`. The canonical "readable foreground on a fill"
|
|
@@ -2,15 +2,52 @@
|
|
|
2
2
|
* OPTICS corner smoothing (Axiom 15) — native twin of `cornerSmoothing.ts`.
|
|
3
3
|
*
|
|
4
4
|
* React Native has no `corner-shape`; native resolves `cornerSmoothing` to
|
|
5
|
-
* `round`
|
|
6
|
-
*
|
|
5
|
+
* `round` until a SquircleView adoption lands (phase 2). Exports stay
|
|
6
|
+
* shape-compatible so shared code and tests can import either file.
|
|
7
7
|
*/
|
|
8
|
+
import type { CornerSmoothing } from "./knobs";
|
|
8
9
|
|
|
9
10
|
/** Class attached on web only — never emitted into native fragments. */
|
|
10
11
|
export const cornerSmoothClassName = "mp-corner-smooth";
|
|
11
12
|
|
|
13
|
+
export const CORNER_SMOOTHING_STYLE_TAG_ID = "mp-corner-smoothing-styles";
|
|
14
|
+
export const CORNER_SMOOTHING_ROOT_ATTR = "data-mp-corner-smoothing";
|
|
15
|
+
export const CORNER_SHAPE_CSS_VAR = "--mp-corner-shape";
|
|
16
|
+
export const CORNER_SHAPE_ROUND = "round";
|
|
17
|
+
export const CORNER_SHAPE_SMOOTH = "squircle";
|
|
18
|
+
export const CORNER_SHAPE_ROUND_COMPUTED = "superellipse(1)";
|
|
19
|
+
export const CORNER_SHAPE_SMOOTH_COMPUTED = "superellipse(2)";
|
|
20
|
+
|
|
12
21
|
/** Web-only stylesheet — empty on native. */
|
|
13
22
|
export const cornerSmoothingCss = "";
|
|
14
23
|
|
|
24
|
+
/** Native has no CSS.supports corner-shape — measured no-op, not assumed. */
|
|
25
|
+
export function cssSupportsCornerShape(_supports?: {
|
|
26
|
+
supports?(property: string, value: string): boolean;
|
|
27
|
+
}): boolean {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readComputedCornerShape(
|
|
32
|
+
_style?: Pick<CSSStyleDeclaration, "getPropertyValue"> & { cornerShape?: string },
|
|
33
|
+
): string {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isSmoothComputedCornerShape(_value: string): boolean {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isRoundComputedCornerShape(_value: string): boolean {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applyRootCornerSmoothing(_stop?: CornerSmoothing): void {}
|
|
46
|
+
|
|
15
47
|
/** Web-only stylesheet mount — no-op on native. */
|
|
16
48
|
export function ensureCornerSmoothingStyles(): void {}
|
|
49
|
+
|
|
50
|
+
/** Native no-op. Web mounts the squircle stylesheet from ThemeProvider. */
|
|
51
|
+
export function CornerSmoothingStyles(): null {
|
|
52
|
+
return null;
|
|
53
|
+
}
|