@multiplatform.one/theme 7.4.0 → 7.5.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.
Files changed (106) hide show
  1. package/README.md +2 -1
  2. package/package.json +5 -5
  3. package/src/figma/figmaTokens.spec.ts +25 -21
  4. package/src/figma/figmaTokens.ts +2 -2
  5. package/src/font.ts +6 -19
  6. package/src/keyboardFocusRing.ts +12 -6
  7. package/src/theme/FontKnobStyles.tsx +5 -3
  8. package/src/theme/Tint.tsx +3 -2
  9. package/src/theme/animations/css.ts +14 -1
  10. package/src/theme/colorRules.spec.ts +233 -2
  11. package/src/theme/colorRules.ts +118 -0
  12. package/src/theme/cornerSmoothing.native.ts +39 -2
  13. package/src/theme/cornerSmoothing.spec.ts +213 -3
  14. package/src/theme/cornerSmoothing.ts +132 -10
  15. package/src/theme/createDefaultThemeConfig.spec.ts +107 -1
  16. package/src/theme/createDefaultThemeConfig.ts +27 -8
  17. package/src/theme/defaults/builderOptions.ts +23 -10
  18. package/src/theme/defaults/categoryFonts.ts +21 -0
  19. package/src/theme/defaults/fonts.spec.ts +93 -10
  20. package/src/theme/defaults/fonts.theme.spec.tsx +128 -0
  21. package/src/theme/defaults/fonts.ts +155 -27
  22. package/src/theme/defaults/index.ts +1 -0
  23. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +0 -11
  24. package/src/theme/focusState.spec.ts +110 -1
  25. package/src/theme/focusState.ts +32 -7
  26. package/src/theme/fontCategoryStacks.ts +24 -0
  27. package/src/theme/fontKnobStyles.spec.ts +15 -2
  28. package/src/theme/glyphPaint.spec.ts +26 -0
  29. package/src/theme/glyphPaint.ts +46 -0
  30. package/src/theme/index.ts +3 -0
  31. package/src/theme/knobs.ts +18 -2
  32. package/src/theme/layoutTokens.spec.ts +3 -1
  33. package/src/theme/layoutTokens.ts +47 -4
  34. package/src/theme/mpo13Proof.spec.ts +3 -3
  35. package/src/theme/mpo19Proof.spec.ts +285 -0
  36. package/src/theme/presets.ts +1 -1
  37. package/src/theme/radiusClass.spec.ts +394 -0
  38. package/src/theme/radiusClass.ts +203 -4
  39. package/src/theme/radiusClassTable.generated.ts +121 -0
  40. package/src/theme/recipeInputs.ts +223 -36
  41. package/src/theme/recipes.ts +68 -4
  42. package/src/theme/resolveKnobs.spec.ts +139 -11
  43. package/src/theme/resolveKnobs.ts +190 -25
  44. package/src/theme/sizeLadder.spec.ts +84 -0
  45. package/src/theme/sizeRecipes.spec.ts +19 -6
  46. package/src/theme/sizeRecipes.ts +43 -13
  47. package/src/theme/theme.native.tsx +2 -0
  48. package/src/theme/theme.provider.spec.ts +9 -0
  49. package/src/theme/theme.tsx +6 -3
  50. package/src/theme/tint.spec.tsx +36 -35
  51. package/src/theme/useResolvedKnobs.ts +6 -3
  52. package/types/figma/figmaTokens.d.ts +1 -1
  53. package/types/figma/figmaTokens.d.ts.map +1 -1
  54. package/types/font.d.ts.map +1 -1
  55. package/types/keyboardFocusRing.d.ts +11 -5
  56. package/types/keyboardFocusRing.d.ts.map +1 -1
  57. package/types/theme/FontKnobStyles.d.ts +2 -1
  58. package/types/theme/FontKnobStyles.d.ts.map +1 -1
  59. package/types/theme/Tint.d.ts +2 -1
  60. package/types/theme/Tint.d.ts.map +1 -1
  61. package/types/theme/animations/css.d.ts +15 -2
  62. package/types/theme/animations/css.d.ts.map +1 -1
  63. package/types/theme/colorRules.d.ts +59 -1
  64. package/types/theme/colorRules.d.ts.map +1 -1
  65. package/types/theme/cornerSmoothing.d.ts +42 -20
  66. package/types/theme/cornerSmoothing.d.ts.map +1 -1
  67. package/types/theme/cornerSmoothing.native.d.ts +22 -2
  68. package/types/theme/cornerSmoothing.native.d.ts.map +1 -1
  69. package/types/theme/createDefaultThemeConfig.d.ts +21 -3
  70. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  71. package/types/theme/defaults/builderOptions.d.ts.map +1 -1
  72. package/types/theme/defaults/categoryFonts.d.ts +19 -0
  73. package/types/theme/defaults/categoryFonts.d.ts.map +1 -0
  74. package/types/theme/defaults/fonts.d.ts +135 -2
  75. package/types/theme/defaults/fonts.d.ts.map +1 -1
  76. package/types/theme/defaults/index.d.ts +1 -0
  77. package/types/theme/defaults/index.d.ts.map +1 -1
  78. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  79. package/types/theme/focusState.d.ts +32 -6
  80. package/types/theme/focusState.d.ts.map +1 -1
  81. package/types/theme/fontCategoryStacks.d.ts +11 -0
  82. package/types/theme/fontCategoryStacks.d.ts.map +1 -0
  83. package/types/theme/glyphPaint.d.ts +16 -0
  84. package/types/theme/glyphPaint.d.ts.map +1 -0
  85. package/types/theme/index.d.ts +3 -0
  86. package/types/theme/index.d.ts.map +1 -1
  87. package/types/theme/knobs.d.ts +10 -2
  88. package/types/theme/knobs.d.ts.map +1 -1
  89. package/types/theme/layoutTokens.d.ts +46 -4
  90. package/types/theme/layoutTokens.d.ts.map +1 -1
  91. package/types/theme/radiusClass.d.ts +96 -4
  92. package/types/theme/radiusClass.d.ts.map +1 -1
  93. package/types/theme/radiusClassTable.generated.d.ts +146 -0
  94. package/types/theme/radiusClassTable.generated.d.ts.map +1 -0
  95. package/types/theme/recipeInputs.d.ts +85 -14
  96. package/types/theme/recipeInputs.d.ts.map +1 -1
  97. package/types/theme/recipes.d.ts +78 -4
  98. package/types/theme/recipes.d.ts.map +1 -1
  99. package/types/theme/resolveKnobs.d.ts +12 -1
  100. package/types/theme/resolveKnobs.d.ts.map +1 -1
  101. package/types/theme/sizeRecipes.d.ts +27 -14
  102. package/types/theme/sizeRecipes.d.ts.map +1 -1
  103. package/types/theme/theme.d.ts.map +1 -1
  104. package/types/theme/theme.native.d.ts.map +1 -1
  105. package/types/theme/useResolvedKnobs.d.ts +2 -2
  106. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
package/README.md CHANGED
@@ -22,7 +22,8 @@ Requires `react` and `tamagui` as peers.
22
22
  including intent + component overrides
23
23
  - **Recipes** — control/text/elevation state recipes (hover, press, focus-visible)
24
24
  - **Optics** — the `cornerSmoothing` knob (`round` | `smooth` squircle corners:
25
- web progressive enhancement via CSS `corner-shape`, native stays round),
25
+ web progressive enhancement via CSS `corner-shape: squircle`, computed
26
+ `superellipse(2)` on Chromium 152; native stays round),
26
27
  `hairline` separator treatments (0.5 device-pixel rules with a 1px low-DPI
27
28
  fallback), and tracking-by-size in the default font faces
28
29
  - **Chart palette** — `useChartPalette()`: identity-led series colors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "7.4.0",
3
+ "version": "7.5.0",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -53,8 +53,8 @@
53
53
  "@tamagui/toast": "2.7.6",
54
54
  "@tamagui/web": "2.7.6",
55
55
  "react-cookie": "^8.1.2",
56
- "@multiplatform.one/platform": "7.4.0",
57
- "@multiplatform.one/store": "7.4.0"
56
+ "@multiplatform.one/platform": "7.5.0",
57
+ "@multiplatform.one/store": "7.5.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@tamagui/animations-css": "2.7.6",
@@ -69,8 +69,8 @@
69
69
  "tamagui": "2.7.6",
70
70
  "typescript": "~5.9.3",
71
71
  "vitest": "^4.1.5",
72
- "@multiplatform.one/config": "7.4.0",
73
- "@multiplatform.one/test-utils": "7.4.0"
72
+ "@multiplatform.one/test-utils": "7.5.0",
73
+ "@multiplatform.one/config": "7.5.0"
74
74
  },
75
75
  "peerDependencies": {
76
76
  "@tamagui/animations-css": "^2.0.0-rc",
@@ -1,5 +1,5 @@
1
1
  import * as Colors from "@tamagui/colors";
2
- import { tokens } from "@tamagui/themes";
2
+ import { defaultConfig } from "@tamagui/config/v5";
3
3
  import { describe, expect, it } from "vitest";
4
4
  import { normalizeToHex } from "../theme/colorRules";
5
5
  import { defaultAccentTheme } from "../theme/defaults/accent";
@@ -89,13 +89,13 @@ describe("buildFigmaTokens", () => {
89
89
 
90
90
  describe("semantic", () => {
91
91
  it("derives the current interaction-ramp aliases (post-knobs rebuild)", () => {
92
- // These pin the drift the May 2026 spec missed: borderColor moved from
93
- // color7 to color5, textMuted from color10 to color11.
94
- expect(result.semantic.borderColor?.alias).toBe("color5");
95
- expect(result.semantic.borderColorHover?.alias).toBe("color6");
92
+ // MPO-19 X4: the seven pinned ramp steps were deleted from
93
+ // `defaultBuilderOptions.getTheme`, so these aliases are the theme
94
+ // BUILDER's derivation again rather than mpo's re-anchoring. That is
95
+ // the correction landing, and what this pins is that it landed —
96
+ // borderColor back off color5, the background states no longer forced.
97
+ expect(result.semantic.borderColor?.alias).toBe("color4");
96
98
  expect(result.semantic.textMuted?.alias).toBe("color11");
97
- expect(result.semantic.backgroundPress?.alias).toBe("color5");
98
- expect(result.semantic.backgroundActive?.alias).toBe("color4");
99
99
  expect(result.semantic.placeholderColor?.alias).toBe("color10");
100
100
  expect(result.semantic.surfaceMuted?.alias).toBe("color10");
101
101
  expect(result.semantic.borderStrong?.alias).toBe("color7");
@@ -135,13 +135,16 @@ describe("buildFigmaTokens", () => {
135
135
  });
136
136
 
137
137
  describe("sizing", () => {
138
- it("mirrors @tamagui/themes tokens with figma-safe names", () => {
139
- const sizeTrue = (tokens.size as Record<string, { val: number }>).true.val;
140
- expect(result.sizing["size-true"]).toBe(sizeTrue);
141
- const radius4 = (tokens.radius as Record<string, { val: number }>)["4"].val;
142
- expect(result.sizing["radius-4"]).toBe(radius4);
138
+ it("mirrors the v5 defaultConfig tokens with figma-safe names", () => {
139
+ // Read against the ERA the package pins (MPO-19 X13), not against a
140
+ // transcribed table: v5 keys carry the `$`, values are raw numbers.
141
+ const size = defaultConfig.tokens.size as Record<string, number>;
142
+ expect(result.sizing["size-true"]).toBe(size.$true);
143
+ expect(result.sizing["radius-4"]).toBe(
144
+ (defaultConfig.tokens.radius as Record<string, number>)["4"],
145
+ );
143
146
  expect(result.sizing["space-1-5"]).toBe(
144
- (tokens.space as Record<string, { val: number }>)["1.5"].val,
147
+ (defaultConfig.tokens.space as Record<string, number>)["$1.5"],
145
148
  );
146
149
  });
147
150
 
@@ -181,7 +184,7 @@ describe("buildFigmaTokens", () => {
181
184
  const { modes } = result.knobs.elevation;
182
185
  expect(modes.none).toEqual({ sizeToken: null, px: null });
183
186
  expect(modes.small.sizeToken).toBe("$1");
184
- expect(modes.small.px).toBe((tokens.size as Record<string, { val: number }>)["1"].val);
187
+ expect(modes.small.px).toBe((defaultConfig.tokens.size as Record<string, number>).$1);
185
188
  expect(modes.medium.sizeToken).toBe("$2");
186
189
  expect(modes.large.sizeToken).toBe("$4");
187
190
  });
@@ -216,17 +219,18 @@ describe("buildFigmaTokens", () => {
216
219
 
217
220
  describe("typography (closes deferred task 10.0)", () => {
218
221
  it("exports heading/body size + lineHeight tables from the built default fonts", () => {
219
- // Heading scale is 1.4x Inter base: H1 $10 = round(46 * 1.4) = 64.
220
- expect(result.typography.heading.size["10"]).toBe(64);
221
- expect(result.typography.body.size.true).toBe(14);
222
- // lineHeight = size + 10 (defaults/fonts.ts).
223
- expect(result.typography.body.lineHeight.true).toBe(24);
224
- expect(result.typography.heading.lineHeight["10"]).toBe(74);
222
+ // MPO-19 X8/X9: heading rides the body scale now, so H1 $10 is 40 at
223
+ // weight 800 rather than 64 at 400, and body $true is v5's 15 with the
224
+ // tapering leading instead of v4's 14 with `size + 10`.
225
+ expect(result.typography.heading.size["10"]).toBe(40);
226
+ expect(result.typography.body.size.true).toBe(15);
227
+ expect(result.typography.body.lineHeight.true).toBe(23);
228
+ expect(result.typography.heading.lineHeight["10"]).toBe(50);
225
229
  });
226
230
 
227
231
  it("exports Inter dynamic tracking per size", () => {
228
232
  const body = result.typography.body;
229
- expect(body.letterSpacing.true).toBe(interTrackingPx(14));
233
+ expect(body.letterSpacing.true).toBe(interTrackingPx(15));
230
234
  expect(Object.keys(body.letterSpacing).length).toBeGreaterThanOrEqual(16);
231
235
  });
232
236
 
@@ -93,7 +93,7 @@ export interface FigmaRadiusMode {
93
93
  /** `Sizing` collection variable to alias (e.g. `radius-4`). */
94
94
  figmaName: string;
95
95
  px: number;
96
- /** Outer/nested radius fragment (`knobProps.borderRadiusOuter`). */
96
+ /** Outer/nested radius fragment (`knobProps.borderRadiusNested`). */
97
97
  outerToken: string;
98
98
  outerFigmaName: string;
99
99
  outerPx: number;
@@ -344,7 +344,7 @@ function buildKnobs(): FigmaKnobsExport {
344
344
  for (const mode of RADIUS_KNOB_MODES) {
345
345
  const { knobProps } = resolveKnobs({ ...defaultKnobs, borderRadius: mode });
346
346
  const token = String(knobProps.borderRadius.borderRadius);
347
- const outerToken = String(knobProps.borderRadiusOuter.borderRadius);
347
+ const outerToken = String(knobProps.borderRadiusNested.borderRadius);
348
348
  borderRadius[mode] = {
349
349
  token,
350
350
  figmaName: tokenToFigma(token, "radius"),
package/src/font.ts CHANGED
@@ -1,25 +1,12 @@
1
1
  import type { GenericFont } from "@tamagui/web";
2
2
  import { createFont } from "@tamagui/web";
3
+ import { bodyFontSizes, bodyLineHeightPx } from "./theme/defaults/fonts";
3
4
 
4
5
  const defaults = {
5
- size: {
6
- 1: 11,
7
- 2: 12,
8
- 3: 13,
9
- 4: 14,
10
- 5: 15,
11
- 6: 16,
12
- 7: 18,
13
- 8: 21,
14
- 9: 28,
15
- 10: 42,
16
- 11: 52,
17
- 12: 62,
18
- 13: 72,
19
- 14: 92,
20
- 15: 114,
21
- 16: 124,
22
- } as const,
6
+ // Same size table as Inter body, including `true: 14`. A missing `true`
7
+ // key is what collapsed `$serif`/`$mono`/`$rounded` to -apple-system
8
+ // 14px/normal on the label text node (MPO-44).
9
+ size: bodyFontSizes,
23
10
  // Neutral by default (LC-11): tracking is a per-family function of size,
24
11
  // so a shared constant table is always wrong for someone — the previous
25
12
  // Inter-ish table leaked +3px tracking into mono/script faces at $5/$6
@@ -37,7 +24,7 @@ export function createDefaultFont<A extends GenericFont>(
37
24
  const size = font.size || defaults.size;
38
25
  return createFont({
39
26
  lineHeight: Object.fromEntries(
40
- Object.entries(size).map(([k, v]) => [k, typeof v === "number" ? v * 1.2 + 6 : v]),
27
+ Object.entries(size).map(([k, v]) => [k, typeof v === "number" ? bodyLineHeightPx(v) : v]),
41
28
  ) as typeof size,
42
29
  letterSpacing: defaults.letterSpacing,
43
30
  weight: defaults.weight,
@@ -36,15 +36,21 @@ export function wasKeyboardFocus(): boolean {
36
36
  return lastInputWasKeyboard;
37
37
  }
38
38
 
39
- import { FOCUS_VISIBLE_RING } from "./theme/focusState";
39
+ import { FOCUS_VISIBLE_RING_INSET } from "./theme/focusState";
40
40
 
41
41
  /**
42
- * Standard manual ring props — the SAME geometry as `FOCUS_VISIBLE_RING`
43
- * (2px solid `$outlineColor`, inset −2), spread here so manually-painted
44
- * rings and `focusVisibleStyle` rings can never drift apart.
42
+ * Standard manual ring props — the INSET ring geometry
43
+ * (`FOCUS_VISIBLE_RING_INSET`: 2px solid `$outlineColor`, offset −2), spread
44
+ * here so manually-painted rings can never drift from the theme contract.
45
+ * Manual painting exists for roving-focus / activedescendant composites
46
+ * (ToggleGroup segments inside a clipped Group, menu/listbox rows under a
47
+ * clipped overlay, table rows and cells) — items inside clipping ancestors,
48
+ * which is exactly the LC-71 §4 inset carve-out; an offset-0 band there
49
+ * would be severed by the clip. Standalone controls take the offset-0
50
+ * `FOCUS_VISIBLE_RING` via `focusVisibleStyle` / `ensureFocusVisibleRing()`.
45
51
  */
46
52
  export const keyboardFocusRingProps = {
47
- ...FOCUS_VISIBLE_RING,
53
+ ...FOCUS_VISIBLE_RING_INSET,
48
54
  } as const;
49
55
 
50
56
  /**
@@ -85,7 +91,7 @@ export const compositeFocusRingCss = `/* LC-71: ring on the composite outer box;
85
91
  }
86
92
  .mp-chip-dismiss:focus-visible .mp-chip-dismiss-ring {
87
93
  outline: 2px solid var(--outlineColor, var(--c-outlineColor, CanvasText));
88
- outline-offset: 2px;
94
+ outline-offset: 0;
89
95
  }`;
90
96
 
91
97
  const COMPOSITE_RING_STYLE_ID = "mp-composite-focus-ring";
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import { getConfig } from "@tamagui/web";
29
29
  import { useEffect } from "react";
30
+ import { fontCategoryStacks } from "./fontCategoryStacks";
30
31
  import type { FontCategory } from "./knobs";
31
32
  import { defaultKnobs } from "./knobs";
32
33
  import { usePresetContext } from "./PresetContext";
@@ -90,7 +91,8 @@ export interface FontScopeTables {
90
91
  /**
91
92
  * Resolve the concrete font-family stack registered in the Tamagui config for
92
93
  * a font category. Returns undefined when the category font is not registered
93
- * (the caller then leaves the default family in place).
94
+ * (the caller then uses `fontCategoryStacks` so the knob still writes
95
+ * `--f-family` — live SHC console had the style tag and skipped the family).
94
96
  */
95
97
  export function getConfiguredFontFamily(category: FontCategory): string | undefined {
96
98
  let config: ReturnType<typeof getConfig> | undefined;
@@ -202,13 +204,13 @@ export function buildFontKnobCss(
202
204
  defaultLeadingRule("body", bodyFont),
203
205
  ];
204
206
  if (headingFont !== "sans-serif") {
205
- const family = resolveFamily(headingFont);
207
+ const family = resolveFamily(headingFont) ?? fontCategoryStacks[headingFont];
206
208
  if (family) {
207
209
  rules.push(buildScopeRule("heading", headingFont, family, resolveTables("heading")));
208
210
  }
209
211
  }
210
212
  if (bodyFont !== "sans-serif") {
211
- const family = resolveFamily(bodyFont);
213
+ const family = resolveFamily(bodyFont) ?? fontCategoryStacks[bodyFont];
212
214
  if (family) {
213
215
  rules.push(buildScopeRule("body", bodyFont, family, resolveTables("body")));
214
216
  }
@@ -13,7 +13,8 @@ export interface TintProps extends PropsWithChildren {
13
13
 
14
14
  /**
15
15
  * Depth-based tint component. Each nesting level increments the depth
16
- * and selects a tint from the preset's tints array via modulo.
16
+ * and selects a tint from the preset's tints array via
17
+ * `tints[(depth - 1) % length]` so the first Tint is `tints[0]`.
17
18
  *
18
19
  * Applies a tint sub-theme based on nesting depth.
19
20
  */
@@ -21,7 +22,7 @@ export function Tint({ children, alt = 0, disable, ...props }: TintProps) {
21
22
  const parentDepth = useContext(TintDepthContext);
22
23
  const depth = parentDepth + 1 + alt;
23
24
  const tints = usePresetTints();
24
- const tint = tints.length > 0 ? tints[depth % tints.length] : undefined;
25
+ const tint = tints.length > 0 ? tints[(depth - 1) % tints.length] : undefined;
25
26
 
26
27
  return (
27
28
  <TintDepthContext.Provider value={depth}>
@@ -1,10 +1,23 @@
1
1
  import { createAnimations } from "@tamagui/animations-css";
2
2
 
3
+ /**
4
+ * The CSS driver's named durations.
5
+ *
6
+ * `bouncy`, `lazy`, `slow`, `medium` and `quick` are Tamagui's own names and
7
+ * carry Tamagui's own values — a shared name that means something different
8
+ * here is worse than no name, because every consumer reading `animation="medium"`
9
+ * against the upstream docs gets a different answer than the one it renders.
10
+ * MPO-19 X10: `medium` was `ease-in-out 250ms` against stock `ease-in 300ms`,
11
+ * with no improvement argument written down anywhere.
12
+ *
13
+ * `tooltip`, `snappy` and `gentle` are additive — names Tamagui does not use,
14
+ * so they are ours to set and are not a contradiction.
15
+ */
3
16
  export const animationConfig = {
4
17
  bouncy: "ease-in 200ms",
5
18
  lazy: "ease-in 600ms",
6
19
  slow: "ease-in 500ms",
7
- medium: "ease-in-out 250ms",
20
+ medium: "ease-in 300ms",
8
21
  quick: "ease-in 100ms",
9
22
  tooltip: "ease-in 400ms",
10
23
  snappy: "ease-out 80ms",
@@ -1,13 +1,22 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
- normalizeToHex,
4
- relativeLuminance,
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,225 @@ 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
+ });