@radicool/throughline 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +5 -5
  2. package/adapters/codex/prompts/component-builder.md +12 -4
  3. package/adapters/cursor/.cursor/rules/component-builder.mdc +12 -4
  4. package/adapters/generic/skills/component-builder/SKILL.md +12 -4
  5. package/package.json +1 -1
  6. package/references/figma-checks.md +813 -0
  7. package/references/figma-component-standards.md +16 -38
  8. package/references/figma-scripting.md +6 -6
  9. package/references/proof-bundle.md +1 -0
  10. package/references/state-baseline.md +5 -3
  11. package/scripts/README.md +5 -2
  12. package/scripts/build-figma-checks.mjs +111 -0
  13. package/scripts/build-state-baseline.mjs +5 -3
  14. package/scripts/install.mjs +9 -4
  15. package/scripts/lib/component-states.mjs +4 -1
  16. package/scripts/lib/contrast.mjs +5 -2
  17. package/scripts/lib/figma-checks-read.figma.js +174 -0
  18. package/scripts/lib/figma-checks.mjs +385 -0
  19. package/scripts/lib/fixtures/figma-checks/avatar-not-in-table.json +44 -0
  20. package/scripts/lib/fixtures/figma-checks/button-ios-swift-skipped.json +86 -0
  21. package/scripts/lib/fixtures/figma-checks/button-missing-default.json +72 -0
  22. package/scripts/lib/fixtures/figma-checks/button-mui-ring.json +93 -0
  23. package/scripts/lib/fixtures/figma-checks/button-no-focus-variant.json +72 -0
  24. package/scripts/lib/fixtures/figma-checks/button-plain-frames.json +93 -0
  25. package/scripts/lib/fixtures/figma-checks/button-ring-child-not-absolute.json +95 -0
  26. package/scripts/lib/fixtures/figma-checks/button-ring-hidden.json +93 -0
  27. package/scripts/lib/fixtures/figma-checks/button-ring-unbound-colour.json +99 -0
  28. package/scripts/lib/fixtures/figma-checks/button-shadcn-filled.json +93 -0
  29. package/scripts/lib/fixtures/figma-checks/button-sparse.json +135 -0
  30. package/scripts/lib/fixtures/figma-checks/button-spread-zero.json +93 -0
  31. package/scripts/lib/fixtures/figma-checks/button-transparent-ring-child.json +95 -0
  32. package/scripts/lib/fixtures/figma-checks/button-vanilla-inside.json +88 -0
  33. package/scripts/lib/fixtures/figma-checks/button-vanilla-outside.json +88 -0
  34. package/scripts/lib/fixtures/figma-checks/button-wrong-combination.json +93 -0
  35. package/scripts/lib/fixtures/figma-checks/checkbox-checked-axis.real.json +246 -0
  36. package/scripts/lib/fixtures/figma-checks/checkbox-checked-synonym.json +88 -0
  37. package/scripts/lib/fixtures/figma-checks/checkbox-missing-selected.json +80 -0
  38. package/scripts/lib/fixtures/figma-checks/indicator-failed.json +106 -0
  39. package/scripts/lib/fixtures/figma-checks/input-control-layer-missing.json +67 -0
  40. package/scripts/lib/fixtures/figma-checks/input-control-layer-ring-on-wrapper.json +80 -0
  41. package/scripts/lib/fixtures/figma-checks/input-control-layer.json +80 -0
  42. package/scripts/lib/fixtures/figma-checks/input-mui-weight-unbound.json +67 -0
  43. package/scripts/lib/fixtures/figma-checks/input-mui.json +67 -0
  44. package/scripts/lib/fixtures/figma-checks/ring-single-mode.json +112 -0
  45. package/scripts/lib/fixtures/figma-checks/ring-translucent.json +111 -0
  46. package/scripts/lib/fixtures/figma-checks/sample-button.real.json +1498 -0
  47. package/scripts/lib/fixtures/figma-checks/sample-ring-on-muted-raw.json +113 -0
  48. package/scripts/lib/fixtures/figma-checks/sample-ring-passes.json +169 -0
  49. package/scripts/lib/fixtures/figma-checks/surface-missing.json +108 -0
  50. package/scripts/lib/fixtures/figma-checks/surface-single-mode.json +112 -0
  51. package/scripts/lib/fixtures/figma-checks/surface-translucent.json +112 -0
  52. package/scripts/lib/fixtures/figma-checks/zygarden-ring-light-fails.json +113 -0
@@ -0,0 +1,385 @@
1
+ // The Figma stop conditions as a pure checker: the COMPONENT_SET assertion against
2
+ // the full variant matrix, the state matrix, the focus ring, and the focus ring's
3
+ // contrast against the surfaces it sits on. ZERO imports,
4
+ // `export const`/`export function` only. This module is inlined, with
5
+ // component-states.mjs and contrast.mjs above it, into the generated Figma script
6
+ // (references/figma-checks.md) by build-figma-checks.mjs, so it must run in both
7
+ // Node and the Figma plugin sandbox.
8
+ //
9
+ // The state table is not imported. `withTable({ resolveArchetype, BASELINE_STATES,
10
+ // STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT })`
11
+ // takes it, the fold and the contrast maths from the caller and returns
12
+ // { checkSpec, checkBuilt }. In the generated script the caller passes the
13
+ // inlined component-states.mjs names; in tests it passes the module's own exports.
14
+ //
15
+ // Spec: docs/specs/2026-10-07-figma-checks-as-scripts.md
16
+ // Rules: references/figma-component-standards.md (State handling; audit items 8, 9)
17
+ //
18
+ // Spec (focus contrast): docs/specs/2026-10-13-focus-ring-contrast.md
19
+ //
20
+ // EXPECT: { name, matrix: { axis: [values] }, uiFramework, controlLayer?, surfaces? }
21
+ // surfaces: colour-variable names of the page surfaces the component sits on.
22
+ //
23
+ // SNAPSHOT (what the reader returns for the COMPONENT_SET in `WIP: <name>`):
24
+ // {
25
+ // type, // the set's node type, 'COMPONENT_SET'
26
+ // variantGroupProperties: { Axis: { values: [...] }, ... }, // only the keys are read
27
+ // children: [ VARIANT, ... ],
28
+ // colors: { [variableName]: { modes: { [modeName]: '#rrggbb' | '#rrggbbaa' | null }, defaultMode } },
29
+ // missingSurfaces: [name, ...] // EXPECT.surfaces names with no local colour variable
30
+ // }
31
+ // `colors` holds every colour variable read on focus variants and their
32
+ // controlLayer, plus each EXPECT.surfaces name that was found.
33
+ // VARIANT (every child): { type, variantProperties: { Axis: 'Value' } }
34
+ // A VARIANT whose state value folds to `focus` also carries FOCUS NODE fields
35
+ // (below), plus `controlLayer` when EXPECT names one.
36
+ // FOCUS NODE (the variant itself, and the controlLayer subtree):
37
+ // {
38
+ // effects: [{ type, visible, spread, colorVariable }], // colorVariable: bound colour variable name or null
39
+ // strokeAlign, // 'INSIDE' | 'OUTSIDE' | 'CENTER'
40
+ // strokeVariables: [name, ...], // names of variables bound to stroke colours
41
+ // strokeWeightBound, // boundVariables.strokeWeight is present
42
+ // children: [{ layoutPositioning, strokeAlign, strokeVariables, strokeWeightBound }] // direct children only
43
+ // }
44
+ // VARIANT.controlLayer: a FOCUS NODE, or null when EXPECT names a layer the variant does not have.
45
+ // Variable names are resolved by the reader and only for focus variants and their controlLayer.
46
+ //
47
+ // RESULT: { pass, checks: [{ name, result: 'pass' | 'fail' | 'skip', detail }] }
48
+ // built mode returns four checks: structural-read-back, state-baseline,
49
+ // focus-indicator, focus-contrast.
50
+ // `pass` ignores skips. A failed assertion is data; nothing here throws.
51
+
52
+ // The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
53
+ // null `uiFramework` uses the `ring` recipe. Any other value is out of scope.
54
+ // ring - DROP_SHADOW effect with a focus-bound colour and spread > 0, or an
55
+ // ABSOLUTE ring child with an OUTSIDE stroke bound to a focus variable
56
+ // mui - an input: the control's own stroke and its weight are bound; any other
57
+ // component uses the ring recipe
58
+ // outline - the control or a direct child carries an OUTSIDE stroke bound to a
59
+ // focus variable
60
+ export const FRAMEWORKS = {
61
+ shadcn: { recipe: 'ring' },
62
+ tailwind: { recipe: 'ring' },
63
+ mui: { recipe: 'mui' },
64
+ 'vanilla-css': { recipe: 'outline' },
65
+ vanilla: { recipe: 'outline' },
66
+ };
67
+
68
+ function frameworkRecipe(uiFramework) {
69
+ if (uiFramework === undefined || uiFramework === null) return 'ring';
70
+ return Object.prototype.hasOwnProperty.call(FRAMEWORKS, uiFramework)
71
+ ? FRAMEWORKS[uiFramework].recipe
72
+ : null;
73
+ }
74
+
75
+ // A focus variable is any variable whose name has `focus` as a whole word: in a
76
+ // `/` segment of its own, or inside one split on `-`, `_`, spaces or a camelCase
77
+ // boundary. `border/focus`, `color/focus/ring`, `color/focusRing` and
78
+ // `interaction/border-primary-focus` count; `color/bg/focused` and a raw colour
79
+ // (null) do not.
80
+ function isFocusVariable(name) {
81
+ if (typeof name !== 'string') return false;
82
+ const words = name
83
+ .split('/')
84
+ .flatMap((seg) => seg.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[\s_-]+/));
85
+ return words.some((word) => word.toLowerCase() === 'focus');
86
+ }
87
+
88
+ // A choice control may carry selection on its own axis instead of as a `selected`
89
+ // value on the state axis: the sample's Checkbox has `checked: false | true |
90
+ // indeterminate`. An axis with one of these names satisfies `selected`.
91
+ const SELECTION_AXES = ['selected', 'checked', 'on'];
92
+
93
+ const hasFocusStroke = (node) => (node.strokeVariables ?? []).some(isFocusVariable);
94
+
95
+ function lacksRing(target, recipe, isInput) {
96
+ const children = target.children ?? [];
97
+ if (recipe === 'mui' && isInput) {
98
+ const lacks = [];
99
+ if (!hasFocusStroke(target)) lacks.push('a stroke bound to a focus variable');
100
+ if (!target.strokeWeightBound) lacks.push('a stroke weight bound to a variable');
101
+ return lacks;
102
+ }
103
+ if (recipe === 'outline') {
104
+ const outside = (n) => n.strokeAlign === 'OUTSIDE' && hasFocusStroke(n);
105
+ return outside(target) || children.some(outside)
106
+ ? []
107
+ : ['an OUTSIDE stroke bound to a focus variable on the control or a direct child'];
108
+ }
109
+ const shadow = (target.effects ?? []).some(
110
+ (e) => e.type === 'DROP_SHADOW' && e.visible !== false && e.spread > 0 && isFocusVariable(e.colorVariable),
111
+ );
112
+ const ringChild = children.some(
113
+ (c) => c.layoutPositioning === 'ABSOLUTE' && c.strokeAlign === 'OUTSIDE' && hasFocusStroke(c),
114
+ );
115
+ return shadow || ringChild
116
+ ? []
117
+ : [
118
+ 'a DROP_SHADOW with spread > 0 and a colour bound to a focus variable, or an ABSOLUTE ring child with an OUTSIDE stroke bound to a focus variable',
119
+ ];
120
+ }
121
+
122
+ // The focus-variable names that make up the ring `lacksRing` accepted, for the
123
+ // contrast check. Same recipes, same tests.
124
+ function ringVariables(target, recipe, isInput) {
125
+ const children = target.children ?? [];
126
+ const focusStrokes = (node) => (node.strokeVariables ?? []).filter(isFocusVariable);
127
+ if (recipe === 'mui' && isInput) return focusStrokes(target);
128
+ if (recipe === 'outline') {
129
+ return [target, ...children].filter((n) => n.strokeAlign === 'OUTSIDE').flatMap(focusStrokes);
130
+ }
131
+ const shadows = (target.effects ?? [])
132
+ .filter((e) => e.type === 'DROP_SHADOW' && e.visible !== false && e.spread > 0 && isFocusVariable(e.colorVariable))
133
+ .map((e) => e.colorVariable);
134
+ const ringChildren = children
135
+ .filter((c) => c.layoutPositioning === 'ABSOLUTE' && c.strokeAlign === 'OUTSIDE')
136
+ .flatMap(focusStrokes);
137
+ return [...shadows, ...ringChildren];
138
+ }
139
+
140
+ export function withTable({
141
+ resolveArchetype,
142
+ BASELINE_STATES,
143
+ STATE_SYNONYMS,
144
+ normalizeName,
145
+ contrastRatio,
146
+ composite,
147
+ parseHex,
148
+ AA_NON_TEXT,
149
+ }) {
150
+ const fold = normalizeName;
151
+ const foldState = (value) => {
152
+ const name = fold(value);
153
+ return STATE_SYNONYMS[name] ?? name;
154
+ };
155
+ const result = (checks) => ({ pass: checks.every((c) => c.result !== 'fail'), checks });
156
+ const label = (props) =>
157
+ Object.entries(props ?? {})
158
+ .map(([k, v]) => `${k}=${v}`)
159
+ .join(', ');
160
+
161
+ // Combinations as comparable strings, axes sorted by folded name.
162
+ const comboKey = (props) =>
163
+ Object.entries(props ?? {})
164
+ .map(([k, v]) => [fold(k), fold(v)])
165
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
166
+ .map(([k, v]) => `${k}=${v}`)
167
+ .join(', ');
168
+
169
+ const isFocusVariant = (child) =>
170
+ child.type === 'COMPONENT' &&
171
+ Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
172
+
173
+ function crossProduct(matrix) {
174
+ let combos = [{}];
175
+ for (const [axis, values] of Object.entries(matrix ?? {})) {
176
+ combos = combos.flatMap((combo) => values.map((value) => ({ ...combo, [axis]: value })));
177
+ }
178
+ return combos;
179
+ }
180
+
181
+ function structural(snapshot, expect) {
182
+ const name = 'structural-read-back';
183
+ const problems = [];
184
+ if (snapshot.type !== 'COMPONENT_SET') problems.push(`the node is ${snapshot.type}, not a COMPONENT_SET`);
185
+ const children = snapshot.children ?? [];
186
+ const notComponents = children.filter((c) => c.type !== 'COMPONENT');
187
+ if (notComponents.length > 0) {
188
+ problems.push(`${notComponents.length} of ${children.length} children are not COMPONENTs (${[...new Set(notComponents.map((c) => c.type))].join(', ')})`);
189
+ }
190
+
191
+ const builtAxes = Object.keys(snapshot.variantGroupProperties ?? {}).map(fold).sort();
192
+ const wantAxes = Object.keys(expect.matrix ?? {}).map(fold).sort();
193
+ const missingAxes = wantAxes.filter((a) => !builtAxes.includes(a));
194
+ const extraAxes = builtAxes.filter((a) => !wantAxes.includes(a));
195
+ if (missingAxes.length) problems.push(`missing axes: ${missingAxes.join(', ')}`);
196
+ if (extraAxes.length) problems.push(`extra axes: ${extraAxes.join(', ')}`);
197
+
198
+ const want = new Map(crossProduct(expect.matrix).map((combo) => [comboKey(combo), label(combo)]));
199
+ const seen = new Map();
200
+ const duplicates = [];
201
+ for (const child of children) {
202
+ const key = comboKey(child.variantProperties);
203
+ if (seen.has(key)) duplicates.push(label(child.variantProperties));
204
+ else seen.set(key, label(child.variantProperties));
205
+ }
206
+ const missing = [...want].filter(([key]) => !seen.has(key)).map(([, text]) => text);
207
+ const extra = [...seen].filter(([key]) => !want.has(key)).map(([, text]) => text);
208
+ if (missing.length) problems.push(`missing combinations: ${missing.join(' | ')}`);
209
+ if (extra.length) problems.push(`extra combinations: ${extra.join(' | ')}`);
210
+ if (duplicates.length) problems.push(`duplicate combinations: ${duplicates.join(' | ')}`);
211
+
212
+ return problems.length
213
+ ? { name, result: 'fail', detail: problems.join('; ') }
214
+ : { name, result: 'pass', detail: `COMPONENT_SET with ${children.length} COMPONENT variants matching the matrix` };
215
+ }
216
+
217
+ // `entries` are [axisName, value] pairs; only the `state` axis is read.
218
+ function stateBaseline(expect, entries) {
219
+ const name = 'state-baseline';
220
+ const archetype = resolveArchetype({ name: expect.name });
221
+ if (archetype === null) {
222
+ return { name, result: 'skip', detail: `"${expect.name}" is not a name in the state table` };
223
+ }
224
+ const baseline = BASELINE_STATES[archetype];
225
+ if (baseline.length === 0) {
226
+ return { name, result: 'skip', detail: `"${expect.name}" is a ${archetype}, which owes no states beyond default` };
227
+ }
228
+ const present = new Set(entries.filter(([axis]) => fold(axis) === 'state').map(([, value]) => foldState(value)));
229
+ if (entries.some(([axis]) => SELECTION_AXES.includes(fold(axis)))) present.add('selected');
230
+ const required = ['default', ...baseline];
231
+ const missing = required.filter((s) => !present.has(foldState(s)));
232
+ return missing.length
233
+ ? { name, result: 'fail', detail: `the state axis is missing: ${missing.join(', ')}` }
234
+ : { name, result: 'pass', detail: `the state axis has default and every ${archetype} state` };
235
+ }
236
+
237
+ function focusIndicator(snapshot, expect) {
238
+ const name = 'focus-indicator';
239
+ const archetype = resolveArchetype({ name: expect.name });
240
+ if (archetype === null) {
241
+ return { name, result: 'skip', detail: `"${expect.name}" is not a name in the state table` };
242
+ }
243
+ if (BASELINE_STATES[archetype].length === 0) {
244
+ return { name, result: 'skip', detail: `"${expect.name}" is a ${archetype}, which owes no states, so no focus ring is checked` };
245
+ }
246
+ const recipe = frameworkRecipe(expect.uiFramework);
247
+ if (recipe === null) {
248
+ return { name, result: 'skip', detail: `uiFramework "${expect.uiFramework}" is out of scope for the focus check` };
249
+ }
250
+ const variants = (snapshot.children ?? []).filter(isFocusVariant);
251
+ if (variants.length === 0) {
252
+ return { name, result: 'fail', detail: 'no COMPONENT variant has a state of focus, so no ring can be read; the state check stops on this' };
253
+ }
254
+ const misses = [];
255
+ for (const variant of variants) {
256
+ let target = variant;
257
+ if (expect.controlLayer !== undefined && expect.controlLayer !== null) {
258
+ target = variant.controlLayer;
259
+ if (!target) {
260
+ misses.push(`${label(variant.variantProperties)}: no layer named "${expect.controlLayer}"`);
261
+ continue;
262
+ }
263
+ }
264
+ const lacks = lacksRing(target, recipe, archetype === 'input');
265
+ if (lacks.length) misses.push(`${label(variant.variantProperties)}: lacks ${lacks.join(' and ')}`);
266
+ }
267
+ return misses.length
268
+ ? { name, result: 'fail', detail: misses.join('; ') }
269
+ : { name, result: 'pass', detail: `all ${variants.length} focus variants carry a ${recipe}-recipe focus indicator` };
270
+ }
271
+
272
+ // WCAG 1.4.11: each ring colour against each named surface, in every mode either
273
+ // side has. Runs only on a ring focusIndicator accepted.
274
+ function focusContrast(snapshot, expect, indicator) {
275
+ const name = 'focus-contrast';
276
+ if (indicator.result !== 'pass') {
277
+ return { name, result: 'skip', detail: 'focus-indicator did not pass, so there is no ring to measure' };
278
+ }
279
+ const surfaces = expect.surfaces ?? [];
280
+ if (surfaces.length === 0) {
281
+ return {
282
+ name,
283
+ result: 'skip',
284
+ detail: 'no surfaces named; the architect names the colour variables of the surfaces the component sits on',
285
+ };
286
+ }
287
+ const missing = snapshot.missingSurfaces ?? [];
288
+ if (missing.length) {
289
+ return { name, result: 'fail', detail: `no local colour variable named: ${missing.join(', ')}` };
290
+ }
291
+
292
+ const recipe = frameworkRecipe(expect.uiFramework);
293
+ const isInput = resolveArchetype({ name: expect.name }) === 'input';
294
+ const hasLayer = expect.controlLayer !== undefined && expect.controlLayer !== null;
295
+ const rings = new Set();
296
+ for (const variant of (snapshot.children ?? []).filter(isFocusVariant)) {
297
+ const target = hasLayer ? variant.controlLayer : variant;
298
+ if (target) for (const ring of ringVariables(target, recipe, isInput)) rings.add(ring);
299
+ }
300
+
301
+ const colors = snapshot.colors ?? {};
302
+ // A mode's value: the same-named mode after the fold, else the default.
303
+ const valueIn = (entry, foldedMode) => {
304
+ const modes = entry?.modes ?? {};
305
+ const hit = Object.keys(modes).find((m) => fold(m) === foldedMode);
306
+ if (hit !== undefined) return modes[hit];
307
+ return entry?.defaultMode !== undefined ? modes[entry.defaultMode] ?? null : null;
308
+ };
309
+ const skipped = { unresolved: 0, translucentSurface: 0 };
310
+ const failures = [];
311
+ const modesSeen = new Set();
312
+ let pairs = 0;
313
+ for (const ring of [...rings].sort()) {
314
+ for (const surface of [...surfaces].sort()) {
315
+ const ringEntry = colors[ring];
316
+ const surfaceEntry = colors[surface];
317
+ // Modes come from the side(s) with more than one: a single-mode collection
318
+ // reads the same value in every mode, so its own mode name adds nothing.
319
+ // When both are single-mode, the ring's one mode is the only comparison.
320
+ const entries = [ringEntry, surfaceEntry];
321
+ const multi = entries.filter((e) => Object.keys(e?.modes ?? {}).length > 1);
322
+ const single = Object.keys(ringEntry?.modes ?? {}).length ? [ringEntry] : [surfaceEntry];
323
+ const modeNames = new Map();
324
+ for (const entry of multi.length ? multi : single) {
325
+ for (const m of Object.keys(entry?.modes ?? {})) if (!modeNames.has(fold(m))) modeNames.set(fold(m), m);
326
+ }
327
+ if (modeNames.size === 0) {
328
+ skipped.unresolved += 1;
329
+ continue;
330
+ }
331
+ for (const [foldedMode, mode] of [...modeNames].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
332
+ const ringHex = valueIn(ringEntry, foldedMode);
333
+ const surfaceHex = valueIn(surfaceEntry, foldedMode);
334
+ const surfaceParsed = parseHex(surfaceHex);
335
+ if (parseHex(ringHex) === null || surfaceParsed === null) {
336
+ skipped.unresolved += 1;
337
+ continue;
338
+ }
339
+ if (surfaceParsed.a < 1) {
340
+ skipped.translucentSurface += 1;
341
+ continue;
342
+ }
343
+ const surfaceOpaque = surfaceHex.slice(0, 7);
344
+ const ratio = contrastRatio(composite(ringHex, surfaceOpaque), surfaceOpaque);
345
+ pairs += 1;
346
+ modesSeen.add(foldedMode);
347
+ if (ratio < AA_NON_TEXT) {
348
+ const shown = (Math.floor(ratio * 100) / 100).toFixed(2);
349
+ failures.push(`${ring} on ${surface} in ${mode}: ${shown}:1, needs ${AA_NON_TEXT}:1`);
350
+ }
351
+ }
352
+ }
353
+ }
354
+ const skipNote = [
355
+ skipped.unresolved && `${skipped.unresolved} skipped as unresolved or not a colour`,
356
+ skipped.translucentSurface && `${skipped.translucentSurface} skipped for a translucent surface`,
357
+ ].filter(Boolean);
358
+ if (failures.length) return { name, result: 'fail', detail: failures.join('; ') };
359
+ if (pairs === 0) {
360
+ return { name, result: 'skip', detail: `no ring/surface pair could be compared: ${skipNote.join(', ') || 'no ring colour found'}` };
361
+ }
362
+ const passed = `${pairs} ring/surface pair(s) clear ${AA_NON_TEXT}:1 across ${modesSeen.size} mode(s)`;
363
+ return { name, result: 'pass', detail: skipNote.length ? `${passed}; ${skipNote.join(', ')}` : passed };
364
+ }
365
+
366
+ // Before anything is written: the matrix the spec asks for against the state table.
367
+ function checkSpec(expect) {
368
+ const entries = Object.entries(expect.matrix ?? {}).flatMap(([axis, values]) => values.map((v) => [axis, v]));
369
+ return result([stateBaseline(expect, entries)]);
370
+ }
371
+
372
+ // After the build: the node as read.
373
+ function checkBuilt(snapshot, expect) {
374
+ const entries = (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
375
+ const indicator = focusIndicator(snapshot, expect);
376
+ return result([
377
+ structural(snapshot, expect),
378
+ stateBaseline(expect, entries),
379
+ indicator,
380
+ focusContrast(snapshot, expect, indicator),
381
+ ]);
382
+ }
383
+
384
+ return { checkSpec, checkBuilt };
385
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "covers": "name not in the state table: state and focus checks skip",
3
+ "expect": {
4
+ "name": "Avatar",
5
+ "matrix": {
6
+ "size": [
7
+ "sm",
8
+ "md"
9
+ ]
10
+ },
11
+ "uiFramework": "shadcn"
12
+ },
13
+ "snapshot": {
14
+ "type": "COMPONENT_SET",
15
+ "variantGroupProperties": {
16
+ "Size": {
17
+ "values": [
18
+ "sm",
19
+ "md"
20
+ ]
21
+ }
22
+ },
23
+ "children": [
24
+ {
25
+ "type": "COMPONENT",
26
+ "variantProperties": {
27
+ "Size": "sm"
28
+ }
29
+ },
30
+ {
31
+ "type": "COMPONENT",
32
+ "variantProperties": {
33
+ "Size": "md"
34
+ }
35
+ }
36
+ ]
37
+ },
38
+ "expected": {
39
+ "structural-read-back": "pass",
40
+ "state-baseline": "skip",
41
+ "focus-indicator": "skip",
42
+ "focus-contrast": "skip"
43
+ }
44
+ }
@@ -0,0 +1,86 @@
1
+ {
2
+ "covers": "framework out of scope: focus check skips",
3
+ "expect": {
4
+ "name": "Button",
5
+ "matrix": {
6
+ "variant": [
7
+ "primary"
8
+ ],
9
+ "state": [
10
+ "default",
11
+ "hover",
12
+ "focus",
13
+ "active",
14
+ "disabled"
15
+ ]
16
+ },
17
+ "uiFramework": "ios-swift"
18
+ },
19
+ "snapshot": {
20
+ "type": "COMPONENT_SET",
21
+ "variantGroupProperties": {
22
+ "Variant": {
23
+ "values": [
24
+ "Primary"
25
+ ]
26
+ },
27
+ "State": {
28
+ "values": [
29
+ "Default",
30
+ "Hover",
31
+ "Focus",
32
+ "Active",
33
+ "Disabled"
34
+ ]
35
+ }
36
+ },
37
+ "children": [
38
+ {
39
+ "type": "COMPONENT",
40
+ "variantProperties": {
41
+ "Variant": "Primary",
42
+ "State": "Default"
43
+ }
44
+ },
45
+ {
46
+ "type": "COMPONENT",
47
+ "variantProperties": {
48
+ "Variant": "Primary",
49
+ "State": "Hover"
50
+ }
51
+ },
52
+ {
53
+ "type": "COMPONENT",
54
+ "variantProperties": {
55
+ "Variant": "Primary",
56
+ "State": "Focus"
57
+ },
58
+ "effects": [],
59
+ "strokeAlign": "INSIDE",
60
+ "strokeVariables": [],
61
+ "strokeWeightBound": false,
62
+ "children": []
63
+ },
64
+ {
65
+ "type": "COMPONENT",
66
+ "variantProperties": {
67
+ "Variant": "Primary",
68
+ "State": "Active"
69
+ }
70
+ },
71
+ {
72
+ "type": "COMPONENT",
73
+ "variantProperties": {
74
+ "Variant": "Primary",
75
+ "State": "Disabled"
76
+ }
77
+ }
78
+ ]
79
+ },
80
+ "expected": {
81
+ "structural-read-back": "pass",
82
+ "state-baseline": "pass",
83
+ "focus-indicator": "skip",
84
+ "focus-contrast": "skip"
85
+ }
86
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "covers": "no default on the state axis",
3
+ "expect": {
4
+ "name": "Button",
5
+ "matrix": {
6
+ "state": [
7
+ "hover",
8
+ "focus",
9
+ "active",
10
+ "disabled"
11
+ ]
12
+ },
13
+ "uiFramework": "shadcn"
14
+ },
15
+ "snapshot": {
16
+ "type": "COMPONENT_SET",
17
+ "variantGroupProperties": {
18
+ "State": {
19
+ "values": [
20
+ "Hover",
21
+ "Focus",
22
+ "Active",
23
+ "Disabled"
24
+ ]
25
+ }
26
+ },
27
+ "children": [
28
+ {
29
+ "type": "COMPONENT",
30
+ "variantProperties": {
31
+ "State": "Hover"
32
+ }
33
+ },
34
+ {
35
+ "type": "COMPONENT",
36
+ "variantProperties": {
37
+ "State": "Focus"
38
+ },
39
+ "effects": [
40
+ {
41
+ "type": "DROP_SHADOW",
42
+ "visible": true,
43
+ "spread": 3,
44
+ "colorVariable": "Color/Border/Focus"
45
+ }
46
+ ],
47
+ "strokeAlign": "INSIDE",
48
+ "strokeVariables": [],
49
+ "strokeWeightBound": false,
50
+ "children": []
51
+ },
52
+ {
53
+ "type": "COMPONENT",
54
+ "variantProperties": {
55
+ "State": "Active"
56
+ }
57
+ },
58
+ {
59
+ "type": "COMPONENT",
60
+ "variantProperties": {
61
+ "State": "Disabled"
62
+ }
63
+ }
64
+ ]
65
+ },
66
+ "expected": {
67
+ "structural-read-back": "pass",
68
+ "state-baseline": "fail",
69
+ "focus-indicator": "pass",
70
+ "focus-contrast": "skip"
71
+ }
72
+ }
@@ -0,0 +1,93 @@
1
+ {
2
+ "covers": "mui on a non-input uses the ring recipe",
3
+ "expect": {
4
+ "name": "Button",
5
+ "matrix": {
6
+ "variant": [
7
+ "primary"
8
+ ],
9
+ "state": [
10
+ "default",
11
+ "hover",
12
+ "focus",
13
+ "active",
14
+ "disabled"
15
+ ]
16
+ },
17
+ "uiFramework": "mui"
18
+ },
19
+ "snapshot": {
20
+ "type": "COMPONENT_SET",
21
+ "variantGroupProperties": {
22
+ "Variant": {
23
+ "values": [
24
+ "Primary"
25
+ ]
26
+ },
27
+ "State": {
28
+ "values": [
29
+ "Default",
30
+ "Hover",
31
+ "Focus",
32
+ "Active",
33
+ "Disabled"
34
+ ]
35
+ }
36
+ },
37
+ "children": [
38
+ {
39
+ "type": "COMPONENT",
40
+ "variantProperties": {
41
+ "Variant": "Primary",
42
+ "State": "Default"
43
+ }
44
+ },
45
+ {
46
+ "type": "COMPONENT",
47
+ "variantProperties": {
48
+ "Variant": "Primary",
49
+ "State": "Hover"
50
+ }
51
+ },
52
+ {
53
+ "type": "COMPONENT",
54
+ "variantProperties": {
55
+ "Variant": "Primary",
56
+ "State": "Focus"
57
+ },
58
+ "effects": [
59
+ {
60
+ "type": "DROP_SHADOW",
61
+ "visible": true,
62
+ "spread": 3,
63
+ "colorVariable": "Color/Border/Focus"
64
+ }
65
+ ],
66
+ "strokeAlign": "INSIDE",
67
+ "strokeVariables": [],
68
+ "strokeWeightBound": false,
69
+ "children": []
70
+ },
71
+ {
72
+ "type": "COMPONENT",
73
+ "variantProperties": {
74
+ "Variant": "Primary",
75
+ "State": "Active"
76
+ }
77
+ },
78
+ {
79
+ "type": "COMPONENT",
80
+ "variantProperties": {
81
+ "Variant": "Primary",
82
+ "State": "Disabled"
83
+ }
84
+ }
85
+ ]
86
+ },
87
+ "expected": {
88
+ "structural-read-back": "pass",
89
+ "state-baseline": "pass",
90
+ "focus-indicator": "pass",
91
+ "focus-contrast": "skip"
92
+ }
93
+ }