@multiplatform.one/theme 7.7.6 → 7.10.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/package.json +5 -5
- package/src/audit/constraintAudit.spec.ts +157 -0
- package/src/audit/constraintAudit.ts +154 -35
- package/src/audit/constraintScope.spec.ts +48 -0
- package/src/audit/index.ts +16 -0
- package/src/audit/themeMatrix.spec.ts +292 -1
- package/src/audit/themeMatrix.ts +1015 -22
- package/src/theme/Intent.stories.tsx +88 -0
- package/src/theme/Preset.stories.tsx +80 -0
- package/src/theme/Surface.spec.tsx +6 -0
- package/src/theme/Surface.stories.tsx +91 -0
- package/src/theme/ThemeProvider.stories.tsx +50 -0
- package/src/theme/Tint.stories.tsx +98 -0
- package/src/theme/chartPalette.spec.ts +128 -5
- package/src/theme/chartPalette.ts +105 -22
- package/src/theme/colorRules.spec.ts +92 -39
- package/src/theme/colorRules.ts +4 -6
- package/src/theme/createDefaultThemeConfig.ts +1 -1
- package/src/theme/createThemes.ts +58 -3
- package/src/theme/devtools/ColorLineVisualizer.stories.tsx +36 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.stories.tsx +16 -0
- package/src/theme/glyphPaint.spec.ts +14 -0
- package/src/theme/glyphPaint.ts +2 -1
- package/src/theme/intent.spec.tsx +1 -0
- package/src/theme/layoutTokensHooks.spec.tsx +1 -0
- package/src/theme/recipeInputs.ts +9 -9
- package/src/theme/resolveKnobs.spec.ts +2 -2
- package/src/theme/sizeLadder.spec.ts +19 -10
- package/src/theme/sizeRecipes.ts +2 -2
- package/src/theme/themeValue.spec.ts +2 -2
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +6 -0
- package/types/audit/constraintAudit.d.ts +17 -1
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/audit/index.d.ts +2 -2
- package/types/audit/index.d.ts.map +1 -1
- package/types/audit/themeMatrix.d.ts +135 -2
- package/types/audit/themeMatrix.d.ts.map +1 -1
- package/types/theme/chartPalette.d.ts +6 -4
- package/types/theme/chartPalette.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +3 -3
- package/types/theme/colorRules.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/glyphPaint.d.ts.map +1 -1
- package/types/theme/recipeInputs.d.ts +8 -8
- package/types/theme/recipeInputs.d.ts.map +1 -1
- 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
|
|
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 `$
|
|
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.
|
|
45
|
-
* when missing
|
|
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
|
|
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
|
-
*
|
|
54
|
-
*
|
|
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.
|
|
58
|
-
{ family: "orange", light: Colors.orange.
|
|
59
|
-
{ family: "green", light: Colors.green.
|
|
60
|
-
{ family: "amber", light: Colors.amber.
|
|
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.
|
|
63
|
-
{ family: "violet", light: Colors.violet.violet9, dark: Colors.violetDark.
|
|
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.
|
|
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.
|
|
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.
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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).
|
|
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(
|
|
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).
|
|
277
|
+
expect(at3.ratio).toBeCloseTo(3.95, 2);
|
|
256
278
|
expect(at3.pass).toBe(true);
|
|
257
|
-
expect(atAa.ratio).
|
|
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
|
|
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([
|
|
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
|
|
399
|
+
).toThrow(/MPO-48 dark accent toast action: #46349d on #161519 = 1\.937[0-9]+:1/);
|
|
375
400
|
|
|
376
|
-
// MPO-40
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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).
|
|
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
|
|
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
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
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
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
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([
|
|
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
|
});
|
package/src/theme/colorRules.ts
CHANGED
|
@@ -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
|
|
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.
|
|
118
|
-
*
|
|
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");
|
package/src/theme/glyphPaint.ts
CHANGED
|
@@ -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
|
|