@multiplatform.one/theme 7.7.6 → 7.8.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 (47) hide show
  1. package/package.json +5 -5
  2. package/src/audit/constraintAudit.spec.ts +157 -0
  3. package/src/audit/constraintAudit.ts +154 -35
  4. package/src/audit/constraintScope.spec.ts +48 -0
  5. package/src/audit/index.ts +16 -0
  6. package/src/audit/themeMatrix.spec.ts +292 -1
  7. package/src/audit/themeMatrix.ts +1015 -22
  8. package/src/theme/Intent.stories.tsx +88 -0
  9. package/src/theme/Preset.stories.tsx +80 -0
  10. package/src/theme/Surface.spec.tsx +6 -0
  11. package/src/theme/Surface.stories.tsx +91 -0
  12. package/src/theme/ThemeProvider.stories.tsx +50 -0
  13. package/src/theme/Tint.stories.tsx +98 -0
  14. package/src/theme/chartPalette.spec.ts +128 -5
  15. package/src/theme/chartPalette.ts +105 -22
  16. package/src/theme/colorRules.spec.ts +92 -39
  17. package/src/theme/colorRules.ts +4 -6
  18. package/src/theme/createDefaultThemeConfig.ts +1 -1
  19. package/src/theme/createThemes.ts +58 -3
  20. package/src/theme/devtools/ColorLineVisualizer.stories.tsx +36 -0
  21. package/src/theme/devtools/ThemeDevtoolsPanel.stories.tsx +16 -0
  22. package/src/theme/glyphPaint.spec.ts +14 -0
  23. package/src/theme/glyphPaint.ts +2 -1
  24. package/src/theme/intent.spec.tsx +1 -0
  25. package/src/theme/layoutTokensHooks.spec.tsx +1 -0
  26. package/src/theme/recipeInputs.ts +9 -9
  27. package/src/theme/resolveKnobs.spec.ts +2 -2
  28. package/src/theme/sizeLadder.spec.ts +19 -10
  29. package/src/theme/sizeRecipes.ts +2 -2
  30. package/src/theme/themeValue.spec.ts +2 -2
  31. package/src/theme/useResolvedKnobsBehavior.spec.tsx +6 -0
  32. package/types/audit/constraintAudit.d.ts +17 -1
  33. package/types/audit/constraintAudit.d.ts.map +1 -1
  34. package/types/audit/index.d.ts +2 -2
  35. package/types/audit/index.d.ts.map +1 -1
  36. package/types/audit/themeMatrix.d.ts +135 -2
  37. package/types/audit/themeMatrix.d.ts.map +1 -1
  38. package/types/theme/chartPalette.d.ts +6 -4
  39. package/types/theme/chartPalette.d.ts.map +1 -1
  40. package/types/theme/colorRules.d.ts +3 -3
  41. package/types/theme/colorRules.d.ts.map +1 -1
  42. package/types/theme/createThemes.d.ts +1 -1
  43. package/types/theme/createThemes.d.ts.map +1 -1
  44. package/types/theme/glyphPaint.d.ts.map +1 -1
  45. package/types/theme/recipeInputs.d.ts +8 -8
  46. package/types/theme/recipeInputs.d.ts.map +1 -1
  47. package/types/theme/sizeRecipes.d.ts +2 -2
@@ -1,7 +1,8 @@
1
1
  import * as Colors from "@tamagui/colors";
2
+ import { themes as stockThemes } from "@tamagui/themes";
2
3
  import { useMemo } from "react";
3
4
  import { useTheme as useTamaguiTheme, useThemeName } from "tamagui";
4
- import { normalizeToHex } from "./colorRules";
5
+ import { contrastRatio, minContrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
5
6
  import { tintHueNames } from "./createThemes";
6
7
 
7
8
  /**
@@ -14,7 +15,7 @@ import { tintHueNames } from "./createThemes";
14
15
  * - `single` — every mark of a single-series chart takes the theme identity:
15
16
  * the active accent, or the tint solid when a tint sub-theme is active
16
17
  * (the palette re-anchors under tint exactly like the neutral ramp does).
17
- * - `categorical` — identity-led list of sanctioned Radix step-9 solids for
18
+ * - `categorical` — identity-led list of sanctioned Radix solids for
18
19
  * multi-series data. Slot 0 is the theme identity; the fixed hue cycle
19
20
  * fills the rest, dropping hues indistinguishable from the identity so
20
21
  * adjacent series stay tellable-apart under every tint.
@@ -22,7 +23,7 @@ import { tintHueNames } from "./createThemes";
22
23
  * semantic ramps ($red/$green/$yellow), tint-independent, scheme-aware.
23
24
  *
24
25
  * Values resolve from the same Radix scales the theme builder splats into the
25
- * base themes, so `categorical[1]` under `light` equals `$blue9`, and they
26
+ * base themes, so `categorical[1]` under `light` equals `$blue10`, and they
26
27
  * stay resolvable on native and under tint sub-themes (which only carry the
27
28
  * re-ramped neutral family).
28
29
  */
@@ -41,31 +42,65 @@ export interface ResolveChartPaletteInput {
41
42
  scheme: "light" | "dark";
42
43
  /**
43
44
  * Resolved theme-identity solid — `accentBackground` in base themes, the
44
- * re-ramped `$color9` under a tint. Falls back to the violet ramp solid
45
- * when missing or unparseable (SSR before hydration, exotic values).
45
+ * re-ramped `$color9` under a tint. Tries the ordered candidates, then
46
+ * falls back to violet when missing, unparseable or below the contrast floor.
46
47
  */
47
48
  identitySolid?: string;
49
+ /** Ordered alternatives when the identity cannot meet the mark contrast floor. */
50
+ identityCandidates?: readonly (string | undefined)[];
48
51
  }
49
52
 
50
53
  /**
51
- * Fixed categorical hue cycle: Radix step-9 solids ordered so neighbouring
54
+ * Fixed categorical hue cycle: Radix solids ordered so neighbouring
52
55
  * entries sit far apart on the hue wheel (min adjacent distance ~78°).
53
- * Step 9 is the Radix "solid" step, designed for filled marks on both
54
- * schemes; hues where Radix ships scheme-specific solids follow the scheme.
56
+ * Light blue, orange, green, amber and teal deepen within their hue to clear
57
+ * 3:1 on reference cards. Dark violet deepens for tinted card backgrounds.
55
58
  */
56
59
  const categoricalHueCycle = [
57
- { family: "blue", light: Colors.blue.blue9, dark: Colors.blueDark.blue9 },
58
- { family: "orange", light: Colors.orange.orange9, dark: Colors.orangeDark.orange9 },
59
- { family: "green", light: Colors.green.green9, dark: Colors.greenDark.green9 },
60
- { family: "amber", light: Colors.amber.amber9, dark: Colors.amberDark.amber9 },
60
+ { family: "blue", light: Colors.blue.blue10, dark: Colors.blueDark.blue9 },
61
+ { family: "orange", light: Colors.orange.orange11, dark: Colors.orangeDark.orange9 },
62
+ { family: "green", light: Colors.green.green10, dark: Colors.greenDark.green9 },
63
+ { family: "amber", light: Colors.amber.amber11, dark: Colors.amberDark.amber9 },
61
64
  { family: "pink", light: Colors.pink.pink9, dark: Colors.pinkDark.pink9 },
62
- { family: "teal", light: Colors.teal.teal9, dark: Colors.tealDark.teal9 },
63
- { family: "violet", light: Colors.violet.violet9, dark: Colors.violetDark.violet9 },
65
+ { family: "teal", light: Colors.teal.teal10, dark: Colors.tealDark.teal9 },
66
+ { family: "violet", light: Colors.violet.violet9, dark: Colors.violetDark.violet10 },
64
67
  { family: "red", light: Colors.red.red9, dark: Colors.redDark.red9 },
65
68
  ] as const;
66
69
 
70
+ function configuredTintCardSurfaces(scheme: "light" | "dark"): string[] {
71
+ const themes = stockThemes as unknown as Record<string, { background?: string }>;
72
+ return [...tintHueNames].flatMap((hue) => {
73
+ const color = themes[`${scheme}_${hue}_Card`]?.background;
74
+ return color ? [color] : [];
75
+ });
76
+ }
77
+
78
+ // Use the shipped tinted Card backgrounds, which are deeper than their page
79
+ // colors. Missing theme names are not invented surfaces. The rendered audit
80
+ // still owns actual custom consumer backgrounds and elevation variants.
81
+ const chartReferenceSurfaces = {
82
+ light: [
83
+ "#ffffff",
84
+ Colors.gray.gray2,
85
+ Colors.mauve.mauve2,
86
+ ...configuredTintCardSurfaces("light"),
87
+ ],
88
+ dark: [Colors.grayDark.gray2, Colors.mauveDark.mauve2, ...configuredTintCardSurfaces("dark")],
89
+ } as const;
90
+
91
+ function readableIdentity(color: string | undefined, scheme: "light" | "dark"): color is string {
92
+ const hex = color ? normalizeToHex(color) : null;
93
+ if (!hex) return false;
94
+ const luminance = relativeLuminance(hex);
95
+ return chartReferenceSurfaces[scheme].every((surface) => {
96
+ const backdrop = normalizeToHex(surface);
97
+ return !!backdrop && contrastRatio(luminance, relativeLuminance(backdrop)) >= minContrastRatio;
98
+ });
99
+ }
100
+
67
101
  /**
68
- * Semantic series solids. Error/success use the step-9 solids. Warning uses
102
+ * Semantic series solids. Light success deepens to green10 for the mark floor.
103
+ * Error and dark success use step 9. Warning uses
69
104
  * the sanctioned $yellow ramp, but yellow9 measures ~1.3:1 against light
70
105
  * card surfaces (invisible marks), so light scheme takes the deep yellow11
71
106
  * step while dark keeps the bright yellow9 (13:1 on dark surfaces).
@@ -73,7 +108,7 @@ const categoricalHueCycle = [
73
108
  const semanticSeries = {
74
109
  light: {
75
110
  error: Colors.red.red9,
76
- success: Colors.green.green9,
111
+ success: Colors.green.green10,
77
112
  warning: Colors.yellow.yellow11,
78
113
  },
79
114
  dark: {
@@ -118,6 +153,47 @@ function hueDistance(a: number, b: number): number {
118
153
  return d > 180 ? 360 - d : d;
119
154
  }
120
155
 
156
+ /** Keep every solid after dedupe, moving only an invalid adjacent sequence. */
157
+ function orderCategoricalCycle(identity: string, cycle: string[]): string[] {
158
+ const colors = [identity, ...cycle];
159
+ const hsl = colors.map((color) => colorToHsl(color) as Hsl);
160
+ const luminance = colors.map((color) => relativeLuminance(normalizeToHex(color) as string));
161
+ // LC-27: any one of hue, saturation or luminance can distinguish a pair.
162
+ const compatible = hsl.map((a, i) =>
163
+ hsl.map(
164
+ (b, j) =>
165
+ (a.s >= neutralSaturationFloor &&
166
+ b.s >= neutralSaturationFloor &&
167
+ hueDistance(a.h, b.h) >= hueDedupeThresholdDeg) ||
168
+ Math.abs(a.s - b.s) >= 0.5 ||
169
+ contrastRatio(luminance[i], luminance[j]) >= 1.5,
170
+ ),
171
+ );
172
+ const indices = cycle.map((_, i) => i + 1);
173
+ if (indices.every((i) => compatible[i - 1][i]) && compatible[colors.length - 1][0]) {
174
+ return cycle;
175
+ }
176
+
177
+ // The fixed cycle has at most eight entries. Original-order traversal
178
+ // preserves valid prefixes and the identity remains first, including wrap.
179
+ const arrange = (previous: number, remaining: number[]): number[] | undefined => {
180
+ if (!remaining.length) return compatible[previous][0] ? [] : undefined;
181
+ for (const next of remaining) {
182
+ if (!compatible[previous][next]) continue;
183
+ const tail = arrange(
184
+ next,
185
+ remaining.filter((index) => index !== next),
186
+ );
187
+ if (tail) return [next, ...tail];
188
+ }
189
+ return undefined;
190
+ };
191
+ const ordered = arrange(0, indices);
192
+ // Preserve the supplied series if no permutation can satisfy the rule.
193
+ // The actual chart audit must still report that unsatisfied distinction.
194
+ return ordered ? ordered.map((index) => colors[index]) : cycle;
195
+ }
196
+
121
197
  /**
122
198
  * Pure palette resolution — see {@link ChartPalette} for the contract.
123
199
  * Exported separately from the hook so tests and non-React callers can
@@ -125,10 +201,12 @@ function hueDistance(a: number, b: number): number {
125
201
  */
126
202
  export function resolveChartPalette(input: ResolveChartPaletteInput): ChartPalette {
127
203
  const { scheme } = input;
128
- const fallbackIdentity = scheme === "dark" ? Colors.violetDark.violet9 : Colors.violet.violet9;
129
- const identityHslRaw = input.identitySolid ? colorToHsl(input.identitySolid) : null;
130
- const single = identityHslRaw ? (input.identitySolid as string) : fallbackIdentity;
131
- const identityHsl = identityHslRaw ?? (colorToHsl(fallbackIdentity) as Hsl);
204
+ const fallbackIdentity = scheme === "dark" ? Colors.violetDark.violet10 : Colors.violet.violet9;
205
+ const single =
206
+ [input.identitySolid, ...(input.identityCandidates ?? [])].find((candidate) =>
207
+ readableIdentity(candidate, scheme),
208
+ ) ?? fallbackIdentity;
209
+ const identityHsl = colorToHsl(single) as Hsl;
132
210
 
133
211
  const cycle = categoricalHueCycle.map((entry) => (scheme === "dark" ? entry.dark : entry.light));
134
212
  const deduped =
@@ -143,7 +221,7 @@ export function resolveChartPalette(input: ResolveChartPaletteInput): ChartPalet
143
221
  return {
144
222
  scheme,
145
223
  single,
146
- categorical: [single, ...deduped],
224
+ categorical: [single, ...orderCategoricalCycle(single, deduped)],
147
225
  semantic: semanticSeries[scheme],
148
226
  };
149
227
  }
@@ -166,5 +244,10 @@ export function useChartPalette(): ChartPalette {
166
244
  const color9 = theme.color9?.val;
167
245
  const accent = theme.accentBackground?.val;
168
246
  const identitySolid = isTint ? (color9 ?? accent) : (accent ?? color9);
169
- return useMemo(() => resolveChartPalette({ scheme, identitySolid }), [scheme, identitySolid]);
247
+ const color10 = theme.color10?.val;
248
+ const color11 = theme.color11?.val;
249
+ return useMemo(
250
+ () => resolveChartPalette({ scheme, identitySolid, identityCandidates: [color10, color11] }),
251
+ [scheme, identitySolid, color10, color11],
252
+ );
170
253
  }
@@ -239,12 +239,34 @@ describe("measureContrast", () => {
239
239
  background: "#161519",
240
240
  label: "MPO-48 dark accent toast action",
241
241
  });
242
- expect(report.ratio).toBe(1.94);
242
+ expect(report.ratio).toBeCloseTo(1.94, 2);
243
243
  expect(report.floor).toBe(aaTextContrastRatio);
244
244
  expect(report.pass).toBe(false);
245
245
  });
246
246
 
247
- it("uses the reported 2-decimal ratio for the verdict", () => {
247
+ it.each([
248
+ { foreground: "#000000", background: "#595959", floor: 3 },
249
+ { foreground: "#77767c", background: "#ffffff", floor: 4.5 },
250
+ ])("does not round a below-$floor pair into a pass", (pair) => {
251
+ const report = measureContrast(pair);
252
+ expect(report.ratio).toBeGreaterThan(pair.floor - 0.005);
253
+ expect(report.ratio).toBeLessThan(pair.floor);
254
+ expect(report.pass).toBe(false);
255
+ expect(() => assertContrast(pair)).toThrow(/contrast floor missed/);
256
+ });
257
+
258
+ it("passes the exact measured floor and fails a higher floor", () => {
259
+ const pair = { foreground: "#77767c", background: "#ffffff" };
260
+ const floor = contrastRatio(
261
+ relativeLuminance(pair.foreground),
262
+ relativeLuminance(pair.background),
263
+ );
264
+ expect(measureContrast({ ...pair, floor }).pass).toBe(true);
265
+ expect(measureContrast({ ...pair, floor: floor - 0.00001 }).pass).toBe(true);
266
+ expect(measureContrast({ ...pair, floor: floor + 0.00001 }).pass).toBe(false);
267
+ });
268
+
269
+ it("uses the actual ratio for the verdict", () => {
248
270
  // #808080 on white is 3.9489…, reported 3.95 — pass at 3, miss at 4.5.
249
271
  const at3 = measureContrast({
250
272
  foreground: "#808080",
@@ -252,9 +274,9 @@ describe("measureContrast", () => {
252
274
  floor: minContrastRatio,
253
275
  });
254
276
  const atAa = measureContrast({ foreground: "#808080", background: "#ffffff" });
255
- expect(at3.ratio).toBe(3.95);
277
+ expect(at3.ratio).toBeCloseTo(3.95, 2);
256
278
  expect(at3.pass).toBe(true);
257
- expect(atAa.ratio).toBe(3.95);
279
+ expect(atAa.ratio).toBeCloseTo(3.95, 2);
258
280
  expect(atAa.pass).toBe(false);
259
281
  });
260
282
 
@@ -287,7 +309,7 @@ describe("assertContrast", () => {
287
309
  { foreground: "#808080", background: "#ffffff", label: "gray" },
288
310
  ]),
289
311
  ).toThrow(
290
- /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/,
312
+ /contrast floor missed \(2 of 3 pairs\):[\s\S]*toast pill: #46349d on #161519 = 1\.937[0-9]+:1[\s\S]*gray: #808080 on #ffffff = 3\.949[0-9]+:1/,
291
313
  );
292
314
  });
293
315
  });
@@ -359,7 +381,10 @@ describe("contrast floor on built themes (DG-A11Y-01 / DG-COL-01)", () => {
359
381
  resolveThemeContrast(themes, { theme, foreground: "color", background: "background" }),
360
382
  ),
361
383
  );
362
- expect(reports.map((report) => report.ratio)).toEqual([7.31, 7.61]);
384
+ expect(reports.map((report) => report.ratio)).toEqual([
385
+ expect.closeTo(7.31, 2),
386
+ expect.closeTo(7.61, 2),
387
+ ]);
363
388
  });
364
389
 
365
390
  it("a colour pair below the floor FAILS", () => {
@@ -371,18 +396,27 @@ describe("contrast floor on built themes (DG-A11Y-01 / DG-COL-01)", () => {
371
396
  background: themes.dark.background,
372
397
  label: "MPO-48 dark accent toast action",
373
398
  }),
374
- ).toThrow(/MPO-48 dark accent toast action: #46349d on #161519 = 1.94:1/);
399
+ ).toThrow(/MPO-48 dark accent toast action: #46349d on #161519 = 1\.937[0-9]+:1/);
375
400
 
376
- // MPO-40: outlined warning $color11 on the warning page surface is 4.44:1.
377
- expect(() =>
378
- assertContrast(
379
- resolveThemeContrast(themes, {
380
- theme: "light_warning",
381
- foreground: "color11",
382
- background: "color1",
383
- }),
384
- ),
385
- ).toThrow(/light_warning: color11 on color1: #9e6c00 on #fcfcf7 = 4.44:1/);
401
+ // MPO-40's warning-on-its-own-color1 arm used to sit here as the second
402
+ // negative control at 4.44:1. The `intentInkFloor` step closed it — the
403
+ // same pair now measures 4.71:1 — so it can no longer prove the trip-wire
404
+ // bites, and it moved to the passing assertion below rather than being
405
+ // deleted. MPO-48 above is still a live miss and still carries this test.
406
+ });
407
+
408
+ it("MPO-40's warning ink now clears the floor on its own color1 too", () => {
409
+ // The pair that used to be the negative control. The palette step was
410
+ // computed against the BASE color2 (#f9f9fa, the strictest ground), and
411
+ // this checks the intent's own surface came along with it. Was 4.44.
412
+ const [report] = assertContrast(
413
+ resolveThemeContrast(themes, {
414
+ theme: "light_warning",
415
+ foreground: "color11",
416
+ background: "color1",
417
+ }),
418
+ );
419
+ expect(report.ratio).toBeCloseTo(4.66, 2);
386
420
  });
387
421
 
388
422
  it("current light_accent color-on-background clears the 3:1 non-text floor", () => {
@@ -397,7 +431,7 @@ describe("contrast floor on built themes (DG-A11Y-01 / DG-COL-01)", () => {
397
431
  floor: minContrastRatio,
398
432
  }),
399
433
  );
400
- expect(reports[0].ratio).toBe(3.96);
434
+ expect(reports[0].ratio).toBeCloseTo(3.96, 2);
401
435
  expect(reports[0].pass).toBe(true);
402
436
  });
403
437
 
@@ -422,7 +456,7 @@ describe("contrast floor on built themes (DG-A11Y-01 / DG-COL-01)", () => {
422
456
  floor: minContrastRatio,
423
457
  }),
424
458
  ),
425
- ).toThrow(/light_accent: color on background: #f9f8fc on #eeecf9 = 1.1:1/);
459
+ ).toThrow(/light_accent: color on background: #f9f8fc on #eeecf9 = 1\.103[0-9]+:1/);
426
460
  });
427
461
  });
428
462
 
@@ -455,37 +489,51 @@ describe("intent color11 on the base page surfaces (MPO-40)", () => {
455
489
  * MPO-40 measured the outlined intent label on the gallery harness ground
456
490
  * and recorded warning 4.34:1 and success 4.48:1. That ground is the BASE
457
491
  * theme's `color2` (#f9f9fa) — NOT `background`/`color1`, which are both
458
- * #ffffff and where the same two pairs clear the floor at 4.57 and 4.72.
459
- * The miss is therefore surface-specific: an outlined warning or success
460
- * Button only goes unreadable once it sits on a color2 surface (a card, a
461
- * striped row, the gallery ground). Measuring against the plain page is
462
- * exactly how a check reports a pass on a control that is failing.
492
+ * #ffffff and where the same two pairs cleared the floor even unfixed. The
493
+ * miss was therefore surface-specific: an outlined warning or success Button
494
+ * only went unreadable once it sat on a color2 surface (a card, a striped
495
+ * row, the gallery ground). Measuring against the plain page is exactly how
496
+ * a check reports a pass on a control that is failing.
463
497
  *
464
- * The two misses are PINNED to their measured ratios rather than skipped.
465
- * Raise the $color11 step and the recorded ratio stops matching, which
466
- * fails here and forces the entry out of this table — an exemption that
467
- * retires itself instead of one that rots.
498
+ * Both are CLOSED by the `intentInkFloor` step in `createThemes.ts`, which
499
+ * darkens light `yellow11` and `green11` to the nearest hue-holding value
500
+ * that clears the floor on `color2`. The exemption table that used to stand
501
+ * here retired itself exactly as designed: the step moved, its pinned ratios
502
+ * stopped matching, and the entries had to come out. There is now no
503
+ * exemption to rot.
468
504
  */
469
- const pinnedMisses: Record<string, number> = {
470
- "light_warning: color11 on light.color2": 4.34,
471
- "light_success: color11 on light.color2": 4.48,
472
- };
473
-
474
505
  it("sweeps every intent x base surface x scheme — 18 pairs, none dropped", () => {
475
506
  expect(intentInkPairs).toHaveLength(18);
476
507
  });
477
508
 
478
- it("clears AA everywhere except the two pinned MPO-40 misses", () => {
509
+ it("clears AA on every intent x base surface x scheme pair, with no exemptions", () => {
479
510
  const misses = intentInkPairs
480
511
  .map(measureContrast)
481
512
  .filter((report) => !report.pass)
482
513
  .map((report) => [report.label, report.ratio] as const);
483
- expect(Object.fromEntries(misses)).toEqual(pinnedMisses);
514
+ expect(Object.fromEntries(misses)).toEqual({});
515
+ });
516
+
517
+ it("pins the two MPO-40 stops on color2, the ground they used to fail", () => {
518
+ // The regression guard proper: color2 is the STRICTEST base surface, so a
519
+ // future palette move that quietly re-lightens either stop lands here
520
+ // first. Was 4.34 / 4.48 against a 4.5 floor.
521
+ const reports = assertContrast(
522
+ (["warning", "success"] as const).map((intent) => ({
523
+ foreground: themes[`light_${intent}`].color11,
524
+ background: themes.light.color2,
525
+ label: `light_${intent}: color11 on light.color2`,
526
+ })),
527
+ );
528
+ expect(reports.map((report) => report.ratio)).toEqual([
529
+ expect.closeTo(4.56, 2),
530
+ expect.closeTo(4.55, 2),
531
+ ]);
484
532
  });
485
533
 
486
- it("the same warning/success ink clears the floor on background and color1", () => {
487
- // Locks the surface-specificity so a future fix cannot be signed off by
488
- // re-measuring against the white page.
534
+ it("the same warning/success ink still clears the floor on background and color1", () => {
535
+ // Locks the surface-specificity so a future change cannot be signed off by
536
+ // re-measuring against the white page. Was 4.57 / 4.72.
489
537
  const reports = assertContrast(
490
538
  (["warning", "success"] as const).flatMap((intent) =>
491
539
  (["background", "color1"] as const).map((surface) => ({
@@ -495,6 +543,11 @@ describe("intent color11 on the base page surfaces (MPO-40)", () => {
495
543
  })),
496
544
  ),
497
545
  );
498
- expect(reports.map((report) => report.ratio)).toEqual([4.57, 4.57, 4.72, 4.72]);
546
+ expect(reports.map((report) => report.ratio)).toEqual([
547
+ expect.closeTo(4.8, 2),
548
+ expect.closeTo(4.8, 2),
549
+ expect.closeTo(4.79, 2),
550
+ expect.closeTo(4.79, 2),
551
+ ]);
499
552
  });
500
553
  });
@@ -104,7 +104,7 @@ export interface ContrastReport {
104
104
  /** The colors actually measured, as normalized 6-digit hex. */
105
105
  foreground: string;
106
106
  background: string;
107
- /** WCAG 2.x ratio rounded to 2 decimals — the number a report reads. */
107
+ /** Unrounded WCAG 2.x ratio. Round only when formatting a display value. */
108
108
  ratio: number;
109
109
  floor: number;
110
110
  pass: boolean;
@@ -114,8 +114,8 @@ export interface ContrastReport {
114
114
  * Measure one pair against its floor. Throws when a color cannot be reduced
115
115
  * to an opaque hex: an alpha color has no contrast of its own and the house
116
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.
117
+ * never a skip. Compare the actual ratio: rounding before classification
118
+ * can turn a below-floor pair into a passing one.
119
119
  */
120
120
  export function measureContrast(pair: ContrastPair): ContrastReport {
121
121
  const floor = pair.floor ?? aaTextContrastRatio;
@@ -128,9 +128,7 @@ export function measureContrast(pair: ContrastPair): ContrastReport {
128
128
  if (!background) {
129
129
  throw new Error(`${label}: background is not an opaque color: ${pair.background}`);
130
130
  }
131
- const ratio =
132
- Math.round(contrastRatio(relativeLuminance(foreground), relativeLuminance(background)) * 100) /
133
- 100;
131
+ const ratio = contrastRatio(relativeLuminance(foreground), relativeLuminance(background));
134
132
  return { label, foreground, background, ratio, floor, pass: ratio >= floor };
135
133
  }
136
134
 
@@ -87,7 +87,7 @@ function composeGetTheme(
87
87
  >;
88
88
  if (!custom) return base;
89
89
  return (props: GetThemeProps) => {
90
- const merged: Record<string, string | number> = { ...base(props) };
90
+ const merged: Record<string, string | number | null | undefined> = { ...base(props) };
91
91
  const consumer = custom(props);
92
92
  // MPO-23 / 10 B.3 item 3. Dropping nullish is not enough: a misspelled
93
93
  // colour is not nullish, survives the merge, and resolves to nothing at
@@ -5,6 +5,61 @@ import {
5
5
  } from "@tamagui/theme-builder";
6
6
  import { themes as tamaguiThemes } from "@tamagui/themes";
7
7
 
8
+ /**
9
+ * MPO-40. Radix aims a scale's step 11 at the AA text floor against ITS OWN
10
+ * step 1-2, never against a foreign neutral. mpo paints intent ink on the BASE
11
+ * theme's surfaces, and on `$color2` — `#f9f9fa`, the ground 35 catalog
12
+ * surfaces and every gallery board actually paint — light `yellow11`
13
+ * (`#9e6c00`) measures 4.34:1 and light `green11` (`#218358`) 4.48:1, both
14
+ * under `colorRules`' `aaTextContrastRatio` of 4.5. So the stock stop is right
15
+ * upstream and wrong here.
16
+ *
17
+ * Only these two stops move. Light `red11` clears at 4.95:1 and every dark
18
+ * intent stop clears by 8.49:1 or more, measured, so red and both dark ramps
19
+ * stay untouched stock Radix — as does every other step of yellow and green.
20
+ *
21
+ * The replacements are the smallest darkening that clears the floor on the
22
+ * strictest base surface while holding the hue — dE76 1.94 for warning and
23
+ * 0.70 for success, both far under the ~2.3 just-noticeable difference, so
24
+ * neither intent hue visibly shifts.
25
+ *
26
+ * THESE ARE QUANTIZED VALUES, and that is not a detail. `createThemes` stores
27
+ * every palette entry through color2k as `hsla()` with INTEGER degrees and
28
+ * percentages, so the reachable palette is coarser than hex and a hex chosen
29
+ * on paper is not what renders. The first pass here picked `#996800` /
30
+ * `#208156` by minimising CIEDE2000 in hex space; `#208156` quantized to the
31
+ * SAME `hsla(154, 60%, 32%)` as the stock stop and changed nothing at all,
32
+ * while `#996800` landed two hundredths off its predicted ratio. Both values
33
+ * below were searched in the quantized space and confirmed against the built
34
+ * theme, not against the arithmetic.
35
+ *
36
+ * `$color11` is ALSO the solid intent Button's resting fill (`step(10)` in
37
+ * `defaults/builderOptions.ts`), so this shifts that fill by the same
38
+ * imperceptible amount. It improves it: the solid warning Button's
39
+ * luminance-picked on-fill label was itself at 4.48:1 — a second, unpinned AA
40
+ * miss. The `pickReadableForeground` anchor does not flip for either intent,
41
+ * so no label changes colour.
42
+ */
43
+ const intentInkFloor = {
44
+ /** stored `hsla(41, 100%, 30%)`; 4.34:1 -> 4.56:1 on the base `$color2` */
45
+ warning: "#996900",
46
+ /** stored `hsla(155, 64%, 31%)`; 4.48:1 -> 4.56:1 on the base `$color2` */
47
+ success: "#1c8257",
48
+ } as const;
49
+
50
+ /**
51
+ * Replace a Radix ramp's step-11 ink stop, leaving all eleven other steps
52
+ * stock. Radix ramps are 12 entries with step N at index N-1; anything else is
53
+ * not a ramp this rule understands, so it passes through untouched rather than
54
+ * writing to a position that means something different.
55
+ */
56
+ function withIntentInkFloor(palette: string[], ink: string): string[] {
57
+ if (palette.length !== 12) return palette;
58
+ const stepped = [...palette];
59
+ stepped[10] = ink;
60
+ return stepped;
61
+ }
62
+
8
63
  const grayTheme = {
9
64
  dark_gray: tamaguiThemes.dark,
10
65
  dark_gray_active: tamaguiThemes.dark_active,
@@ -55,7 +110,7 @@ export interface GetThemeProps {
55
110
 
56
111
  export interface CreateThemesBuilderOptions {
57
112
  grandChildrenThemes?: CreateThemesProps["grandChildrenThemes"];
58
- getTheme?: (props: GetThemeProps) => Record<string, string | number>;
113
+ getTheme?: (props: GetThemeProps) => Record<string, string | number | null | undefined>;
59
114
  }
60
115
 
61
116
  /**
@@ -311,7 +366,7 @@ export function createThemesBuilder(
311
366
  warning: {
312
367
  palette: {
313
368
  dark: Object.values(Colors.yellowDark),
314
- light: Object.values(Colors.yellow),
369
+ light: withIntentInkFloor(Object.values(Colors.yellow), intentInkFloor.warning),
315
370
  },
316
371
  },
317
372
  error: {
@@ -323,7 +378,7 @@ export function createThemesBuilder(
323
378
  success: {
324
379
  palette: {
325
380
  dark: Object.values(Colors.greenDark),
326
- light: Object.values(Colors.green),
381
+ light: withIntentInkFloor(Object.values(Colors.green), intentInkFloor.success),
327
382
  },
328
383
  },
329
384
  };
@@ -0,0 +1,36 @@
1
+ import { defaultKnobs } from "../knobs";
2
+ import { ColorLineVisualizer } from "./ColorLineVisualizer";
3
+
4
+ const SAMPLE = Array.from({ length: 12 }, (_, i) => `hsl(${i * 28}, 62%, ${72 - i * 3.5}%)`);
5
+
6
+ export default {
7
+ title: "Theme/ColorLineVisualizer",
8
+ component: ColorLineVisualizer,
9
+ parameters: {
10
+ status: { type: "beta" },
11
+ docs: {
12
+ description: {
13
+ component:
14
+ "Twelve-step theme ramp with semantic group brackets. Tamagui has no equivalent; this is the mpo knob-devtools colour line.",
15
+ },
16
+ },
17
+ },
18
+ };
19
+
20
+ export const main = {
21
+ name: "Main",
22
+ render: () => (
23
+ <div data-testid="color-line-visualizer">
24
+ <ColorLineVisualizer knobs={defaultKnobs} isDark={false} themeColors={SAMPLE} />
25
+ </div>
26
+ ),
27
+ };
28
+
29
+ export const dark = {
30
+ name: "Dark",
31
+ render: () => (
32
+ <div data-testid="color-line-visualizer-dark">
33
+ <ColorLineVisualizer knobs={defaultKnobs} isDark themeColors={SAMPLE} />
34
+ </div>
35
+ ),
36
+ };
@@ -0,0 +1,16 @@
1
+ import { ThemeDevtoolsPanel } from "./ThemeDevtoolsPanel";
2
+
3
+ export default {
4
+ title: "Theme/ThemeDevtoolsPanel",
5
+ component: ThemeDevtoolsPanel,
6
+ parameters: { status: { type: "beta" } },
7
+ };
8
+
9
+ export const main = {
10
+ name: "Main",
11
+ render: () => (
12
+ <div data-testid="theme-devtools-panel" style={{ maxHeight: 640, overflow: "auto" }}>
13
+ <ThemeDevtoolsPanel devtoolsTheme="light" />
14
+ </div>
15
+ ),
16
+ };
@@ -2,6 +2,20 @@ import { describe, expect, it } from "vitest";
2
2
  import { resolveGlyphPaint } from "./glyphPaint";
3
3
 
4
4
  describe("resolveGlyphPaint (LC-84)", () => {
5
+ it("falls back to concrete ink when a requested theme token is absent", () => {
6
+ expect(resolveGlyphPaint({ color: { val: "#111" } }, "$accentColor")).toBe("#111");
7
+ expect(resolveGlyphPaint({ color12: { val: "#eee" } }, "$missing")).toBe("#eee");
8
+ expect(resolveGlyphPaint({ color11: { val: "#ddd" } }, "$missing")).toBe("#ddd");
9
+ expect(resolveGlyphPaint({}, "$missing")).toBe("currentColor");
10
+ });
11
+
12
+ it("preserves supplied theme tokens and supported literal paint", () => {
13
+ expect(resolveGlyphPaint({ accentColor: { val: "#123456" } }, "$accentColor")).toBe("#123456");
14
+ for (const paint of ["currentColor", "red", "#123456", "rgb(1, 2, 3)", "hsl(0, 0%, 50%)"]) {
15
+ expect(resolveGlyphPaint({ color: { val: "#111" } }, paint)).toBe(paint);
16
+ }
17
+ });
18
+
5
19
  it("never returns undefined", () => {
6
20
  expect(resolveGlyphPaint({})).toBe("currentColor");
7
21
  expect(resolveGlyphPaint({}, undefined)).toBe("currentColor");
@@ -26,7 +26,8 @@ export function resolveGlyphPaint(
26
26
  push(theme[key]?.val);
27
27
  if (color.startsWith("#") || color.startsWith("rgb") || color.startsWith("hsl")) {
28
28
  push(color);
29
- } else if (!theme[key]) {
29
+ } else if (!theme[key] && !color.startsWith("$")) {
30
+ // Literal CSS colors may pass through; an unresolved theme token cannot paint.
30
31
  push(color);
31
32
  }
32
33
  }
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
5
5
 
6
6
  // Mock Tamagui's <Theme> so we can inspect the chosen theme name
7
7
  vi.mock("tamagui", () => ({
8
+ useThemeName: () => "light",
8
9
  Theme: ({ name, children }: { name: string; children: ReactNode }) =>
9
10
  createElement("div", { "data-tamagui-theme": name }, children),
10
11
  createStyledContext: (defaults: { size: string; density: string }) => ({
@@ -25,6 +25,7 @@ vi.mock(import("tamagui"), async (importOriginal) => ({
25
25
  ...(await importOriginal()),
26
26
  // Pin the web path — these specs drive size class via matchMedia listeners.
27
27
  isWeb: true,
28
+ useThemeName: () => "light",
28
29
  useWindowDimensions: () => nativeWindowDimensions,
29
30
  }));
30
31