@slotted/theme 0.0.0-stage → 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +10 -2
- package/cli/build-theme.mjs +17 -0
- package/cli/build.mjs +53 -0
- package/cli/validate-rules.mjs +36 -0
- package/conformance/theme-conformance.mjs +452 -0
- package/dist/index.js +6135 -0
- package/dist/layer-order.js +10 -0
- package/dist/seeds-x76IGbQO.js +99 -0
- package/dist/seeds.js +2 -0
- package/dist/styles.css +1 -0
- package/dist/types/apply.d.ts +17 -0
- package/dist/types/bound.d.ts +38 -0
- package/dist/types/color.d.ts +30 -0
- package/dist/types/contract-types.d.ts +18 -0
- package/dist/types/css-scan.d.ts +29 -0
- package/dist/types/define.d.ts +56 -0
- package/dist/types/format.d.ts +6 -0
- package/dist/types/generated/contract.d.ts +9 -0
- package/dist/types/index.d.ts +24 -0
- package/dist/types/layer-order.d.ts +9 -0
- package/dist/types/recipe-checks.d.ts +11 -0
- package/dist/types/recipe.d.ts +41 -0
- package/dist/types/recipes/index.d.ts +4 -0
- package/dist/types/recipes/standard.d.ts +2 -0
- package/dist/types/render.d.ts +6 -0
- package/dist/types/resolve.d.ts +5 -0
- package/dist/types/rule-policy.d.ts +41 -0
- package/dist/types/rules.d.ts +25 -0
- package/dist/types/seeds.d.ts +9 -0
- package/dist/types/validate.d.ts +26 -0
- package/package.json +51 -3
- package/src/contract.json +1963 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Slotted contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @slotted/theme
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Slotted theme contract: token names, cascade layer order, seeds, the `defineTheme` runtime and the `slotted-build-theme` command that compiles a theme to a stylesheet.
|
|
4
|
+
|
|
5
|
+
This package is a `0.x` beta. Its public API changes between beta releases without deprecation.
|
|
6
|
+
|
|
7
|
+
An application installs it as a peer dependency of `@slotted/react` or `@slotted/angular`. A custom theme is built against `@slotted/theme/contract.json` and checked with `@slotted/theme/conformance` ([theming](https://github.com/andrelopes-code/slotted/blob/main/docs/decisions/0004-theming-architecture.md)).
|
|
8
|
+
|
|
9
|
+
## License
|
|
10
|
+
|
|
11
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { buildTheme } from './build.mjs';
|
|
3
|
+
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
const outIndex = args.indexOf('--out');
|
|
6
|
+
const out = outIndex === -1 ? 'dist' : args[outIndex + 1];
|
|
7
|
+
const input = args.find((arg, index) => !arg.startsWith('--') && index !== outIndex + 1);
|
|
8
|
+
if (!input || !out) {
|
|
9
|
+
console.error('usage: slotted-build-theme <theme module> [--out <dir>]');
|
|
10
|
+
process.exit(2);
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
await buildTheme(input, out);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error(error.message);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
package/cli/build.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { defineTheme, renderTheme } from '../dist/index.js';
|
|
6
|
+
import { validateRules } from './validate-rules.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds one theme module into `<name>.css`, `<name>.js` and `<name>.d.ts`. A
|
|
10
|
+
* rule given as a URL is read here, so the published module carries every
|
|
11
|
+
* rule as text and `applyTheme` never fetches.
|
|
12
|
+
*/
|
|
13
|
+
export async function buildTheme(modulePath, outDir) {
|
|
14
|
+
let theme;
|
|
15
|
+
try {
|
|
16
|
+
theme = (await import(pathToFileURL(resolve(modulePath)).href)).default;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
// `defineTheme` refuses a rule given as text while the module evaluates, naming it by its
|
|
19
|
+
// index; the build names the module too, as it does for the rules it validates itself.
|
|
20
|
+
throw new Error(error.message.replaceAll(/^- rules\[/gm, `- ${modulePath}#rules[`), {
|
|
21
|
+
cause: error,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (theme?.kind !== 'slotted-theme') {
|
|
25
|
+
throw new Error(`${modulePath}: the default export is not a theme from defineTheme`);
|
|
26
|
+
}
|
|
27
|
+
const errors = [];
|
|
28
|
+
const rules = [];
|
|
29
|
+
for (const [index, rule] of (theme.rules ?? []).entries()) {
|
|
30
|
+
const text = typeof rule === 'string' ? rule : await readFile(rule, 'utf8');
|
|
31
|
+
const filename =
|
|
32
|
+
typeof rule === 'string' ? `${modulePath}#rules[${index}]` : fileURLToPath(rule);
|
|
33
|
+
errors.push(...validateRules(text, filename));
|
|
34
|
+
rules.push(text);
|
|
35
|
+
}
|
|
36
|
+
if (errors.length > 0) {
|
|
37
|
+
throw new Error(`Invalid theme rules:\n${errors.map((error) => `- ${error}`).join('\n')}`);
|
|
38
|
+
}
|
|
39
|
+
const { kind: _kind, ...definition } = theme;
|
|
40
|
+
const built = defineTheme({ ...definition, rules });
|
|
41
|
+
const css = renderTheme(built);
|
|
42
|
+
await mkdir(resolve(outDir), { recursive: true });
|
|
43
|
+
await writeFile(resolve(outDir, `${built.name}.css`), css);
|
|
44
|
+
await writeFile(
|
|
45
|
+
resolve(outDir, `${built.name}.js`),
|
|
46
|
+
`export default ${JSON.stringify(built, null, 2)};\n`,
|
|
47
|
+
);
|
|
48
|
+
await writeFile(
|
|
49
|
+
resolve(outDir, `${built.name}.d.ts`),
|
|
50
|
+
"import type { Theme } from '@slotted/theme';\n\ndeclare const theme: Theme;\nexport default theme;\n",
|
|
51
|
+
);
|
|
52
|
+
return { name: built.name, css };
|
|
53
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { transform } from 'lightningcss';
|
|
2
|
+
|
|
3
|
+
import { ruleErrors } from '../dist/index.js';
|
|
4
|
+
|
|
5
|
+
const at = (filename, loc) => `${filename}:${(loc?.line ?? 0) + 1}:${loc?.column ?? 0}`;
|
|
6
|
+
|
|
7
|
+
const policy = (css, filename) =>
|
|
8
|
+
ruleErrors(css).map(
|
|
9
|
+
({ line, selector, message }) => `${filename}:${line} ${selector}: ${message}`,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The errors of one file of theme rules, each naming the file, the line and
|
|
14
|
+
* the selector of the rule at fault. lightningcss reads the text first, so a
|
|
15
|
+
* syntax error is reported with its column; what a rule may select and read
|
|
16
|
+
* is `ruleErrors`, the check `defineTheme` runs on every rule at runtime.
|
|
17
|
+
*
|
|
18
|
+
* Some text the policy refuses is also text lightningcss cannot parse — a
|
|
19
|
+
* declaration outside a style rule is one. There the policy's message wins,
|
|
20
|
+
* so the build and `defineTheme` refuse it in the same words; the syntax
|
|
21
|
+
* error stands only where the policy has nothing to say.
|
|
22
|
+
*/
|
|
23
|
+
export function validateRules(css, filename) {
|
|
24
|
+
try {
|
|
25
|
+
transform({ filename, code: Buffer.from(css), errorRecovery: false });
|
|
26
|
+
} catch (error) {
|
|
27
|
+
let refused;
|
|
28
|
+
try {
|
|
29
|
+
refused = policy(css, filename);
|
|
30
|
+
} catch {
|
|
31
|
+
refused = [];
|
|
32
|
+
}
|
|
33
|
+
return refused.length > 0 ? refused : [`${at(filename, error.loc)} ${error.message}`];
|
|
34
|
+
}
|
|
35
|
+
return policy(css, filename);
|
|
36
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { LAYER_ORDER_STATEMENT, parseSeed, resolveTokens } from '../dist/index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The rules every theme is held to, in one place, so a second theme is a JSON
|
|
8
|
+
* file rather than a JSON file and a copied test suite. What a theme *chose* —
|
|
9
|
+
* its shadow scale, its neutral, the white it puts on a solid — is not here:
|
|
10
|
+
* that is the theme's identity, and it belongs to the theme's own tests.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const VARIANTS = ['accent', 'secondary', 'success', 'warning', 'danger'];
|
|
14
|
+
const SOLID_STATES = ['solid', 'solid-hover', 'solid-active'];
|
|
15
|
+
const SURFACES = ['--slotted-surface', '--slotted-surface-raised', '--slotted-surface-sunken'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A scheme named for contrast has to earn the name. `high-contrast` is held to
|
|
19
|
+
* the AAA ratio for body text rather than the AA ratio the other schemes meet,
|
|
20
|
+
* and its borders are held to the non-text ratio, because a border is what
|
|
21
|
+
* separates two surfaces that no longer differ in fill.
|
|
22
|
+
*/
|
|
23
|
+
export const TEXT_RATIO = { light: 4.5, dark: 4.5, 'high-contrast': 7 };
|
|
24
|
+
const NON_TEXT_RATIO = 3;
|
|
25
|
+
/**
|
|
26
|
+
* The floor for the edge of a control at rest: a line, and a step above the
|
|
27
|
+
* rule a divider draws (1.35:1 in the default light scheme), never the 3:1 a
|
|
28
|
+
* separator or a focus ring holds. See the border test below for the reason.
|
|
29
|
+
*/
|
|
30
|
+
const CONTROL_EDGE_RATIO = 1.5;
|
|
31
|
+
/** The OKLab lightness step a neutral highlight keeps from the ground it is drawn on. */
|
|
32
|
+
const HIGHLIGHT_STEP = 0.02;
|
|
33
|
+
|
|
34
|
+
/** The collapsed duration is short enough to read as instant, and never zero. */
|
|
35
|
+
const MAX_REDUCED_MOTION_MS = 1;
|
|
36
|
+
|
|
37
|
+
function channel(value) {
|
|
38
|
+
const normalized = value / 255;
|
|
39
|
+
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function luminance(hex) {
|
|
43
|
+
const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex);
|
|
44
|
+
assert.ok(match, `Expected a literal six-digit hex color, received ${hex}`);
|
|
45
|
+
const [, red = '00', green = '00', blue = '00'] = match;
|
|
46
|
+
return (
|
|
47
|
+
0.2126 * channel(Number.parseInt(red, 16)) +
|
|
48
|
+
0.7152 * channel(Number.parseInt(green, 16)) +
|
|
49
|
+
0.0722 * channel(Number.parseInt(blue, 16))
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function contrast(first, second) {
|
|
54
|
+
const high = Math.max(luminance(first), luminance(second));
|
|
55
|
+
const low = Math.min(luminance(first), luminance(second));
|
|
56
|
+
return (high + 0.05) / (low + 0.05);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function milliseconds(value) {
|
|
60
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(String(value).trim());
|
|
61
|
+
return match ? Number(match[1]) * (match[2] === 's' ? 1000 : 1) : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function names(contract, axis) {
|
|
65
|
+
return contract.tokens.filter((token) => token.axis === axis).map((token) => token.name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function reducedMotionNames(contract) {
|
|
69
|
+
return contract.tokens.filter((token) => token.reducedMotion).map((token) => token.name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function glyphNames(contract) {
|
|
73
|
+
return names(contract, 'base').filter((name) => name.startsWith('--slotted-icon-'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const indent = (text) =>
|
|
77
|
+
text
|
|
78
|
+
.split('\n')
|
|
79
|
+
.map((line) => (line ? ` ${line}` : line))
|
|
80
|
+
.join('\n');
|
|
81
|
+
|
|
82
|
+
function expectedReducedMotionBlock(contract, theme) {
|
|
83
|
+
const declarations = reducedMotionNames(contract)
|
|
84
|
+
.map((name) => ` ${name}: ${theme.reducedMotion[name]};`)
|
|
85
|
+
.join('\n');
|
|
86
|
+
return indent(
|
|
87
|
+
`@media (prefers-reduced-motion: reduce) {\n [data-slotted-theme="${theme.name}"] {\n${declarations}\n }\n}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function payloadFor(theme, name) {
|
|
92
|
+
const value = theme.base[name];
|
|
93
|
+
assert.ok(value !== undefined, `${name} is missing`);
|
|
94
|
+
|
|
95
|
+
const match = /^url\("data:image\/svg\+xml,([^"]*)"\)$/.exec(value);
|
|
96
|
+
assert.ok(match, `${name} is not a complete SVG data URI`);
|
|
97
|
+
return match[1];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function structureCases(contract, theme, css) {
|
|
101
|
+
const label = theme.name;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A scheme is a palette and a UA hint. Without the hint every native control,
|
|
105
|
+
* scrollbar and form widget renders in the light user-agent style whatever
|
|
106
|
+
* scheme the application selected, so an unchecked radio on a dark card came
|
|
107
|
+
* out a solid white dot that read as checked. Card was the first rebuilt
|
|
108
|
+
* family to render a native control, and it is where this was found.
|
|
109
|
+
*/
|
|
110
|
+
test(`${label} tells the user agent which scheme its own widgets are painted for`, () => {
|
|
111
|
+
for (const [scheme, expected] of [
|
|
112
|
+
['light', 'light'],
|
|
113
|
+
['dark', 'dark'],
|
|
114
|
+
['high-contrast', 'light'],
|
|
115
|
+
]) {
|
|
116
|
+
assert.match(
|
|
117
|
+
css,
|
|
118
|
+
new RegExp(
|
|
119
|
+
`@scope \\([^{]*\\[data-slotted-scheme="${scheme}"\\][^{]*\\{\\s*:scope,\\s*\\[data-slotted-seeds\\] \\{\\s*color-scheme: ${expected};`,
|
|
120
|
+
),
|
|
121
|
+
`${scheme} declares no color-scheme`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test(`${label} collapses every duration the contract marks, and never to zero`, () => {
|
|
127
|
+
assert.deepEqual(Object.keys(theme.reducedMotion).sort(), reducedMotionNames(contract).sort());
|
|
128
|
+
|
|
129
|
+
for (const [name, value] of Object.entries(theme.reducedMotion)) {
|
|
130
|
+
const duration = milliseconds(value);
|
|
131
|
+
assert.ok(duration !== undefined, `${name} collapses to ${value}, which is not a duration`);
|
|
132
|
+
assert.ok(
|
|
133
|
+
duration > 0 && duration <= MAX_REDUCED_MOTION_MS,
|
|
134
|
+
`${name} collapses to ${value}, outside (0, ${MAX_REDUCED_MOTION_MS}ms]`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test(`${label} collapses every duration under its own theme selector`, () => {
|
|
140
|
+
assert.ok(
|
|
141
|
+
css.includes(expectedReducedMotionBlock(contract, theme)),
|
|
142
|
+
'The theme emits no reduced-motion block',
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test(`${label} emits the reduced-motion block after the block it overrides`, () => {
|
|
147
|
+
assert.ok(
|
|
148
|
+
css.indexOf('@media (prefers-reduced-motion: reduce)') >
|
|
149
|
+
css.indexOf(reducedMotionNames(contract)[0]),
|
|
150
|
+
'A media query adds no specificity, so source order is what makes the override win',
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test(`${label} emits the reduced-motion block as the final content of the theme layer`, () => {
|
|
155
|
+
assert.ok(
|
|
156
|
+
css.includes(`${expectedReducedMotionBlock(contract, theme)}\n}\n`),
|
|
157
|
+
'No later block of the theme layer may override reduced motion',
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test(`${label} grows every control to the comfortable target under a coarse pointer`, () => {
|
|
162
|
+
const query = css.slice(css.indexOf('@media (pointer: coarse)'));
|
|
163
|
+
|
|
164
|
+
for (const density of contract.requiredDensities) {
|
|
165
|
+
assert.match(query, new RegExp(`data-slotted-density="${density}"`));
|
|
166
|
+
}
|
|
167
|
+
assert.match(
|
|
168
|
+
query,
|
|
169
|
+
/--slotted-control-height-md: max\([^)]+, var\(--slotted-target-comfortable\)\)/,
|
|
170
|
+
);
|
|
171
|
+
assert.ok(
|
|
172
|
+
css.indexOf('@media (pointer: coarse)') > css.indexOf('--slotted-control-height-md:'),
|
|
173
|
+
'The coarse-pointer block must follow the density block it overrides',
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test(`${label} maps every scheme token to a system colour under forced colours`, () => {
|
|
178
|
+
const query = css.slice(
|
|
179
|
+
css.indexOf('@media (forced-colors: active)'),
|
|
180
|
+
css.indexOf('@media (prefers-reduced-motion: reduce)'),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
for (const name of names(contract, 'scheme')) {
|
|
184
|
+
assert.match(query, new RegExp(`${name}: [A-Za-z]+;`), `${name} is unmapped`);
|
|
185
|
+
}
|
|
186
|
+
for (const scheme of contract.requiredSchemes) {
|
|
187
|
+
assert.match(
|
|
188
|
+
query,
|
|
189
|
+
new RegExp(`\\[data-slotted-scheme="${scheme}"\\]`),
|
|
190
|
+
`forced colours must carry the specificity of the ${scheme} block`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test(`${label} scopes every block to its own name`, () => {
|
|
196
|
+
const scopes = new Set(
|
|
197
|
+
[...css.matchAll(/\[data-slotted-theme="([^"]+)"\]/g)].map(([, name]) => name),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
assert.deepEqual([...scopes], [theme.name], 'Two themes must emit disjoint selectors');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test(`${label} opens the stylesheet with the published layer order`, () => {
|
|
204
|
+
assert.ok(css.startsWith(LAYER_ORDER_STATEMENT));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function contrastCases(theme) {
|
|
209
|
+
const label = theme.name;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* One radius for every control, at every size. Button carried three of its
|
|
213
|
+
* own — 7, 6 and 5 pixels — so a small button read rounder than a large one,
|
|
214
|
+
* which is a scale nothing else in the library shares. The rebuild dropped
|
|
215
|
+
* them, and this is what keeps a second corner scale from reappearing beside
|
|
216
|
+
* the first.
|
|
217
|
+
*/
|
|
218
|
+
test(`${label} leaves the corner of a control to one token`, () => {
|
|
219
|
+
assert.deepEqual(
|
|
220
|
+
Object.keys(theme.base).filter(
|
|
221
|
+
(name) => /radius/.test(name) && !name.startsWith('--slotted-radius-'),
|
|
222
|
+
),
|
|
223
|
+
['--slotted-control-radius'],
|
|
224
|
+
);
|
|
225
|
+
assert.match(theme.base['--slotted-control-radius'], /^var\(--slotted-radius-[a-z]+\)$/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
for (const [scheme, raw] of Object.entries(theme.schemes)) {
|
|
229
|
+
const values = resolveTokens({ ...theme.base, ...raw });
|
|
230
|
+
const required = TEXT_RATIO[scheme];
|
|
231
|
+
|
|
232
|
+
test(`${label} ${scheme} text reads on every surface`, () => {
|
|
233
|
+
for (const surface of SURFACES) {
|
|
234
|
+
for (const text of ['--slotted-text', '--slotted-text-muted']) {
|
|
235
|
+
assert.ok(
|
|
236
|
+
contrast(values[surface], values[text]) >= required,
|
|
237
|
+
`${scheme} ${text} on ${surface} is below ${required}:1`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The neutral highlight is a ground, not a line, so it is held to a step of
|
|
245
|
+
* lightness rather than to a ratio: an item under the pointer or the
|
|
246
|
+
* keyboard has to read as lit on every surface it is drawn on. The dark
|
|
247
|
+
* scheme's step was calibrated on `--slotted-surface` alone, and on
|
|
248
|
+
* `--slotted-surface-raised` — where every menu, popup list and sheet
|
|
249
|
+
* lives — it sat 0.005 above the ground in OKLab lightness: the highlight
|
|
250
|
+
* of an action sheet's focused item was invisible. 0.02 is the step a
|
|
251
|
+
* reader tells apart at a glance; the pressed step sits beyond the hover.
|
|
252
|
+
*/
|
|
253
|
+
test(`${label} ${scheme} lights a neutral highlight on every surface`, () => {
|
|
254
|
+
const lightness = (name) => parseSeed(values[name]).l;
|
|
255
|
+
for (const surface of ['--slotted-surface', '--slotted-surface-raised']) {
|
|
256
|
+
const ground = lightness(surface);
|
|
257
|
+
const hover = lightness('--slotted-tone-secondary-subtle-hover');
|
|
258
|
+
const active = lightness('--slotted-tone-secondary-subtle-active');
|
|
259
|
+
assert.ok(
|
|
260
|
+
Math.abs(hover - ground) >= HIGHLIGHT_STEP,
|
|
261
|
+
`${scheme} --slotted-tone-secondary-subtle-hover is ${Math.abs(hover - ground).toFixed(3)} from ${surface}, below ${HIGHLIGHT_STEP}`,
|
|
262
|
+
);
|
|
263
|
+
assert.ok(
|
|
264
|
+
Math.abs(active - ground) > Math.abs(hover - ground),
|
|
265
|
+
`${scheme} the pressed step is not beyond the hover on ${surface}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test(`${label} ${scheme} inverts a surface without losing its text`, () => {
|
|
271
|
+
assert.ok(
|
|
272
|
+
contrast(values['--slotted-surface-inverse'], values['--slotted-text-inverse']) >= required,
|
|
273
|
+
`${scheme} inverse text is below ${required}:1`,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A separator and a focus ring are non-text elements, and the surface they
|
|
279
|
+
* are drawn against is not always `--slotted-surface`: an `outline` control
|
|
280
|
+
* takes whatever ground it is placed on, so a form inside a raised card and
|
|
281
|
+
* the same form on a sunken page are both cases the ratio has to survive.
|
|
282
|
+
* Holding only the default surface is what let a line soften past the
|
|
283
|
+
* floor on one of the other two without anything saying so.
|
|
284
|
+
*
|
|
285
|
+
* The edge of a control at rest is not held to the ratio. At 3:1 it was the
|
|
286
|
+
* darkest line on the page in both schemes and read as a high-contrast
|
|
287
|
+
* theme; a control is identified by its fill, its shadow, its label and its
|
|
288
|
+
* size, which is the case WCAG 1.4.11 exempts from the boundary ratio, and
|
|
289
|
+
* the states that carry meaning — focus, invalid, checked — keep it. The
|
|
290
|
+
* edge still has to be a line: the floor below is the step a divider takes.
|
|
291
|
+
*/
|
|
292
|
+
test(`${label} ${scheme} separates surfaces with a visible border`, () => {
|
|
293
|
+
for (const surface of SURFACES) {
|
|
294
|
+
assert.ok(
|
|
295
|
+
contrast(values[surface], values['--slotted-border-strong']) >= NON_TEXT_RATIO,
|
|
296
|
+
`${scheme} --slotted-border-strong on ${surface} is below ${NON_TEXT_RATIO}:1`,
|
|
297
|
+
);
|
|
298
|
+
assert.ok(
|
|
299
|
+
contrast(values[surface], values['--slotted-control-border-color']) >= CONTROL_EDGE_RATIO,
|
|
300
|
+
`${scheme} --slotted-control-border-color on ${surface} is below ${CONTROL_EDGE_RATIO}:1`,
|
|
301
|
+
);
|
|
302
|
+
assert.ok(
|
|
303
|
+
contrast(values[surface], values['--slotted-focus-ring-color']) >= NON_TEXT_RATIO,
|
|
304
|
+
`${scheme} focus ring on ${surface} is below ${NON_TEXT_RATIO}:1`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The edge of a control is lighter than the line between two surfaces, and
|
|
311
|
+
* the two are separate tokens for that reason. Reading
|
|
312
|
+
* `--slotted-border-strong` on a control drew a frame that was the darkest
|
|
313
|
+
* line on the form.
|
|
314
|
+
*/
|
|
315
|
+
test(`${label} ${scheme} keeps the control edge lighter than the surface separator`, () => {
|
|
316
|
+
assert.ok(
|
|
317
|
+
contrast(values['--slotted-surface'], values['--slotted-control-border-color']) <
|
|
318
|
+
contrast(values['--slotted-surface'], values['--slotted-border-strong']) ||
|
|
319
|
+
scheme === 'high-contrast',
|
|
320
|
+
`${scheme} control border is not lighter than the strong border`,
|
|
321
|
+
);
|
|
322
|
+
assert.ok(
|
|
323
|
+
contrast(values['--slotted-surface'], values['--slotted-disabled-border']) <
|
|
324
|
+
contrast(values['--slotted-surface'], values['--slotted-control-border-color']) ||
|
|
325
|
+
scheme === 'high-contrast',
|
|
326
|
+
`${scheme} disabled border is not lighter than the control border`,
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test(`${label} ${scheme} solid variants meet the ratio the scheme promises`, () => {
|
|
331
|
+
for (const variant of VARIANTS) {
|
|
332
|
+
const foreground = values[`--slotted-tone-${variant}-on-solid`];
|
|
333
|
+
for (const state of SOLID_STATES) {
|
|
334
|
+
assert.ok(
|
|
335
|
+
contrast(values[`--slotted-tone-${variant}-${state}`], foreground) >= required,
|
|
336
|
+
`${scheme}.${variant}.${state} is below ${required}:1`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The tinted rectangle each tone carries — its `surface` role under its
|
|
344
|
+
* `text` role — is what `badge`, `tag` and `alert` are made of, and Button
|
|
345
|
+
* handed the pair to them when `soft` left its painting axis. Nothing held
|
|
346
|
+
* the ratio: the solid roles were covered from the first commit and the
|
|
347
|
+
* tinted ones never were, so the first family to depend on them would have
|
|
348
|
+
* been the one to discover a shortfall, in a screenshot.
|
|
349
|
+
*
|
|
350
|
+
* `outline` puts the same text on the page instead, so both grounds are
|
|
351
|
+
* held here.
|
|
352
|
+
*/
|
|
353
|
+
test(`${label} ${scheme} keeps a tinted ground readable`, () => {
|
|
354
|
+
for (const variant of VARIANTS) {
|
|
355
|
+
const text = values[`--slotted-tone-${variant}-text`];
|
|
356
|
+
for (const ground of [`--slotted-tone-${variant}-surface`, ...SURFACES]) {
|
|
357
|
+
assert.ok(
|
|
358
|
+
contrast(values[ground], text) >= required,
|
|
359
|
+
`${scheme} ${variant} text on ${ground} is below ${required}:1`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* What says a row, a card or a toggle is chosen. The `accent` ground alone
|
|
367
|
+
* cannot say it: a tinted ground is bracketed from both sides — it has to
|
|
368
|
+
* stay far enough below the page to be seen and far enough above its own
|
|
369
|
+
* text to be read — and on a light page the two brackets do not overlap.
|
|
370
|
+
* At 1.5:1 under white a ground sits at L ≤ 0.650, while `accent-text`
|
|
371
|
+
* needs L ≥ 0.655 to hold 4.5:1 over it, and `danger` and `success` need
|
|
372
|
+
* 0.680 and 0.892 (G9, `tools/harness/out/review-loop/g9/tones-before.txt`).
|
|
373
|
+
* So the pair is what carries the state: the edge separates the chosen
|
|
374
|
+
* item from the page and from the ground it encloses, at the non-text
|
|
375
|
+
* ratio, and the ground tints what the edge encloses. A theme that softens
|
|
376
|
+
* the accent edge loses the only half of the pair that is measurable in
|
|
377
|
+
* every scheme — `high-contrast` paints every tone ground the page's own
|
|
378
|
+
* white, and there the edge is the whole gesture.
|
|
379
|
+
*/
|
|
380
|
+
test(`${label} ${scheme} says chosen with an edge, not with a ground alone`, () => {
|
|
381
|
+
const ground = values['--slotted-tone-accent-surface'];
|
|
382
|
+
const edge = values['--slotted-tone-accent-border'];
|
|
383
|
+
for (const [against, what] of [
|
|
384
|
+
[values['--slotted-surface'], 'the page'],
|
|
385
|
+
[ground, 'the ground it encloses'],
|
|
386
|
+
]) {
|
|
387
|
+
assert.ok(
|
|
388
|
+
contrast(edge, against) >= NON_TEXT_RATIO,
|
|
389
|
+
`${scheme} the accent edge reads ${contrast(edge, against).toFixed(
|
|
390
|
+
3,
|
|
391
|
+
)}:1 against ${what}, below ${NON_TEXT_RATIO}:1`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test(`${label} ${scheme} keeps disabled text legible`, () => {
|
|
397
|
+
assert.ok(
|
|
398
|
+
contrast(
|
|
399
|
+
values['--slotted-disabled-background'],
|
|
400
|
+
values['--slotted-disabled-foreground'],
|
|
401
|
+
) >= NON_TEXT_RATIO,
|
|
402
|
+
`${scheme} disabled text is below ${NON_TEXT_RATIO}:1`,
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function glyphCases(contract, theme) {
|
|
409
|
+
const label = theme.name;
|
|
410
|
+
const glyphs = glyphNames(contract);
|
|
411
|
+
|
|
412
|
+
test(`${label} gives every glyph a fully encoded, complete SVG payload a mask can use`, () => {
|
|
413
|
+
assert.ok(glyphs.length > 0, 'The contract declares no glyph');
|
|
414
|
+
|
|
415
|
+
for (const name of glyphs) {
|
|
416
|
+
const payload = payloadFor(theme, name);
|
|
417
|
+
assert.doesNotMatch(payload, /[<>#]/, `${name} leaves SVG syntax unencoded`);
|
|
418
|
+
|
|
419
|
+
const svg = decodeURIComponent(payload);
|
|
420
|
+
assert.match(svg, /^<svg\b[^>]*>[\s\S]*<\/svg>$/, `${name} is not a complete SVG document`);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test(`${label} uses black or none only for its mask alpha geometry`, () => {
|
|
425
|
+
for (const name of glyphs) {
|
|
426
|
+
const svg = decodeURIComponent(payloadFor(theme, name));
|
|
427
|
+
const attributes = [...svg.matchAll(/\b(fill|stroke)=['"]([^'"]+)['"]/g)];
|
|
428
|
+
assert.ok(attributes.length > 0, `${name} has no fill or stroke alpha geometry`);
|
|
429
|
+
|
|
430
|
+
for (const [, attribute, value] of attributes) {
|
|
431
|
+
assert.ok(
|
|
432
|
+
value === 'black' || value === 'none',
|
|
433
|
+
`${name} uses ${value} for ${attribute}, not black or none mask alpha geometry`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test(`${label} declares one viewBox for every glyph, so one size rule fits all`, () => {
|
|
440
|
+
for (const name of glyphs) {
|
|
441
|
+
const svg = decodeURIComponent(payloadFor(theme, name));
|
|
442
|
+
assert.ok(svg.includes("viewBox='0 0 24 24'"), `${name} has a foreign viewBox`);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Registers every case a theme has to pass. `theme` is `flattenTheme(theme)`; `css` is `renderTheme(theme)`. */
|
|
448
|
+
export function themeConformance({ contract, theme, css }) {
|
|
449
|
+
structureCases(contract, theme, css);
|
|
450
|
+
contrastCases(theme);
|
|
451
|
+
glyphCases(contract, theme);
|
|
452
|
+
}
|