@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 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
- # Temporary Holding Version
1
+ # @slotted/theme
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }