@multiplatform.one/theme 7.69.2 → 7.71.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.ts +2 -0
- package/src/expo.spec.ts +4 -4
- package/src/expo.ts +11 -1
- package/src/theme/colorRules.ts +87 -0
- package/src/theme/createDefaultThemeConfig.spec.ts +8 -4
- package/src/theme/createDefaultThemeConfig.ts +27 -3
- package/src/theme/createThemes.ts +221 -56
- package/src/theme/dedupeInherited.spec.ts +160 -0
- package/src/theme/dedupeInherited.ts +117 -0
- package/src/theme/defaults/builderOptions.ts +1 -1
- package/src/theme/fillContrast.spec.ts +204 -0
- package/src/theme/fillContrast.ts +163 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/oneThemeEngine.spec.ts +192 -0
- package/src/theme/presets.ts +37 -2
- package/src/theme/selectedState.spec.ts +19 -1
- package/src/theme/switchThumb.spec.ts +25 -14
- package/src/theme/v3TokenRoles.ts +51 -0
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/expo.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +39 -0
- package/types/theme/colorRules.d.ts.map +1 -1
- package/types/theme/createDefaultThemeConfig.d.ts +13 -2
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/dedupeInherited.d.ts +54 -0
- package/types/theme/dedupeInherited.d.ts.map +1 -0
- package/types/theme/fillContrast.d.ts +41 -0
- package/types/theme/fillContrast.d.ts.map +1 -0
- package/types/theme/index.d.ts +2 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/presets.d.ts +24 -0
- package/types/theme/presets.d.ts.map +1 -1
- package/types/theme/v3TokenRoles.d.ts +51 -0
- package/types/theme/v3TokenRoles.d.ts.map +1 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LC-160: the theme matrix ships each value once per rule that needs it.
|
|
3
|
+
* `createTamagui` is mocked pass-through so `config.tamagui.themes` is the
|
|
4
|
+
* deduped record the CSS is built from.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
vi.mock("tamagui", () => ({
|
|
9
|
+
createTamagui: vi.fn((config: Record<string, unknown>) => config),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("./animations/index", () => ({ animations: {} }));
|
|
12
|
+
|
|
13
|
+
import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
|
|
14
|
+
import { createThemesBuilder } from "./createThemes";
|
|
15
|
+
import {
|
|
16
|
+
dedupeInheritedThemeValues,
|
|
17
|
+
parentThemeNames,
|
|
18
|
+
resolveAllThemes,
|
|
19
|
+
resolveThemeThroughParents,
|
|
20
|
+
} from "./dedupeInherited";
|
|
21
|
+
import { defaultAccentTheme, defaultBaseTheme, defaultBuilderOptions } from "./defaults";
|
|
22
|
+
|
|
23
|
+
type Themes = Record<string, Record<string, unknown>>;
|
|
24
|
+
|
|
25
|
+
const deduped = createDefaultThemeConfig().tamagui.themes as unknown as Themes;
|
|
26
|
+
const built = createThemesBuilder(
|
|
27
|
+
defaultBaseTheme,
|
|
28
|
+
defaultAccentTheme,
|
|
29
|
+
defaultBuilderOptions,
|
|
30
|
+
).themes() as unknown as Themes;
|
|
31
|
+
|
|
32
|
+
/** Names whose rules declare keys on an element rendered in `name`: its non-root chain and itself. */
|
|
33
|
+
function declaringChain(name: string): string[] {
|
|
34
|
+
return [...parentThemeNames(name).slice(1), name];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("parentThemeNames", () => {
|
|
38
|
+
it("walks the chain the way proxyThemeToParents does, nearest last", () => {
|
|
39
|
+
expect(parentThemeNames("dark_blue_alt1_Button")).toEqual([
|
|
40
|
+
"dark",
|
|
41
|
+
"dark_blue",
|
|
42
|
+
"dark_blue_alt1",
|
|
43
|
+
]);
|
|
44
|
+
expect(parentThemeNames("light")).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("dedupeInheritedThemeValues", () => {
|
|
49
|
+
it("drops a key the nearest non-root ancestor keeps with the same value", () => {
|
|
50
|
+
const out = dedupeInheritedThemeValues({
|
|
51
|
+
light: { a: "1", b: "1" },
|
|
52
|
+
light_blue: { a: "2", b: "1" },
|
|
53
|
+
light_blue_Button: { a: "2", b: "1", c: "3" },
|
|
54
|
+
});
|
|
55
|
+
expect(out.light_blue_Button).toEqual({ c: "3" });
|
|
56
|
+
expect(out.light_blue).toEqual({ a: "2" });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps a root-valued key some other branch overrides (a Button inside a Card)", () => {
|
|
60
|
+
const out = dedupeInheritedThemeValues({
|
|
61
|
+
light: { borderColor: "#ccc" },
|
|
62
|
+
light_Card: { borderColor: "#eee" },
|
|
63
|
+
light_Button: { borderColor: "#ccc" },
|
|
64
|
+
dark: { borderColor: "#333" },
|
|
65
|
+
});
|
|
66
|
+
expect(out.light_Button).toEqual({ borderColor: "#ccc" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps a root-valued key the other scheme's sibling rule declares (nested schemes)", () => {
|
|
70
|
+
const out = dedupeInheritedThemeValues({
|
|
71
|
+
light: { a: "1" },
|
|
72
|
+
light_Button: { a: "1" },
|
|
73
|
+
dark: { a: "9" },
|
|
74
|
+
dark_Button: { a: "8" },
|
|
75
|
+
});
|
|
76
|
+
expect(out.light_Button).toEqual({ a: "1" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("drops a key no non-root theme in either scheme moves off its root", () => {
|
|
80
|
+
const out = dedupeInheritedThemeValues({
|
|
81
|
+
light: { red5: "#f00" },
|
|
82
|
+
light_blue: { red5: "#f00" },
|
|
83
|
+
light_blue_Button: { red5: "#f00" },
|
|
84
|
+
dark: { red5: "#900" },
|
|
85
|
+
dark_blue: { red5: "#900" },
|
|
86
|
+
});
|
|
87
|
+
expect(out.light_blue).toEqual({});
|
|
88
|
+
expect(out.light_blue_Button).toEqual({});
|
|
89
|
+
expect(out.dark_blue).toEqual({});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not depend on the record's order", () => {
|
|
93
|
+
const themes = {
|
|
94
|
+
light_blue_Button: { a: "2" },
|
|
95
|
+
light_blue: { a: "2" },
|
|
96
|
+
light: { a: "1" },
|
|
97
|
+
};
|
|
98
|
+
expect(dedupeInheritedThemeValues(themes)).toEqual(
|
|
99
|
+
dedupeInheritedThemeValues({
|
|
100
|
+
light: themes.light,
|
|
101
|
+
light_blue: themes.light_blue,
|
|
102
|
+
light_blue_Button: themes.light_blue_Button,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("the default config ships deduped themes", () => {
|
|
109
|
+
it("resolves every theme to exactly what the builder's own themes resolve to", () => {
|
|
110
|
+
expect(Object.keys(deduped).sort()).toEqual(Object.keys(built).sort());
|
|
111
|
+
expect(resolveAllThemes(deduped)).toEqual(resolveAllThemes(built));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("drops declarations, never whole themes", () => {
|
|
115
|
+
const count = (themes: Themes) =>
|
|
116
|
+
Object.values(themes).reduce((sum, theme) => sum + Object.keys(theme).length, 0);
|
|
117
|
+
expect(count(deduped)).toBeLessThan(count(built));
|
|
118
|
+
expect(deduped.light).toEqual(built.light);
|
|
119
|
+
expect(deduped.dark).toEqual(built.dark);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// The web hazard SHC's parent-chain rule has: a key no rule on the element
|
|
123
|
+
// declares is inherited from whatever ancestor element is nearest, so it
|
|
124
|
+
// must read the same in every theme of the scheme, and no rule of the other
|
|
125
|
+
// scheme may declare it on the element through a nested-scheme selector.
|
|
126
|
+
it("leaves every undeclared key reading the same from any ancestor theme", () => {
|
|
127
|
+
const names = Object.keys(deduped).filter((name) => name.includes("_"));
|
|
128
|
+
const valuesInScheme = new Map<string, Set<unknown>>();
|
|
129
|
+
for (const name of names) {
|
|
130
|
+
const scheme = name.split("_")[0];
|
|
131
|
+
for (const [key, value] of Object.entries(built[name] ?? {})) {
|
|
132
|
+
const slot = `${scheme}:${key}`;
|
|
133
|
+
if (!valuesInScheme.has(slot)) valuesInScheme.set(slot, new Set());
|
|
134
|
+
valuesInScheme.get(slot)?.add(value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const hazards: string[] = [];
|
|
138
|
+
for (const name of names) {
|
|
139
|
+
const scheme = name.split("_")[0] as string;
|
|
140
|
+
const other = scheme === "light" ? "dark" : "light";
|
|
141
|
+
const resolved = resolveThemeThroughParents(deduped, name);
|
|
142
|
+
const declared = new Set(declaringChain(name).flatMap((n) => Object.keys(deduped[n] ?? {})));
|
|
143
|
+
// Keys the builder already left to inheritance are not this dedupe's.
|
|
144
|
+
const builtDeclared = new Set(
|
|
145
|
+
declaringChain(name).flatMap((n) => Object.keys(built[n] ?? {})),
|
|
146
|
+
);
|
|
147
|
+
const siblings = declaringChain(`${other}${name.slice(scheme.length)}`);
|
|
148
|
+
for (const key of Object.keys(resolved)) {
|
|
149
|
+
if (declared.has(key) || !builtDeclared.has(key)) continue;
|
|
150
|
+
const values = valuesInScheme.get(`${scheme}:${key}`) ?? new Set();
|
|
151
|
+
if ([...values].some((value) => value !== resolved[key])) hazards.push(`${name}.${key}`);
|
|
152
|
+
for (const sibling of siblings) {
|
|
153
|
+
if (deduped[sibling] && key in deduped[sibling])
|
|
154
|
+
hazards.push(`${sibling}.${key} over ${name}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
expect(hazards).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop theme values that another rule already puts on the element (LC-160).
|
|
3
|
+
*
|
|
4
|
+
* Ported from SHC (`shc/packages/themes/dedupeInherited.ts`), which measured
|
|
5
|
+
* every theme rule restating keys its parent chain carries. MPO's builder
|
|
6
|
+
* restates fewer of them, and SHC's rule is not safe on web as written, so
|
|
7
|
+
* this keeps the subset that cannot change a resolved value.
|
|
8
|
+
*
|
|
9
|
+
* JS and native are never at risk: `createTamagui` runs
|
|
10
|
+
* `proxyThemesToParents`, which `Object.assign`s every ancestor onto each
|
|
11
|
+
* theme before anything reads it. CSS is the consumer that needs care. A
|
|
12
|
+
* themed element carries its theme's whole hierarchy as classes
|
|
13
|
+
* (`light_blue_Button` puts `t_blue t_blue_Button` on the Button), so a key
|
|
14
|
+
* dropped from `light_blue_Button` and kept by `light_blue` is still declared
|
|
15
|
+
* on that same element. A key every non-root rule drops is different: it
|
|
16
|
+
* reaches the element by inheritance from the nearest ANCESTOR element that
|
|
17
|
+
* declares it, and that ancestor need not be in the theme's name chain. A
|
|
18
|
+
* Button inside a Card resolves `light_Button`, but its nearest ancestor is
|
|
19
|
+
* the Card's `light_Card`; nested schemes add `dark_*` rules that match the
|
|
20
|
+
* element through `.t_dark .t_X` selectors. SHC's parent-chain rule drops
|
|
21
|
+
* those keys anyway, which repaints a Button's root-valued key with its
|
|
22
|
+
* Card's.
|
|
23
|
+
*
|
|
24
|
+
* So a key is dropped from a non-root theme only when:
|
|
25
|
+
*
|
|
26
|
+
* - the nearest non-root theme in its own chain keeps the key with the same
|
|
27
|
+
* value (declared on the same element), or
|
|
28
|
+
* - no non-root theme in either scheme gives the key a value other than its
|
|
29
|
+
* scheme root's, so whatever ancestor the element inherits from, it reads
|
|
30
|
+
* the root value, and no other rule declares it.
|
|
31
|
+
*
|
|
32
|
+
* Resolution reads the input and the already-pruned shallower themes, so the
|
|
33
|
+
* result does not depend on the record's iteration order.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
type ThemeValues = Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ancestor names of `dark_blue_alt1_Button`, nearest last:
|
|
40
|
+
* `["dark", "dark_blue", "dark_blue_alt1"]`. The same walk as Tamagui's
|
|
41
|
+
* `proxyThemeToParents`; the two must agree or a dropped key would not come
|
|
42
|
+
* back on the JS side.
|
|
43
|
+
*/
|
|
44
|
+
export function parentThemeNames(name: string): string[] {
|
|
45
|
+
const parts = name.split("_");
|
|
46
|
+
const parents: string[] = [];
|
|
47
|
+
for (let i = 1; i < parts.length; i++) parents.push(parts.slice(0, i).join("_"));
|
|
48
|
+
return parents;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Keys some non-root theme sets to a value other than its scheme root's. */
|
|
52
|
+
function keysThatLeaveTheRoot(themes: Record<string, ThemeValues>): Set<string> {
|
|
53
|
+
const leaving = new Set<string>();
|
|
54
|
+
for (const [name, theme] of Object.entries(themes)) {
|
|
55
|
+
const root = themes[name.split("_")[0] ?? ""];
|
|
56
|
+
if (!root || root === theme) continue;
|
|
57
|
+
for (const [key, value] of Object.entries(theme)) {
|
|
58
|
+
if (root[key] !== value) leaving.add(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return leaving;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Each non-root theme without the values another rule already puts on its element. */
|
|
65
|
+
export function dedupeInheritedThemeValues<T extends ThemeValues>(
|
|
66
|
+
themes: Record<string, T>,
|
|
67
|
+
): Record<string, T> {
|
|
68
|
+
const leaving = keysThatLeaveTheRoot(themes);
|
|
69
|
+
const byDepth = Object.keys(themes).sort((a, b) => a.split("_").length - b.split("_").length);
|
|
70
|
+
const pruned: Record<string, ThemeValues> = {};
|
|
71
|
+
for (const name of byDepth) {
|
|
72
|
+
const theme = themes[name] as ThemeValues;
|
|
73
|
+
const parents = parentThemeNames(name);
|
|
74
|
+
if (parents.length === 0) {
|
|
75
|
+
pruned[name] = theme;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const root = themes[parents[0] as string];
|
|
79
|
+
const nonRoot = parents.slice(1).reverse();
|
|
80
|
+
const kept: ThemeValues = {};
|
|
81
|
+
for (const [key, value] of Object.entries(theme)) {
|
|
82
|
+
const holder = nonRoot.find((parent) => pruned[parent] && key in pruned[parent]);
|
|
83
|
+
if (holder) {
|
|
84
|
+
if (pruned[holder]?.[key] !== value) kept[key] = value;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (root?.[key] === value && !leaving.has(key)) continue;
|
|
88
|
+
kept[key] = value;
|
|
89
|
+
}
|
|
90
|
+
pruned[name] = kept;
|
|
91
|
+
}
|
|
92
|
+
return Object.fromEntries(Object.keys(themes).map((name) => [name, pruned[name] as T]));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One theme as Tamagui resolves it: every ancestor, then the theme itself.
|
|
97
|
+
* Use it to read a key off a deduped record; `getConfig().themes` and
|
|
98
|
+
* `themeConfig.tamagui.themes` come out of `createTamagui` already resolved.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveThemeThroughParents<T extends ThemeValues>(
|
|
101
|
+
themes: Record<string, T>,
|
|
102
|
+
name: string,
|
|
103
|
+
): T {
|
|
104
|
+
const out: ThemeValues = {};
|
|
105
|
+
for (const parent of parentThemeNames(name)) Object.assign(out, themes[parent]);
|
|
106
|
+
Object.assign(out, themes[name]);
|
|
107
|
+
return out as T;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every theme of a deduped record resolved through its parents. */
|
|
111
|
+
export function resolveAllThemes<T extends ThemeValues>(
|
|
112
|
+
themes: Record<string, T>,
|
|
113
|
+
): Record<string, T> {
|
|
114
|
+
return Object.fromEntries(
|
|
115
|
+
Object.keys(themes).map((name) => [name, resolveThemeThroughParents(themes, name)]),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -123,7 +123,7 @@ function getBaseTheme(theme: Record<string, string>) {
|
|
|
123
123
|
// The v5 makeSurface templates own the interaction ramp and borders.
|
|
124
124
|
// No ramp step is pinned here; only semantic aliases are added.
|
|
125
125
|
textMuted: pickSecondaryInk(theme),
|
|
126
|
-
textSubtle: theme.
|
|
126
|
+
textSubtle: theme.color10,
|
|
127
127
|
borderStrong: theme.color7,
|
|
128
128
|
borderStrongHover: theme.color8,
|
|
129
129
|
borderStrongPress: theme.color6,
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LC-172 FILL-CONTRAST, as a ratchet. Every preset MPO ships (the catalog's
|
|
3
|
+
* and the app presets in packages/themes) is audited: its filled accent
|
|
4
|
+
* control, active toggle and first chart series carry a hue, the label on
|
|
5
|
+
* the filled control clears APCA Lc 60, the off toggle track is HIG's
|
|
6
|
+
* light system fill and stays clearly apart from the on track, and a fill on a track (Slider, Progress) clears 3:1 on its
|
|
7
|
+
* rail. There is no baseline: every preset passes outright, so
|
|
8
|
+
* no preset can ship an achromatic accent.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
vi.mock("tamagui", () => ({
|
|
16
|
+
createTamagui: vi.fn((config: Record<string, unknown>) => config),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock("./animations/index", () => ({ animations: {} }));
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
aaTextContrastRatio,
|
|
22
|
+
contrastRatio,
|
|
23
|
+
fillLabelLc,
|
|
24
|
+
inSwitchOffBand,
|
|
25
|
+
normalizeToHex,
|
|
26
|
+
relativeLuminance,
|
|
27
|
+
} from "./colorRules";
|
|
28
|
+
import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
|
|
29
|
+
import { resolveAllThemes } from "./dedupeInherited";
|
|
30
|
+
import {
|
|
31
|
+
apcaContrast,
|
|
32
|
+
auditPresetFills,
|
|
33
|
+
oklabDistance,
|
|
34
|
+
srgbChroma,
|
|
35
|
+
toggleStateFloor,
|
|
36
|
+
} from "./fillContrast";
|
|
37
|
+
import {
|
|
38
|
+
boldPreset,
|
|
39
|
+
consolePreset,
|
|
40
|
+
defaultPreset,
|
|
41
|
+
densePreset,
|
|
42
|
+
heroPreset,
|
|
43
|
+
roomyPreset,
|
|
44
|
+
storefrontPreset,
|
|
45
|
+
} from "./presets";
|
|
46
|
+
|
|
47
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../..");
|
|
48
|
+
|
|
49
|
+
/** The app presets' colour themes, read from source: public/theme cannot import @package/themes. */
|
|
50
|
+
function appPresetThemes(): Record<string, string> {
|
|
51
|
+
const source = readFileSync(join(repoRoot, "packages/themes/presets.ts"), "utf8");
|
|
52
|
+
const out: Record<string, string> = {};
|
|
53
|
+
for (const block of source.split(/export const /).slice(1)) {
|
|
54
|
+
const name = block.match(/^(\w+)Preset\b/)?.[1];
|
|
55
|
+
if (!name) continue;
|
|
56
|
+
out[name] = block.match(/\btheme:\s*"([^"]*)"/)?.[1] ?? "";
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const presetThemes: Record<string, string> = {
|
|
62
|
+
default: defaultPreset.theme,
|
|
63
|
+
hero: heroPreset.theme,
|
|
64
|
+
bold: boldPreset.theme,
|
|
65
|
+
dense: densePreset.theme,
|
|
66
|
+
roomy: roomyPreset.theme,
|
|
67
|
+
console: consolePreset.theme,
|
|
68
|
+
storefront: storefrontPreset.theme,
|
|
69
|
+
...appPresetThemes(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const resolved = resolveAllThemes(
|
|
73
|
+
createDefaultThemeConfig().tamagui.themes as unknown as Record<string, Record<string, unknown>>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
describe("LC-172 APCA and chroma measures", () => {
|
|
77
|
+
it("reads grey and black as chroma 0 and a Radix solid as a hue", () => {
|
|
78
|
+
expect(srgbChroma("#202020")).toBe(0);
|
|
79
|
+
expect(srgbChroma("hsla(0, 0%, 45%, 1)")).toBe(0);
|
|
80
|
+
expect(srgbChroma("#8e4ec6")).toBeGreaterThan(0.1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("holds the off toggle to HIG's system-fill band", () => {
|
|
84
|
+
// UISwitch's off track, rgba(120,120,128,0.16) over white and 0.32 over black.
|
|
85
|
+
expect(inSwitchOffBand("#e9e9eb", "#ffffff")).toBe(true);
|
|
86
|
+
expect(inSwitchOffBand("#262629", "#000000")).toBe(true);
|
|
87
|
+
// The round-2 console track and the old gray10 read as on beside a hue fill.
|
|
88
|
+
expect(inSwitchOffBand("#202020", "#ffffff")).toBe(false);
|
|
89
|
+
expect(inSwitchOffBand("#838383", "#f7f7f7")).toBe(false);
|
|
90
|
+
expect(inSwitchOffBand("#000000", "#000000")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("tells the on track from the off track by colour, not only by the thumb", () => {
|
|
94
|
+
// UIKit's own pairs over systemFill clear the floor; systemYellow on white is the closest.
|
|
95
|
+
expect(oklabDistance("#34c759", "#e9e9eb")).toBeGreaterThan(toggleStateFloor);
|
|
96
|
+
expect(oklabDistance("#ffcc00", "#e9e9eb")).toBeGreaterThan(toggleStateFloor);
|
|
97
|
+
expect(oklabDistance("#0a84ff", "#262629")).toBeGreaterThan(toggleStateFloor);
|
|
98
|
+
// FZ-8117 on main: default dark painted an rgb 115 grey on track beside an rgb 121 off track.
|
|
99
|
+
expect(oklabDistance("#737373", "#797979")).toBeLessThan(toggleStateFloor);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("matches APCA's published reference pairs", () => {
|
|
103
|
+
expect(apcaContrast("#888888", "#ffffff")).toBeCloseTo(63.06, 1);
|
|
104
|
+
expect(apcaContrast("#ffffff", "#888888")).toBeCloseTo(-68.54, 1);
|
|
105
|
+
expect(apcaContrast("#000000", "#aaaaaa")).toBeCloseTo(58.15, 1);
|
|
106
|
+
expect(apcaContrast("#aaaaaa", "#000000")).toBeCloseTo(-56.24, 1);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("LC-172 FILL-CONTRAST every preset's fills carry a hue and a readable label", () => {
|
|
111
|
+
it("covers the catalog and the app presets", () => {
|
|
112
|
+
expect(Object.keys(presetThemes)).toEqual(
|
|
113
|
+
expect.arrayContaining(["default", "console", "storefront", "studio", "ocean", "slate"]),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
for (const [preset, theme] of Object.entries(presetThemes)) {
|
|
118
|
+
it(`${preset} (${theme || "house accent"}) paints hued fills and a light off toggle`, () => {
|
|
119
|
+
expect(auditPresetFills(preset, theme, resolved)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("LC-172 the engine keeps every off toggle in the HIG band, a preset's or a user's pick", () => {
|
|
125
|
+
// The page grounds a Switch sits on: the inverse _accent and the _active press
|
|
126
|
+
// surfaces are control fills, not grounds.
|
|
127
|
+
const scopes = Object.keys(resolved).filter(
|
|
128
|
+
(name) => /^(light|dark)(_[a-z]+)?$/.test(name) && !/_(accent|active)$/.test(name),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
it("covers both schemes and every hue", () => {
|
|
132
|
+
expect(scopes.length).toBeGreaterThanOrEqual(18);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
for (const name of scopes) {
|
|
136
|
+
it(`${name}: the off track sits in the system-fill band over the page`, () => {
|
|
137
|
+
const off = String(resolved[name].switchTrackOff);
|
|
138
|
+
const ground = String(resolved[name].background);
|
|
139
|
+
expect(inSwitchOffBand(off, ground), `${off} on ${ground}`).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("LC-172 the engine keeps every on track clearly apart from its off track", () => {
|
|
145
|
+
const scopes = Object.keys(resolved).filter(
|
|
146
|
+
(name) => /^(light|dark)(_[a-z]+)?$/.test(name) && !/_(accent|active)$/.test(name),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
for (const name of scopes) {
|
|
150
|
+
it(`${name}: on and off tracks differ by colour, not only by the thumb`, () => {
|
|
151
|
+
const on = String(resolved[name].accentBackground);
|
|
152
|
+
const off = String(resolved[name].switchTrackOff);
|
|
153
|
+
expect(oklabDistance(on, off), `${on} / ${off}`).toBeGreaterThanOrEqual(toggleStateFloor);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("LC-172 the engine holds every hue's accent solid, a preset's or a user's pick", () => {
|
|
159
|
+
const solids = Object.keys(resolved).filter((name) => /^(light|dark)_[a-z]+_accent$/.test(name));
|
|
160
|
+
|
|
161
|
+
it("builds a solid per hue and scheme", () => {
|
|
162
|
+
expect(solids.length).toBeGreaterThanOrEqual(16);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
for (const name of solids) {
|
|
166
|
+
it(`${name}: the label clears WCAG AA 4.5 and APCA Lc 60 on the fill`, () => {
|
|
167
|
+
const fill = normalizeToHex(String(resolved[name].background));
|
|
168
|
+
const label = normalizeToHex(String(resolved[name].color));
|
|
169
|
+
expect(fill && label, name).toBeTruthy();
|
|
170
|
+
const ratio = contrastRatio(
|
|
171
|
+
relativeLuminance(fill as string),
|
|
172
|
+
relativeLuminance(label as string),
|
|
173
|
+
);
|
|
174
|
+
expect(ratio).toBeGreaterThanOrEqual(aaTextContrastRatio);
|
|
175
|
+
expect(Math.abs(apcaContrast(label as string, fill as string))).toBeGreaterThanOrEqual(
|
|
176
|
+
fillLabelLc,
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("LC-172 non-text fills on a track clear WCAG 1.4.11 3:1, every preset and scheme", () => {
|
|
183
|
+
// Slider and Progress paint formSelectedColors.trackFill ($accentColor) on
|
|
184
|
+
// formControlColors.background ($color5), or on the page when outlined.
|
|
185
|
+
const nonTextFloor = 3;
|
|
186
|
+
const ratio = (a: unknown, b: unknown) => {
|
|
187
|
+
const x = normalizeToHex(String(a));
|
|
188
|
+
const y = normalizeToHex(String(b));
|
|
189
|
+
expect(x && y, `${String(a)} / ${String(b)}`).toBeTruthy();
|
|
190
|
+
return contrastRatio(relativeLuminance(x as string), relativeLuminance(y as string));
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
for (const [preset, theme] of Object.entries(presetThemes)) {
|
|
194
|
+
for (const scheme of ["light", "dark"] as const) {
|
|
195
|
+
const name = theme ? `${scheme}_${theme}` : scheme;
|
|
196
|
+
it(`${preset} ${scheme}: the track fill holds 3:1 on its rail and on the page`, () => {
|
|
197
|
+
const scope = resolved[name];
|
|
198
|
+
expect(scope, name).toBeDefined();
|
|
199
|
+
expect(ratio(scope.accentColor, scope.color5)).toBeGreaterThanOrEqual(nonTextFloor);
|
|
200
|
+
expect(ratio(scope.accentColor, scope.background)).toBeGreaterThanOrEqual(nonTextFloor);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { accentIdentityCandidates, resolveChartPalette } from "./chartPalette";
|
|
2
|
+
import { apcaContrast, fillLabelLc, inSwitchOffBand, normalizeToHex } from "./colorRules";
|
|
3
|
+
import { tintHueNames } from "./createThemes";
|
|
4
|
+
|
|
5
|
+
export { apcaContrast };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* LC-172 FILL-CONTRAST. A preset's fills carry a hue: the filled accent
|
|
9
|
+
* control, the active toggle track and the first chart series are never grey
|
|
10
|
+
* or black on the grey and white grounds, and the label on the filled
|
|
11
|
+
* control passes APCA Lc 60. The off toggle track is HIG's light system fill,
|
|
12
|
+
* never a grey darker than systemFill on a light ground (`inSwitchOffBand`),
|
|
13
|
+
* and the on and off tracks sit at least `toggleStateFloor` apart in OKLab.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** sRGB chroma (max channel minus min channel, 0..1) under which a fill reads as grey or black. */
|
|
17
|
+
export const fillChromaFloor = 0.1;
|
|
18
|
+
|
|
19
|
+
/** APCA |Lc| the label on a filled control must reach. */
|
|
20
|
+
export const fillLabelLcFloor = fillLabelLc;
|
|
21
|
+
|
|
22
|
+
function channels(hex: string): [number, number, number] {
|
|
23
|
+
return [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255) as [
|
|
24
|
+
number,
|
|
25
|
+
number,
|
|
26
|
+
number,
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* OKLab distance the on and off toggle tracks must keep, so a Switch's state
|
|
32
|
+
* reads from its track and not only from the thumb's side. UIKit's least
|
|
33
|
+
* distinct pair, systemYellow on the light off fill, measures 0.19; the
|
|
34
|
+
* FZ-8117 default dark pair (an rgb 115 grey on an rgb 121 grey) measured 0.02.
|
|
35
|
+
*/
|
|
36
|
+
export const toggleStateFloor = 0.15;
|
|
37
|
+
|
|
38
|
+
const linear = (channel: number) =>
|
|
39
|
+
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
40
|
+
|
|
41
|
+
/** Björn Ottosson's OKLab of an opaque colour, or null when it does not parse. */
|
|
42
|
+
export function oklab(color: string): [number, number, number] | null {
|
|
43
|
+
const hex = normalizeToHex(color);
|
|
44
|
+
if (!hex) return null;
|
|
45
|
+
const [r, g, b] = channels(hex).map(linear) as [number, number, number];
|
|
46
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
47
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
48
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787405 * b);
|
|
49
|
+
return [
|
|
50
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
51
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
52
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Euclidean OKLab distance between two colours, 0 when either does not parse. */
|
|
57
|
+
export function oklabDistance(a: string, b: string): number {
|
|
58
|
+
const x = oklab(a);
|
|
59
|
+
const y = oklab(b);
|
|
60
|
+
if (!x || !y) return 0;
|
|
61
|
+
return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** sRGB chroma of an opaque colour, 0 for any grey. */
|
|
65
|
+
export function srgbChroma(color: string): number {
|
|
66
|
+
const hex = normalizeToHex(color);
|
|
67
|
+
if (!hex) return 0;
|
|
68
|
+
const rgb = channels(hex);
|
|
69
|
+
return Math.max(...rgb) - Math.min(...rgb);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FillContrastFinding {
|
|
73
|
+
preset: string;
|
|
74
|
+
scheme: "light" | "dark";
|
|
75
|
+
part: "accent fill" | "toggle" | "toggle off" | "toggle state" | "chart series" | "fill label";
|
|
76
|
+
detail: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type ResolvedThemes = Record<string, Record<string, unknown> | undefined>;
|
|
80
|
+
|
|
81
|
+
const text = (value: unknown) => (typeof value === "string" ? value : undefined);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every LC-172 breach of one preset colour theme in `resolved` (themes run
|
|
85
|
+
* through `resolveAllThemes`). `theme` is the preset's colour theme, `""`
|
|
86
|
+
* for the house accent.
|
|
87
|
+
*/
|
|
88
|
+
export function auditPresetFills(
|
|
89
|
+
preset: string,
|
|
90
|
+
theme: string,
|
|
91
|
+
resolved: ResolvedThemes,
|
|
92
|
+
): FillContrastFinding[] {
|
|
93
|
+
const findings: FillContrastFinding[] = [];
|
|
94
|
+
for (const scheme of ["light", "dark"] as const) {
|
|
95
|
+
const scopeName = theme ? `${scheme}_${theme}` : scheme;
|
|
96
|
+
const scope = resolved[scopeName] ?? {};
|
|
97
|
+
const solid = resolved[`${scopeName}_accent`] ?? {};
|
|
98
|
+
const isTint = tintHueNames.has(theme);
|
|
99
|
+
const accent = text(scope.accentBackground);
|
|
100
|
+
const color9 = text(scope.color9);
|
|
101
|
+
const identity = isTint ? (color9 ?? accent) : (accent ?? color9);
|
|
102
|
+
const accentSolids = Array.from({ length: 10 }, (_, index) =>
|
|
103
|
+
text(scope[`accent${index + 1}`]),
|
|
104
|
+
);
|
|
105
|
+
const chart = resolveChartPalette({
|
|
106
|
+
scheme,
|
|
107
|
+
identitySolid: identity,
|
|
108
|
+
identityCandidates: [
|
|
109
|
+
...(isTint ? [] : accentIdentityCandidates(identity, accentSolids)),
|
|
110
|
+
text(scope.color10),
|
|
111
|
+
text(scope.color11),
|
|
112
|
+
],
|
|
113
|
+
surfaces: [text(scope.color2), text(scheme === "light" ? scope.color1 : scope.color3)],
|
|
114
|
+
});
|
|
115
|
+
const fills: [FillContrastFinding["part"], string | undefined][] = [
|
|
116
|
+
["accent fill", text(solid.background)],
|
|
117
|
+
["toggle", accent],
|
|
118
|
+
["chart series", chart.single],
|
|
119
|
+
];
|
|
120
|
+
for (const [part, fill] of fills) {
|
|
121
|
+
const chroma = fill ? srgbChroma(fill) : 0;
|
|
122
|
+
if (chroma < fillChromaFloor) {
|
|
123
|
+
findings.push({
|
|
124
|
+
preset,
|
|
125
|
+
scheme,
|
|
126
|
+
part,
|
|
127
|
+
detail: `${fill ?? "missing"} is grey (chroma ${chroma.toFixed(2)} < ${fillChromaFloor})`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const off = text(scope.switchTrackOff);
|
|
132
|
+
const ground = text(scope.background);
|
|
133
|
+
if (!off || !ground || !inSwitchOffBand(off, ground)) {
|
|
134
|
+
findings.push({
|
|
135
|
+
preset,
|
|
136
|
+
scheme,
|
|
137
|
+
part: "toggle off",
|
|
138
|
+
detail: `${off ?? "missing"} on ${ground ?? "missing"} is outside the HIG system-fill band`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const apart = accent && off ? oklabDistance(accent, off) : 0;
|
|
142
|
+
if (apart < toggleStateFloor) {
|
|
143
|
+
findings.push({
|
|
144
|
+
preset,
|
|
145
|
+
scheme,
|
|
146
|
+
part: "toggle state",
|
|
147
|
+
detail: `on ${accent ?? "missing"} and off ${off ?? "missing"} are OKLab ${apart.toFixed(2)} apart < ${toggleStateFloor}`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const label = text(solid.color);
|
|
151
|
+
const background = text(solid.background);
|
|
152
|
+
const lc = label && background ? Math.abs(apcaContrast(label, background)) : 0;
|
|
153
|
+
if (lc < fillLabelLcFloor) {
|
|
154
|
+
findings.push({
|
|
155
|
+
preset,
|
|
156
|
+
scheme,
|
|
157
|
+
part: "fill label",
|
|
158
|
+
detail: `${label ?? "missing"} on ${background ?? "missing"} is APCA Lc ${lc.toFixed(1)} < ${fillLabelLcFloor}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return findings;
|
|
163
|
+
}
|
package/src/theme/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./cookies";
|
|
|
6
6
|
export * from "./cornerSmoothing";
|
|
7
7
|
export * from "./createDefaultThemeConfig";
|
|
8
8
|
export * from "./createThemes";
|
|
9
|
+
export * from "./dedupeInherited";
|
|
9
10
|
export * from "./defaults/index";
|
|
10
11
|
export * from "./devtools/ThemeDevtoolsPanel";
|
|
11
12
|
export * from "./dynamicType";
|
|
@@ -50,6 +51,7 @@ export * from "./transitionProps";
|
|
|
50
51
|
|
|
51
52
|
export * from "./theme";
|
|
52
53
|
export * from "./Tint";
|
|
54
|
+
export * from "./v3TokenRoles";
|
|
53
55
|
export * from "./useResolvedKnobs";
|
|
54
56
|
export * from "./useTheme";
|
|
55
57
|
export * from "./usePresetSwitch";
|