@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,813 @@
1
+ # Figma stop-condition checks (GENERATED)
2
+
3
+ > **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/component-states.mjs`
4
+ > (the state table), `scripts/lib/contrast.mjs` (the WCAG contrast maths),
5
+ > `scripts/lib/figma-checks.mjs` (the pure checker, unit-tested in Node) +
6
+ > `scripts/lib/figma-checks-read.figma.js` (the Figma reader). Regenerate with
7
+ > `node scripts/build-figma-checks.mjs`; CI gates freshness with `--check`.
8
+
9
+ The canonical `figma_execute` snippet for the checks that can stop a component
10
+ build: the COMPONENT_SET assertion against the full variant matrix, the state
11
+ matrix, the focus ring, and the focus ring's contrast against the surfaces it
12
+ sits on (WCAG 1.4.11, 3:1, in every mode). The script is the rule; do not re-write
13
+ these checks by hand.
14
+
15
+ ## How to call it
16
+
17
+ 1. Paste the snippet below into `figma_execute`, with the two slots filled above it:
18
+ - `const EXPECT = { name, matrix, uiFramework, controlLayer, surfaces }` — `name` is the
19
+ component's real name; `matrix` is `{ axis: [values] }` for every axis of the
20
+ set; `uiFramework` is `project.uiFramework` from `design-system.json`;
21
+ `controlLayer` is the layer that is the actual control in a wrapped control,
22
+ copied from the architect's spec when it gives one (omit it otherwise);
23
+ `surfaces` is the list of colour-variable names of the page surfaces the
24
+ component sits on, copied from the architect's spec. Without it the
25
+ focus-contrast check is skipped. Surfaces are looked up among the file's
26
+ local variables only.
27
+ - `const MODE = 'spec'` or `'built'`.
28
+ 2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass
29
+ `timeout: 30000`.
30
+ 3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is
31
+ `pass`, `fail` or `skip`; `pass` ignores skips. On `fail`, fix what each failing
32
+ check's `detail` names and run it again, within the ~3-pass budget; after that,
33
+ `BLOCKED` naming the failing checks' `detail`. A `focus-contrast` fail is
34
+ the exception: return `BLOCKED` at once, naming its detail. A ring below 3:1 is
35
+ fixed by a token value, which no rebuild changes, and a missing surface is a
36
+ wrong name in the spec. Never edit a variable to make it pass.
37
+ 4. `'spec'` reads no node and runs before the first write. `'built'` reads the
38
+ COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs
39
+ in the self-verify loop. It throws only if that frame is missing; a frame with
40
+ no set, or a set Figma reports as conflicting, fails the structural check.
41
+
42
+ ```js
43
+ const EXPECT = { name: 'Chip', matrix: { state: ['default', 'hover'] }, uiFramework: 'shadcn' };
44
+ const MODE = 'spec';
45
+ // … the generated snippet …
46
+ return await runFigmaChecks(EXPECT, MODE);
47
+ ```
48
+
49
+ ## The snippet
50
+
51
+ ```js
52
+ // The archetype baseline for `state-incomplete` (`docs/specs/2026-09-12-verification-proof-bundle.md`,
53
+ // step 3). The prose in `references/figma-component-standards.md` (State handling) points at
54
+ // `references/state-baseline.md`, which is generated from this file.
55
+ //
56
+ // `default` is deliberately excluded from every baseline below: documenting the
57
+ // resting state is not documentation, and the Figma matrix requirement for it is
58
+ // the executor's, not this table's. Conditional states (`loading`, `error`,
59
+ // `success`) are deliberately excluded too — whether one applies to a given
60
+ // component is a design judgment, not something a baseline can assert. `selected`
61
+ // is not conditional for a choice control: a checkbox, radio, toggle, switch or
62
+ // chip that cannot be selected is not one.
63
+ //
64
+ // This table is the single source for both checks. `scripts/build-state-baseline.mjs`
65
+ // renders it into `references/state-baseline.md`, which the Figma executor reads;
66
+ // CI fails when that file is stale. MAINTENANCE: change this table, then run it.
67
+
68
+ // Defined here rather than imported from source-scan.mjs so this module has no
69
+ // imports and can be inlined into the Figma checks script. A test keeps the two
70
+ // copies in step.
71
+ const normalizeName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
72
+
73
+ const ARCHETYPES = ['button', 'input', 'choice', 'card', 'modal', 'badge', 'other'];
74
+
75
+ const BASELINE_STATES = {
76
+ button: ['hover', 'focus', 'active', 'disabled'],
77
+ input: ['hover', 'focus', 'disabled'],
78
+ choice: ['hover', 'focus', 'active', 'selected', 'disabled'],
79
+ card: [],
80
+ modal: [],
81
+ badge: [],
82
+ other: [],
83
+ };
84
+
85
+ const NAME_SYNONYMS = {
86
+ button: 'button',
87
+ input: 'input',
88
+ textfield: 'input',
89
+ textinput: 'input',
90
+ checkbox: 'choice',
91
+ radio: 'choice',
92
+ toggle: 'choice',
93
+ switch: 'choice',
94
+ chip: 'choice',
95
+ card: 'card',
96
+ modal: 'modal',
97
+ dialog: 'modal',
98
+ badge: 'badge',
99
+ tag: 'badge',
100
+ };
101
+
102
+ // Other names a recorded state goes by. shadcn and Radix call a checked checkbox
103
+ // `checked` and a pressed-in toggle `on`; both are `selected` here. `pressed` is
104
+ // not one: the standards use it for `active`.
105
+ const STATE_SYNONYMS = {
106
+ checked: 'selected',
107
+ on: 'selected',
108
+ };
109
+
110
+ function resolveArchetype(record) {
111
+ if (ARCHETYPES.includes(record.archetype)) return record.archetype;
112
+ const bySynonym = NAME_SYNONYMS[normalizeName(record.name)];
113
+ return bySynonym ?? null;
114
+ }
115
+
116
+ function missingStates(record) {
117
+ const archetype = resolveArchetype(record);
118
+ if (archetype === null) return [];
119
+ const baseline = BASELINE_STATES[archetype];
120
+ if (baseline.length === 0) return [];
121
+ const recordedKeys = new Set(
122
+ Object.keys(record.states ?? {}).map((key) => {
123
+ const name = normalizeName(key);
124
+ return STATE_SYNONYMS[name] ?? name;
125
+ }),
126
+ );
127
+ return baseline.filter((state) => !recordedKeys.has(normalizeName(state)));
128
+ }
129
+
130
+ // The WCAG contrast maths and the semantic colour pair table behind the
131
+ // `color-contrast` rule (`docs/specs/2026-09-12-token-contrast-validation.md`,
132
+ // step 1). Source of truth for the prose contract is
133
+ // `skills/token-builder/SKILL.md:250-265`.
134
+ //
135
+ // MAINTENANCE: a change to the semantic role set must change this table.
136
+
137
+ const AA_NORMAL_TEXT = 4.5;
138
+ // WCAG 1.4.11 non-text contrast, used by the Figma `focus-contrast` check.
139
+ const AA_NON_TEXT = 3;
140
+
141
+ // `text/disabled` is excluded: WCAG 1.4.3 exempts inactive user-interface
142
+ // components from the contrast minimum, and a disabled role is deliberately
143
+ // low-contrast — gating it would fail correct systems.
144
+ //
145
+ // `bg/subtle` and `bg/muted` are deliberately not paired in v1 (Open
146
+ // questions) — a system is free to use those roles for non-text surfaces, so
147
+ // pairing them risks the first false failures in the table.
148
+ //
149
+ // This table is text-on-surface only. Focus-ring contrast, the 3:1 non-text case
150
+ // (WCAG 1.4.11), is checked in the Figma checks (`figma-checks.mjs`,
151
+ // `focus-contrast`), where the build knows which ring sits on which surface.
152
+ //
153
+ // One threshold is applied to every pair because a colour role does not say
154
+ // what text size it is used at — `text/primary` is body copy as well as
155
+ // headings, so 4.5:1 is the binding requirement for it anyway.
156
+ const CONTRAST_PAIRS = [
157
+ {
158
+ fg: 'color.text.primary',
159
+ bg: 'color.bg.default',
160
+ threshold: AA_NORMAL_TEXT,
161
+ why: 'the default reading pair; if this fails, body copy fails everywhere at once',
162
+ },
163
+ {
164
+ fg: 'color.text.secondary',
165
+ bg: 'color.bg.default',
166
+ threshold: AA_NORMAL_TEXT,
167
+ why: 'secondary text is still text, and still has to clear AA on the same surface primary does',
168
+ },
169
+ {
170
+ fg: 'color.text.link',
171
+ bg: 'color.bg.default',
172
+ threshold: AA_NORMAL_TEXT,
173
+ why: 'a link that fails contrast is a link nobody can find, regardless of colour meaning',
174
+ },
175
+ {
176
+ fg: 'color.text.onEmphasis',
177
+ bg: 'color.bg.emphasis',
178
+ threshold: AA_NORMAL_TEXT,
179
+ why: 'the role exists because it sits on the emphasis fill; that is the whole reason it is not `text/inverse`',
180
+ },
181
+ {
182
+ fg: 'color.text.inverse',
183
+ bg: 'color.bg.inverse',
184
+ threshold: AA_NORMAL_TEXT,
185
+ why: 'the inverse pair flips with the theme, and both sides have to keep clearing together',
186
+ },
187
+ {
188
+ fg: 'color.status.success.text',
189
+ bg: 'color.status.success.bg',
190
+ threshold: AA_NORMAL_TEXT,
191
+ why: 'a status text role exists to sit on that status\'s own background, not any other surface',
192
+ },
193
+ {
194
+ fg: 'color.status.warning.text',
195
+ bg: 'color.status.warning.bg',
196
+ threshold: AA_NORMAL_TEXT,
197
+ why: 'a status text role exists to sit on that status\'s own background, not any other surface',
198
+ },
199
+ {
200
+ fg: 'color.status.danger.text',
201
+ bg: 'color.status.danger.bg',
202
+ threshold: AA_NORMAL_TEXT,
203
+ why: 'a status text role exists to sit on that status\'s own background, not any other surface',
204
+ },
205
+ ];
206
+
207
+ // WCAG 2.x sRGB relative luminance over 0-255 channels.
208
+ function relativeLuminance({ r, g, b }) {
209
+ const linearize = (channel) => {
210
+ const c = channel / 255;
211
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
212
+ };
213
+ return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
214
+ }
215
+
216
+ // Takes two `#rrggbb` strings (no alpha; the caller composites first) and
217
+ // returns the raw ratio, unrounded.
218
+ function contrastRatio(hexA, hexB) {
219
+ const lumA = relativeLuminance(parseHex(hexA));
220
+ const lumB = relativeLuminance(parseHex(hexB));
221
+ const lighter = Math.max(lumA, lumB);
222
+ const darker = Math.min(lumA, lumB);
223
+ return (lighter + 0.05) / (darker + 0.05);
224
+ }
225
+
226
+ // `#rrggbb` or `#rrggbbaa` → `{ r, g, b, a }` with `a` in 0-1. `null` for
227
+ // anything else.
228
+ function parseHex(hex) {
229
+ if (typeof hex !== 'string') return null;
230
+ const match = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(hex);
231
+ if (!match) return null;
232
+ const digits = match[1];
233
+ const r = parseInt(digits.slice(0, 2), 16);
234
+ const g = parseInt(digits.slice(2, 4), 16);
235
+ const b = parseInt(digits.slice(4, 6), 16);
236
+ const a = digits.length === 8 ? parseInt(digits.slice(6, 8), 16) / 255 : 1;
237
+ return { r, g, b, a };
238
+ }
239
+
240
+ // The `#rrggbb` of `fg` drawn over `bg` with src-over (`out = fg*a + bg*(1-a)`).
241
+ function composite(fgHex, bgHex) {
242
+ const fg = parseHex(fgHex);
243
+ if (fg.a === 0) return bgHex;
244
+ if (fg.a === 1) return fgHex.slice(0, 7);
245
+ const bg = parseHex(bgHex);
246
+ const mix = (fgChannel, bgChannel) => Math.round(fgChannel * fg.a + bgChannel * (1 - fg.a));
247
+ const toHex = (value) => value.toString(16).padStart(2, '0');
248
+ return `#${toHex(mix(fg.r, bg.r))}${toHex(mix(fg.g, bg.g))}${toHex(mix(fg.b, bg.b))}`;
249
+ }
250
+
251
+ // The Figma stop conditions as a pure checker: the COMPONENT_SET assertion against
252
+ // the full variant matrix, the state matrix, the focus ring, and the focus ring's
253
+ // contrast against the surfaces it sits on. ZERO imports,
254
+ // `export const`/`export function` only. This module is inlined, with
255
+ // component-states.mjs and contrast.mjs above it, into the generated Figma script
256
+ // (references/figma-checks.md) by build-figma-checks.mjs, so it must run in both
257
+ // Node and the Figma plugin sandbox.
258
+ //
259
+ // The state table is not imported. `withTable({ resolveArchetype, BASELINE_STATES,
260
+ // STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT })`
261
+ // takes it, the fold and the contrast maths from the caller and returns
262
+ // { checkSpec, checkBuilt }. In the generated script the caller passes the
263
+ // inlined component-states.mjs names; in tests it passes the module's own exports.
264
+ //
265
+ // Spec: docs/specs/2026-10-07-figma-checks-as-scripts.md
266
+ // Rules: references/figma-component-standards.md (State handling; audit items 8, 9)
267
+ //
268
+ // Spec (focus contrast): docs/specs/2026-10-13-focus-ring-contrast.md
269
+ //
270
+ // EXPECT: { name, matrix: { axis: [values] }, uiFramework, controlLayer?, surfaces? }
271
+ // surfaces: colour-variable names of the page surfaces the component sits on.
272
+ //
273
+ // SNAPSHOT (what the reader returns for the COMPONENT_SET in `WIP: <name>`):
274
+ // {
275
+ // type, // the set's node type, 'COMPONENT_SET'
276
+ // variantGroupProperties: { Axis: { values: [...] }, ... }, // only the keys are read
277
+ // children: [ VARIANT, ... ],
278
+ // colors: { [variableName]: { modes: { [modeName]: '#rrggbb' | '#rrggbbaa' | null }, defaultMode } },
279
+ // missingSurfaces: [name, ...] // EXPECT.surfaces names with no local colour variable
280
+ // }
281
+ // `colors` holds every colour variable read on focus variants and their
282
+ // controlLayer, plus each EXPECT.surfaces name that was found.
283
+ // VARIANT (every child): { type, variantProperties: { Axis: 'Value' } }
284
+ // A VARIANT whose state value folds to `focus` also carries FOCUS NODE fields
285
+ // (below), plus `controlLayer` when EXPECT names one.
286
+ // FOCUS NODE (the variant itself, and the controlLayer subtree):
287
+ // {
288
+ // effects: [{ type, visible, spread, colorVariable }], // colorVariable: bound colour variable name or null
289
+ // strokeAlign, // 'INSIDE' | 'OUTSIDE' | 'CENTER'
290
+ // strokeVariables: [name, ...], // names of variables bound to stroke colours
291
+ // strokeWeightBound, // boundVariables.strokeWeight is present
292
+ // children: [{ layoutPositioning, strokeAlign, strokeVariables, strokeWeightBound }] // direct children only
293
+ // }
294
+ // VARIANT.controlLayer: a FOCUS NODE, or null when EXPECT names a layer the variant does not have.
295
+ // Variable names are resolved by the reader and only for focus variants and their controlLayer.
296
+ //
297
+ // RESULT: { pass, checks: [{ name, result: 'pass' | 'fail' | 'skip', detail }] }
298
+ // built mode returns four checks: structural-read-back, state-baseline,
299
+ // focus-indicator, focus-contrast.
300
+ // `pass` ignores skips. A failed assertion is data; nothing here throws.
301
+
302
+ // The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
303
+ // null `uiFramework` uses the `ring` recipe. Any other value is out of scope.
304
+ // ring - DROP_SHADOW effect with a focus-bound colour and spread > 0, or an
305
+ // ABSOLUTE ring child with an OUTSIDE stroke bound to a focus variable
306
+ // mui - an input: the control's own stroke and its weight are bound; any other
307
+ // component uses the ring recipe
308
+ // outline - the control or a direct child carries an OUTSIDE stroke bound to a
309
+ // focus variable
310
+ const FRAMEWORKS = {
311
+ shadcn: { recipe: 'ring' },
312
+ tailwind: { recipe: 'ring' },
313
+ mui: { recipe: 'mui' },
314
+ 'vanilla-css': { recipe: 'outline' },
315
+ vanilla: { recipe: 'outline' },
316
+ };
317
+
318
+ function frameworkRecipe(uiFramework) {
319
+ if (uiFramework === undefined || uiFramework === null) return 'ring';
320
+ return Object.prototype.hasOwnProperty.call(FRAMEWORKS, uiFramework)
321
+ ? FRAMEWORKS[uiFramework].recipe
322
+ : null;
323
+ }
324
+
325
+ // A focus variable is any variable whose name has `focus` as a whole word: in a
326
+ // `/` segment of its own, or inside one split on `-`, `_`, spaces or a camelCase
327
+ // boundary. `border/focus`, `color/focus/ring`, `color/focusRing` and
328
+ // `interaction/border-primary-focus` count; `color/bg/focused` and a raw colour
329
+ // (null) do not.
330
+ function isFocusVariable(name) {
331
+ if (typeof name !== 'string') return false;
332
+ const words = name
333
+ .split('/')
334
+ .flatMap((seg) => seg.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[\s_-]+/));
335
+ return words.some((word) => word.toLowerCase() === 'focus');
336
+ }
337
+
338
+ // A choice control may carry selection on its own axis instead of as a `selected`
339
+ // value on the state axis: the sample's Checkbox has `checked: false | true |
340
+ // indeterminate`. An axis with one of these names satisfies `selected`.
341
+ const SELECTION_AXES = ['selected', 'checked', 'on'];
342
+
343
+ const hasFocusStroke = (node) => (node.strokeVariables ?? []).some(isFocusVariable);
344
+
345
+ function lacksRing(target, recipe, isInput) {
346
+ const children = target.children ?? [];
347
+ if (recipe === 'mui' && isInput) {
348
+ const lacks = [];
349
+ if (!hasFocusStroke(target)) lacks.push('a stroke bound to a focus variable');
350
+ if (!target.strokeWeightBound) lacks.push('a stroke weight bound to a variable');
351
+ return lacks;
352
+ }
353
+ if (recipe === 'outline') {
354
+ const outside = (n) => n.strokeAlign === 'OUTSIDE' && hasFocusStroke(n);
355
+ return outside(target) || children.some(outside)
356
+ ? []
357
+ : ['an OUTSIDE stroke bound to a focus variable on the control or a direct child'];
358
+ }
359
+ const shadow = (target.effects ?? []).some(
360
+ (e) => e.type === 'DROP_SHADOW' && e.visible !== false && e.spread > 0 && isFocusVariable(e.colorVariable),
361
+ );
362
+ const ringChild = children.some(
363
+ (c) => c.layoutPositioning === 'ABSOLUTE' && c.strokeAlign === 'OUTSIDE' && hasFocusStroke(c),
364
+ );
365
+ return shadow || ringChild
366
+ ? []
367
+ : [
368
+ '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',
369
+ ];
370
+ }
371
+
372
+ // The focus-variable names that make up the ring `lacksRing` accepted, for the
373
+ // contrast check. Same recipes, same tests.
374
+ function ringVariables(target, recipe, isInput) {
375
+ const children = target.children ?? [];
376
+ const focusStrokes = (node) => (node.strokeVariables ?? []).filter(isFocusVariable);
377
+ if (recipe === 'mui' && isInput) return focusStrokes(target);
378
+ if (recipe === 'outline') {
379
+ return [target, ...children].filter((n) => n.strokeAlign === 'OUTSIDE').flatMap(focusStrokes);
380
+ }
381
+ const shadows = (target.effects ?? [])
382
+ .filter((e) => e.type === 'DROP_SHADOW' && e.visible !== false && e.spread > 0 && isFocusVariable(e.colorVariable))
383
+ .map((e) => e.colorVariable);
384
+ const ringChildren = children
385
+ .filter((c) => c.layoutPositioning === 'ABSOLUTE' && c.strokeAlign === 'OUTSIDE')
386
+ .flatMap(focusStrokes);
387
+ return [...shadows, ...ringChildren];
388
+ }
389
+
390
+ function withTable({
391
+ resolveArchetype,
392
+ BASELINE_STATES,
393
+ STATE_SYNONYMS,
394
+ normalizeName,
395
+ contrastRatio,
396
+ composite,
397
+ parseHex,
398
+ AA_NON_TEXT,
399
+ }) {
400
+ const fold = normalizeName;
401
+ const foldState = (value) => {
402
+ const name = fold(value);
403
+ return STATE_SYNONYMS[name] ?? name;
404
+ };
405
+ const result = (checks) => ({ pass: checks.every((c) => c.result !== 'fail'), checks });
406
+ const label = (props) =>
407
+ Object.entries(props ?? {})
408
+ .map(([k, v]) => `${k}=${v}`)
409
+ .join(', ');
410
+
411
+ // Combinations as comparable strings, axes sorted by folded name.
412
+ const comboKey = (props) =>
413
+ Object.entries(props ?? {})
414
+ .map(([k, v]) => [fold(k), fold(v)])
415
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
416
+ .map(([k, v]) => `${k}=${v}`)
417
+ .join(', ');
418
+
419
+ const isFocusVariant = (child) =>
420
+ child.type === 'COMPONENT' &&
421
+ Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
422
+
423
+ function crossProduct(matrix) {
424
+ let combos = [{}];
425
+ for (const [axis, values] of Object.entries(matrix ?? {})) {
426
+ combos = combos.flatMap((combo) => values.map((value) => ({ ...combo, [axis]: value })));
427
+ }
428
+ return combos;
429
+ }
430
+
431
+ function structural(snapshot, expect) {
432
+ const name = 'structural-read-back';
433
+ const problems = [];
434
+ if (snapshot.type !== 'COMPONENT_SET') problems.push(`the node is ${snapshot.type}, not a COMPONENT_SET`);
435
+ const children = snapshot.children ?? [];
436
+ const notComponents = children.filter((c) => c.type !== 'COMPONENT');
437
+ if (notComponents.length > 0) {
438
+ problems.push(`${notComponents.length} of ${children.length} children are not COMPONENTs (${[...new Set(notComponents.map((c) => c.type))].join(', ')})`);
439
+ }
440
+
441
+ const builtAxes = Object.keys(snapshot.variantGroupProperties ?? {}).map(fold).sort();
442
+ const wantAxes = Object.keys(expect.matrix ?? {}).map(fold).sort();
443
+ const missingAxes = wantAxes.filter((a) => !builtAxes.includes(a));
444
+ const extraAxes = builtAxes.filter((a) => !wantAxes.includes(a));
445
+ if (missingAxes.length) problems.push(`missing axes: ${missingAxes.join(', ')}`);
446
+ if (extraAxes.length) problems.push(`extra axes: ${extraAxes.join(', ')}`);
447
+
448
+ const want = new Map(crossProduct(expect.matrix).map((combo) => [comboKey(combo), label(combo)]));
449
+ const seen = new Map();
450
+ const duplicates = [];
451
+ for (const child of children) {
452
+ const key = comboKey(child.variantProperties);
453
+ if (seen.has(key)) duplicates.push(label(child.variantProperties));
454
+ else seen.set(key, label(child.variantProperties));
455
+ }
456
+ const missing = [...want].filter(([key]) => !seen.has(key)).map(([, text]) => text);
457
+ const extra = [...seen].filter(([key]) => !want.has(key)).map(([, text]) => text);
458
+ if (missing.length) problems.push(`missing combinations: ${missing.join(' | ')}`);
459
+ if (extra.length) problems.push(`extra combinations: ${extra.join(' | ')}`);
460
+ if (duplicates.length) problems.push(`duplicate combinations: ${duplicates.join(' | ')}`);
461
+
462
+ return problems.length
463
+ ? { name, result: 'fail', detail: problems.join('; ') }
464
+ : { name, result: 'pass', detail: `COMPONENT_SET with ${children.length} COMPONENT variants matching the matrix` };
465
+ }
466
+
467
+ // `entries` are [axisName, value] pairs; only the `state` axis is read.
468
+ function stateBaseline(expect, entries) {
469
+ const name = 'state-baseline';
470
+ const archetype = resolveArchetype({ name: expect.name });
471
+ if (archetype === null) {
472
+ return { name, result: 'skip', detail: `"${expect.name}" is not a name in the state table` };
473
+ }
474
+ const baseline = BASELINE_STATES[archetype];
475
+ if (baseline.length === 0) {
476
+ return { name, result: 'skip', detail: `"${expect.name}" is a ${archetype}, which owes no states beyond default` };
477
+ }
478
+ const present = new Set(entries.filter(([axis]) => fold(axis) === 'state').map(([, value]) => foldState(value)));
479
+ if (entries.some(([axis]) => SELECTION_AXES.includes(fold(axis)))) present.add('selected');
480
+ const required = ['default', ...baseline];
481
+ const missing = required.filter((s) => !present.has(foldState(s)));
482
+ return missing.length
483
+ ? { name, result: 'fail', detail: `the state axis is missing: ${missing.join(', ')}` }
484
+ : { name, result: 'pass', detail: `the state axis has default and every ${archetype} state` };
485
+ }
486
+
487
+ function focusIndicator(snapshot, expect) {
488
+ const name = 'focus-indicator';
489
+ const archetype = resolveArchetype({ name: expect.name });
490
+ if (archetype === null) {
491
+ return { name, result: 'skip', detail: `"${expect.name}" is not a name in the state table` };
492
+ }
493
+ if (BASELINE_STATES[archetype].length === 0) {
494
+ return { name, result: 'skip', detail: `"${expect.name}" is a ${archetype}, which owes no states, so no focus ring is checked` };
495
+ }
496
+ const recipe = frameworkRecipe(expect.uiFramework);
497
+ if (recipe === null) {
498
+ return { name, result: 'skip', detail: `uiFramework "${expect.uiFramework}" is out of scope for the focus check` };
499
+ }
500
+ const variants = (snapshot.children ?? []).filter(isFocusVariant);
501
+ if (variants.length === 0) {
502
+ 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' };
503
+ }
504
+ const misses = [];
505
+ for (const variant of variants) {
506
+ let target = variant;
507
+ if (expect.controlLayer !== undefined && expect.controlLayer !== null) {
508
+ target = variant.controlLayer;
509
+ if (!target) {
510
+ misses.push(`${label(variant.variantProperties)}: no layer named "${expect.controlLayer}"`);
511
+ continue;
512
+ }
513
+ }
514
+ const lacks = lacksRing(target, recipe, archetype === 'input');
515
+ if (lacks.length) misses.push(`${label(variant.variantProperties)}: lacks ${lacks.join(' and ')}`);
516
+ }
517
+ return misses.length
518
+ ? { name, result: 'fail', detail: misses.join('; ') }
519
+ : { name, result: 'pass', detail: `all ${variants.length} focus variants carry a ${recipe}-recipe focus indicator` };
520
+ }
521
+
522
+ // WCAG 1.4.11: each ring colour against each named surface, in every mode either
523
+ // side has. Runs only on a ring focusIndicator accepted.
524
+ function focusContrast(snapshot, expect, indicator) {
525
+ const name = 'focus-contrast';
526
+ if (indicator.result !== 'pass') {
527
+ return { name, result: 'skip', detail: 'focus-indicator did not pass, so there is no ring to measure' };
528
+ }
529
+ const surfaces = expect.surfaces ?? [];
530
+ if (surfaces.length === 0) {
531
+ return {
532
+ name,
533
+ result: 'skip',
534
+ detail: 'no surfaces named; the architect names the colour variables of the surfaces the component sits on',
535
+ };
536
+ }
537
+ const missing = snapshot.missingSurfaces ?? [];
538
+ if (missing.length) {
539
+ return { name, result: 'fail', detail: `no local colour variable named: ${missing.join(', ')}` };
540
+ }
541
+
542
+ const recipe = frameworkRecipe(expect.uiFramework);
543
+ const isInput = resolveArchetype({ name: expect.name }) === 'input';
544
+ const hasLayer = expect.controlLayer !== undefined && expect.controlLayer !== null;
545
+ const rings = new Set();
546
+ for (const variant of (snapshot.children ?? []).filter(isFocusVariant)) {
547
+ const target = hasLayer ? variant.controlLayer : variant;
548
+ if (target) for (const ring of ringVariables(target, recipe, isInput)) rings.add(ring);
549
+ }
550
+
551
+ const colors = snapshot.colors ?? {};
552
+ // A mode's value: the same-named mode after the fold, else the default.
553
+ const valueIn = (entry, foldedMode) => {
554
+ const modes = entry?.modes ?? {};
555
+ const hit = Object.keys(modes).find((m) => fold(m) === foldedMode);
556
+ if (hit !== undefined) return modes[hit];
557
+ return entry?.defaultMode !== undefined ? modes[entry.defaultMode] ?? null : null;
558
+ };
559
+ const skipped = { unresolved: 0, translucentSurface: 0 };
560
+ const failures = [];
561
+ const modesSeen = new Set();
562
+ let pairs = 0;
563
+ for (const ring of [...rings].sort()) {
564
+ for (const surface of [...surfaces].sort()) {
565
+ const ringEntry = colors[ring];
566
+ const surfaceEntry = colors[surface];
567
+ // Modes come from the side(s) with more than one: a single-mode collection
568
+ // reads the same value in every mode, so its own mode name adds nothing.
569
+ // When both are single-mode, the ring's one mode is the only comparison.
570
+ const entries = [ringEntry, surfaceEntry];
571
+ const multi = entries.filter((e) => Object.keys(e?.modes ?? {}).length > 1);
572
+ const single = Object.keys(ringEntry?.modes ?? {}).length ? [ringEntry] : [surfaceEntry];
573
+ const modeNames = new Map();
574
+ for (const entry of multi.length ? multi : single) {
575
+ for (const m of Object.keys(entry?.modes ?? {})) if (!modeNames.has(fold(m))) modeNames.set(fold(m), m);
576
+ }
577
+ if (modeNames.size === 0) {
578
+ skipped.unresolved += 1;
579
+ continue;
580
+ }
581
+ for (const [foldedMode, mode] of [...modeNames].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
582
+ const ringHex = valueIn(ringEntry, foldedMode);
583
+ const surfaceHex = valueIn(surfaceEntry, foldedMode);
584
+ const surfaceParsed = parseHex(surfaceHex);
585
+ if (parseHex(ringHex) === null || surfaceParsed === null) {
586
+ skipped.unresolved += 1;
587
+ continue;
588
+ }
589
+ if (surfaceParsed.a < 1) {
590
+ skipped.translucentSurface += 1;
591
+ continue;
592
+ }
593
+ const surfaceOpaque = surfaceHex.slice(0, 7);
594
+ const ratio = contrastRatio(composite(ringHex, surfaceOpaque), surfaceOpaque);
595
+ pairs += 1;
596
+ modesSeen.add(foldedMode);
597
+ if (ratio < AA_NON_TEXT) {
598
+ const shown = (Math.floor(ratio * 100) / 100).toFixed(2);
599
+ failures.push(`${ring} on ${surface} in ${mode}: ${shown}:1, needs ${AA_NON_TEXT}:1`);
600
+ }
601
+ }
602
+ }
603
+ }
604
+ const skipNote = [
605
+ skipped.unresolved && `${skipped.unresolved} skipped as unresolved or not a colour`,
606
+ skipped.translucentSurface && `${skipped.translucentSurface} skipped for a translucent surface`,
607
+ ].filter(Boolean);
608
+ if (failures.length) return { name, result: 'fail', detail: failures.join('; ') };
609
+ if (pairs === 0) {
610
+ return { name, result: 'skip', detail: `no ring/surface pair could be compared: ${skipNote.join(', ') || 'no ring colour found'}` };
611
+ }
612
+ const passed = `${pairs} ring/surface pair(s) clear ${AA_NON_TEXT}:1 across ${modesSeen.size} mode(s)`;
613
+ return { name, result: 'pass', detail: skipNote.length ? `${passed}; ${skipNote.join(', ')}` : passed };
614
+ }
615
+
616
+ // Before anything is written: the matrix the spec asks for against the state table.
617
+ function checkSpec(expect) {
618
+ const entries = Object.entries(expect.matrix ?? {}).flatMap(([axis, values]) => values.map((v) => [axis, v]));
619
+ return result([stateBaseline(expect, entries)]);
620
+ }
621
+
622
+ // After the build: the node as read.
623
+ function checkBuilt(snapshot, expect) {
624
+ const entries = (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
625
+ const indicator = focusIndicator(snapshot, expect);
626
+ return result([
627
+ structural(snapshot, expect),
628
+ stateBaseline(expect, entries),
629
+ indicator,
630
+ focusContrast(snapshot, expect, indicator),
631
+ ]);
632
+ }
633
+
634
+ return { checkSpec, checkBuilt };
635
+ }
636
+
637
+ const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });
638
+
639
+ // Figma plugin-API reader for the stop-condition checks. This file is NOT a
640
+ // Node module — build-figma-checks.mjs concatenates it after the inlined
641
+ // component-states.mjs, contrast.mjs and figma-checks.mjs into
642
+ // references/figma-checks.md, and
643
+ // the result runs inside figma_execute (dynamic-page mode). Constraints honored
644
+ // here (see references/figma-scripting.md): async getters only, variable names
645
+ // and per-mode colour values resolved with getVariableByIdAsync and only for
646
+ // focus variants and their controlLayer, plus the EXPECT.surfaces names, which
647
+ // are looked up among the file's LOCAL colour variables only (a surface from a
648
+ // published library reads as missing). Untested in Node, like the doc-card
649
+ // renderer; the pure checker it feeds is unit-tested.
650
+ //
651
+ // In-scope globals when assembled: normalizeName (inlined
652
+ // component-states.mjs), checkSpec, checkBuilt (bound from withTable) and the
653
+ // caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
654
+ // shape is documented at the top of scripts/lib/figma-checks.mjs.
655
+
656
+ // Colour variables read on focus nodes, by name, for the contrast check.
657
+ const readVariables = new Map();
658
+
659
+ async function variableName(alias) {
660
+ if (!alias || !alias.id) return null;
661
+ const variable = await figma.variables.getVariableByIdAsync(alias.id);
662
+ if (!variable) return null;
663
+ if (variable.resolvedType === 'COLOR') readVariables.set(variable.name, variable);
664
+ return variable.name;
665
+ }
666
+
667
+ function colorHex(value) {
668
+ if (!value || typeof value !== 'object' || !('r' in value)) return null;
669
+ const byte = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
670
+ const alpha = value.a === undefined ? 1 : value.a;
671
+ return `#${byte(value.r)}${byte(value.g)}${byte(value.b)}${alpha < 1 ? byte(alpha) : ''}`;
672
+ }
673
+
674
+ // The value of `variable` in the mode named `modeName` (after the fold), else
675
+ // its collection's default mode, following aliases up to 10 hops.
676
+ async function resolveColor(variable, modeName, hops) {
677
+ if (!variable || hops > 10) return null;
678
+ const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
679
+ if (!collection) return null;
680
+ const mode = collection.modes.find((m) => normalizeName(m.name) === normalizeName(modeName));
681
+ const value = variable.valuesByMode[mode ? mode.modeId : collection.defaultModeId];
682
+ if (value && value.type === 'VARIABLE_ALIAS') {
683
+ return resolveColor(await figma.variables.getVariableByIdAsync(value.id), modeName, hops + 1);
684
+ }
685
+ return colorHex(value);
686
+ }
687
+
688
+ // { modes: { [modeName]: hex | null }, defaultMode } over the variable's own collection.
689
+ async function variableColors(variable) {
690
+ const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
691
+ if (!collection) return { modes: {}, defaultMode: undefined };
692
+ const modes = {};
693
+ for (const mode of collection.modes) modes[mode.name] = await resolveColor(variable, mode.name, 0);
694
+ const fallback = collection.modes.find((m) => m.modeId === collection.defaultModeId);
695
+ return { modes, defaultMode: fallback ? fallback.name : undefined };
696
+ }
697
+
698
+ // The fields the focus check reads from a variant, a control layer or a direct child.
699
+ async function readFocusNode(node, { withEffects }) {
700
+ const strokes = (node.boundVariables && node.boundVariables.strokes) || [];
701
+ const strokeVariables = [];
702
+ for (const alias of strokes) {
703
+ const name = await variableName(alias);
704
+ if (name !== null) strokeVariables.push(name);
705
+ }
706
+ const bound = node.boundVariables || {};
707
+ const out = {
708
+ strokeAlign: node.strokeAlign,
709
+ strokeVariables,
710
+ strokeWeightBound: Boolean(
711
+ bound.strokeWeight || bound.strokeTopWeight || bound.strokeRightWeight || bound.strokeBottomWeight || bound.strokeLeftWeight,
712
+ ),
713
+ };
714
+ if (withEffects) {
715
+ out.effects = [];
716
+ for (const effect of node.effects || []) {
717
+ const colorAlias = effect.boundVariables && effect.boundVariables.color;
718
+ out.effects.push({
719
+ type: effect.type,
720
+ visible: effect.visible,
721
+ spread: effect.spread,
722
+ colorVariable: await variableName(colorAlias),
723
+ });
724
+ }
725
+ } else {
726
+ out.layoutPositioning = node.layoutPositioning;
727
+ }
728
+ return out;
729
+ }
730
+
731
+ async function readFocusTree(node) {
732
+ const focus = await readFocusNode(node, { withEffects: true });
733
+ focus.children = [];
734
+ for (const child of node.children || []) {
735
+ focus.children.push(await readFocusNode(child, { withEffects: false }));
736
+ }
737
+ return focus;
738
+ }
739
+
740
+ // The getter throws when Figma flags the set as conflicting (duplicate variant
741
+ // combinations). Fall back to the axes the children declare, so the structural
742
+ // check names the duplicates instead of the read throwing.
743
+ function groupProperties(set) {
744
+ try {
745
+ return set.variantGroupProperties;
746
+ } catch {
747
+ const axes = {};
748
+ for (const child of set.children) {
749
+ for (const axis of Object.keys(child.variantProperties || {})) axes[axis] = { values: [] };
750
+ }
751
+ return axes;
752
+ }
753
+ }
754
+
755
+ async function readComponentSet(expect) {
756
+ readVariables.clear();
757
+ await figma.currentPage.loadAsync();
758
+ const frameName = `WIP: ${expect.name}`;
759
+ const frame = figma.currentPage.findOne((n) => n.name === frameName);
760
+ if (!frame) throw new Error(`no node named "${frameName}" on the current page`);
761
+ const set = frame.type === 'COMPONENT_SET' ? frame : (frame.children || []).find((c) => c.type === 'COMPONENT_SET');
762
+ // A frame holding plain FRAMEs where the set should be is a failed assertion,
763
+ // not a missing target: report what is there so the structural check names it.
764
+ if (!set) {
765
+ const first = (frame.children || [])[0];
766
+ return { type: first ? first.type : 'NONE', variantGroupProperties: {}, children: [] };
767
+ }
768
+
769
+ const isFocusState = (props) =>
770
+ Object.entries(props || {}).some(([k, v]) => {
771
+ const state = normalizeName(v);
772
+ return normalizeName(k) === 'state' && state === 'focus';
773
+ });
774
+
775
+ const children = [];
776
+ for (const child of set.children) {
777
+ const read = { type: child.type, variantProperties: child.variantProperties };
778
+ if (child.type === 'COMPONENT' && isFocusState(child.variantProperties)) {
779
+ Object.assign(read, await readFocusTree(child));
780
+ if (expect.controlLayer !== undefined && expect.controlLayer !== null) {
781
+ const layer = child.findOne((n) => n.name === expect.controlLayer);
782
+ read.controlLayer = layer ? await readFocusTree(layer) : null;
783
+ }
784
+ }
785
+ children.push(read);
786
+ }
787
+ const colors = {};
788
+ for (const [name, variable] of readVariables) colors[name] = await variableColors(variable);
789
+ const missingSurfaces = [];
790
+ const surfaces = expect.surfaces || [];
791
+ if (surfaces.length) {
792
+ const local = await figma.variables.getLocalVariablesAsync('COLOR');
793
+ for (const name of surfaces) {
794
+ const variable = local.find((v) => v.name === name);
795
+ if (variable) colors[name] = await variableColors(variable);
796
+ else missingSurfaces.push(name);
797
+ }
798
+ }
799
+ return {
800
+ type: set.type,
801
+ variantGroupProperties: groupProperties(set),
802
+ children,
803
+ colors,
804
+ missingSurfaces,
805
+ };
806
+ }
807
+
808
+ async function runFigmaChecks(EXPECT, MODE) {
809
+ if (MODE === 'spec') return checkSpec(EXPECT);
810
+ if (MODE === 'built') return checkBuilt(await readComponentSet(EXPECT), EXPECT);
811
+ throw new Error(`MODE must be 'spec' or 'built', got ${JSON.stringify(MODE)}`);
812
+ }
813
+ ```