@radicool/throughline 0.23.0 → 0.25.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 (71) hide show
  1. package/README.md +7 -5
  2. package/adapters/codex/AGENTS.md +1 -0
  3. package/adapters/codex/prompts/check-figma.md +41 -0
  4. package/adapters/codex/prompts/component-builder.md +8 -4
  5. package/adapters/cursor/.cursor/commands/check-figma.md +41 -0
  6. package/adapters/cursor/.cursor/rules/component-builder.mdc +8 -4
  7. package/adapters/generic/AGENTS.md +1 -0
  8. package/adapters/generic/commands/check-figma.md +41 -0
  9. package/adapters/generic/skills/component-builder/SKILL.md +8 -4
  10. package/package.json +1 -1
  11. package/references/figma-adherence.md +442 -0
  12. package/references/figma-checks.md +432 -40
  13. package/references/figma-component-standards.md +8 -0
  14. package/references/proof-bundle.md +1 -0
  15. package/scripts/README.md +7 -4
  16. package/scripts/build-figma-checks.mjs +139 -28
  17. package/scripts/install.mjs +5 -1
  18. package/scripts/lib/contrast.mjs +5 -2
  19. package/scripts/lib/figma-adherence-read.figma.js +207 -0
  20. package/scripts/lib/figma-adherence.mjs +146 -0
  21. package/scripts/lib/figma-checks-read.figma.js +68 -14
  22. package/scripts/lib/figma-checks.mjs +173 -17
  23. package/scripts/lib/figma-variables-read.figma.js +39 -0
  24. package/scripts/lib/fixtures/figma-adherence/clean.json +90 -0
  25. package/scripts/lib/fixtures/figma-adherence/detached-library.json +116 -0
  26. package/scripts/lib/fixtures/figma-adherence/ds01-gaps.json +84 -0
  27. package/scripts/lib/fixtures/figma-adherence/inert-all.json +45 -0
  28. package/scripts/lib/fixtures/figma-adherence/mixed-text.json +61 -0
  29. package/scripts/lib/fixtures/figma-adherence/nothing-scanned.json +49 -0
  30. package/scripts/lib/fixtures/figma-adherence/off-scale-gap.json +65 -0
  31. package/scripts/lib/fixtures/figma-adherence/override-raw.json +72 -0
  32. package/scripts/lib/fixtures/figma-adherence/padding-raw.json +74 -0
  33. package/scripts/lib/fixtures/figma-adherence/raw-colour-token.json +70 -0
  34. package/scripts/lib/fixtures/figma-adherence/root-radius.json +88 -0
  35. package/scripts/lib/fixtures/figma-adherence/skip-colour.json +86 -0
  36. package/scripts/lib/fixtures/figma-adherence/zygarden-override-bound.json +85 -0
  37. package/scripts/lib/fixtures/figma-adherence/zygarden-text-raw.json +208 -0
  38. package/scripts/lib/fixtures/figma-checks/avatar-not-in-table.json +2 -1
  39. package/scripts/lib/fixtures/figma-checks/button-ios-swift-skipped.json +2 -1
  40. package/scripts/lib/fixtures/figma-checks/button-missing-default.json +2 -1
  41. package/scripts/lib/fixtures/figma-checks/button-mui-ring.json +2 -1
  42. package/scripts/lib/fixtures/figma-checks/button-no-focus-variant.json +2 -1
  43. package/scripts/lib/fixtures/figma-checks/button-plain-frames.json +2 -1
  44. package/scripts/lib/fixtures/figma-checks/button-ring-child-not-absolute.json +2 -1
  45. package/scripts/lib/fixtures/figma-checks/button-ring-hidden.json +2 -1
  46. package/scripts/lib/fixtures/figma-checks/button-ring-unbound-colour.json +4 -3
  47. package/scripts/lib/fixtures/figma-checks/button-shadcn-filled.json +2 -1
  48. package/scripts/lib/fixtures/figma-checks/button-sparse.json +2 -1
  49. package/scripts/lib/fixtures/figma-checks/button-spread-zero.json +2 -1
  50. package/scripts/lib/fixtures/figma-checks/button-transparent-ring-child.json +2 -1
  51. package/scripts/lib/fixtures/figma-checks/button-vanilla-inside.json +2 -1
  52. package/scripts/lib/fixtures/figma-checks/button-vanilla-outside.json +2 -1
  53. package/scripts/lib/fixtures/figma-checks/button-wrong-combination.json +2 -1
  54. package/scripts/lib/fixtures/figma-checks/checkbox-checked-axis.real.json +2 -1
  55. package/scripts/lib/fixtures/figma-checks/checkbox-checked-synonym.json +2 -1
  56. package/scripts/lib/fixtures/figma-checks/checkbox-missing-selected.json +2 -1
  57. package/scripts/lib/fixtures/figma-checks/indicator-failed.json +106 -0
  58. package/scripts/lib/fixtures/figma-checks/input-control-layer-missing.json +2 -1
  59. package/scripts/lib/fixtures/figma-checks/input-control-layer-ring-on-wrapper.json +2 -1
  60. package/scripts/lib/fixtures/figma-checks/input-control-layer.json +2 -1
  61. package/scripts/lib/fixtures/figma-checks/input-mui-weight-unbound.json +2 -1
  62. package/scripts/lib/fixtures/figma-checks/input-mui.json +2 -1
  63. package/scripts/lib/fixtures/figma-checks/ring-single-mode.json +112 -0
  64. package/scripts/lib/fixtures/figma-checks/ring-translucent.json +111 -0
  65. package/scripts/lib/fixtures/figma-checks/sample-button.real.json +2 -1
  66. package/scripts/lib/fixtures/figma-checks/sample-ring-on-muted-raw.json +113 -0
  67. package/scripts/lib/fixtures/figma-checks/sample-ring-passes.json +169 -0
  68. package/scripts/lib/fixtures/figma-checks/surface-missing.json +108 -0
  69. package/scripts/lib/fixtures/figma-checks/surface-single-mode.json +112 -0
  70. package/scripts/lib/fixtures/figma-checks/surface-translucent.json +112 -0
  71. package/scripts/lib/fixtures/figma-checks/zygarden-ring-light-fails.json +113 -0
@@ -1,34 +1,55 @@
1
1
  # Figma stop-condition checks (GENERATED)
2
2
 
3
3
  > **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/component-states.mjs`
4
- > (the state table), `scripts/lib/figma-checks.mjs` (the pure checker, unit-tested in
5
- > Node) + `scripts/lib/figma-checks-read.figma.js` (the Figma reader). Regenerate with
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
6
7
  > `node scripts/build-figma-checks.mjs`; CI gates freshness with `--check`.
7
8
 
8
9
  The canonical `figma_execute` snippet for the checks that can stop a component
9
10
  build: the COMPONENT_SET assertion against the full variant matrix, the state
10
- matrix, and the focus ring. The script is the rule; do not re-write these checks
11
- by hand.
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.
12
14
 
13
15
  ## How to call it
14
16
 
15
17
  1. Paste the snippet below into `figma_execute`, with the two slots filled above it:
16
- - `const EXPECT = { name, matrix, uiFramework, controlLayer }` — `name` is the
18
+ - `const EXPECT = { name, matrix, uiFramework, controlLayer, surfaces }` — `name` is the
17
19
  component's real name; `matrix` is `{ axis: [values] }` for every axis of the
18
20
  set; `uiFramework` is `project.uiFramework` from `design-system.json`;
19
21
  `controlLayer` is the layer that is the actual control in a wrapped control,
20
- copied from the architect's spec when it gives one (omit it otherwise).
21
- - `const MODE = 'spec'` or `'built'`.
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'`, `'built'` or `'existing'`.
22
28
  2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass
23
29
  `timeout: 30000`.
24
30
  3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is
25
31
  `pass`, `fail` or `skip`; `pass` ignores skips. On `fail`, fix what each failing
26
32
  check's `detail` names and run it again, within the ~3-pass budget; after that,
27
- `BLOCKED` naming the failing checks' `detail`.
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.
28
37
  4. `'spec'` reads no node and runs before the first write. `'built'` reads the
29
38
  COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs
30
39
  in the self-verify loop. It throws only if that frame is missing; a frame with
31
40
  no set, or a set Figma reports as conflicting, fails the structural check.
41
+ 5. `'existing'` checks a set already in the library, read-only: the first
42
+ COMPONENT_SET whose name matches `name` ignoring case and punctuation. Add
43
+ `page` to `EXPECT` (the page holding the components) so only that page is
44
+ loaded; without it every page loads in turn, which on a large file can stall
45
+ the bridge. `matrix` is not needed. There is no spec to copy `controlLayer` and
46
+ `surfaces` from, so the caller names them: `surfaces` as for a build, and
47
+ `controlLayer` for a wrapped control whose ring sits on an inner layer (without
48
+ it, a correct ring on an inner layer reads as missing). It runs the state,
49
+ focus-ring and focus-contrast checks against the set's own variants, not the
50
+ structural check. A set whose focus state is drawn some other way, such as an
51
+ INSIDE stroke, fails the focus-ring check and skips contrast. It throws when no
52
+ set has that name.
32
53
 
33
54
  ```js
34
55
  const EXPECT = { name: 'Chip', matrix: { state: ['default', 'hover'] }, uiFramework: 'shadcn' };
@@ -118,29 +139,159 @@ function missingStates(record) {
118
139
  return baseline.filter((state) => !recordedKeys.has(normalizeName(state)));
119
140
  }
120
141
 
142
+ // The WCAG contrast maths and the semantic colour pair table behind the
143
+ // `color-contrast` rule (`docs/specs/2026-09-12-token-contrast-validation.md`,
144
+ // step 1). Source of truth for the prose contract is
145
+ // `skills/token-builder/SKILL.md:250-265`.
146
+ //
147
+ // MAINTENANCE: a change to the semantic role set must change this table.
148
+
149
+ const AA_NORMAL_TEXT = 4.5;
150
+ // WCAG 1.4.11 non-text contrast, used by the Figma `focus-contrast` check.
151
+ const AA_NON_TEXT = 3;
152
+
153
+ // `text/disabled` is excluded: WCAG 1.4.3 exempts inactive user-interface
154
+ // components from the contrast minimum, and a disabled role is deliberately
155
+ // low-contrast — gating it would fail correct systems.
156
+ //
157
+ // `bg/subtle` and `bg/muted` are deliberately not paired in v1 (Open
158
+ // questions) — a system is free to use those roles for non-text surfaces, so
159
+ // pairing them risks the first false failures in the table.
160
+ //
161
+ // This table is text-on-surface only. Focus-ring contrast, the 3:1 non-text case
162
+ // (WCAG 1.4.11), is checked in the Figma checks (`figma-checks.mjs`,
163
+ // `focus-contrast`), where the build knows which ring sits on which surface.
164
+ //
165
+ // One threshold is applied to every pair because a colour role does not say
166
+ // what text size it is used at — `text/primary` is body copy as well as
167
+ // headings, so 4.5:1 is the binding requirement for it anyway.
168
+ const CONTRAST_PAIRS = [
169
+ {
170
+ fg: 'color.text.primary',
171
+ bg: 'color.bg.default',
172
+ threshold: AA_NORMAL_TEXT,
173
+ why: 'the default reading pair; if this fails, body copy fails everywhere at once',
174
+ },
175
+ {
176
+ fg: 'color.text.secondary',
177
+ bg: 'color.bg.default',
178
+ threshold: AA_NORMAL_TEXT,
179
+ why: 'secondary text is still text, and still has to clear AA on the same surface primary does',
180
+ },
181
+ {
182
+ fg: 'color.text.link',
183
+ bg: 'color.bg.default',
184
+ threshold: AA_NORMAL_TEXT,
185
+ why: 'a link that fails contrast is a link nobody can find, regardless of colour meaning',
186
+ },
187
+ {
188
+ fg: 'color.text.onEmphasis',
189
+ bg: 'color.bg.emphasis',
190
+ threshold: AA_NORMAL_TEXT,
191
+ why: 'the role exists because it sits on the emphasis fill; that is the whole reason it is not `text/inverse`',
192
+ },
193
+ {
194
+ fg: 'color.text.inverse',
195
+ bg: 'color.bg.inverse',
196
+ threshold: AA_NORMAL_TEXT,
197
+ why: 'the inverse pair flips with the theme, and both sides have to keep clearing together',
198
+ },
199
+ {
200
+ fg: 'color.status.success.text',
201
+ bg: 'color.status.success.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
+ fg: 'color.status.warning.text',
207
+ bg: 'color.status.warning.bg',
208
+ threshold: AA_NORMAL_TEXT,
209
+ why: 'a status text role exists to sit on that status\'s own background, not any other surface',
210
+ },
211
+ {
212
+ fg: 'color.status.danger.text',
213
+ bg: 'color.status.danger.bg',
214
+ threshold: AA_NORMAL_TEXT,
215
+ why: 'a status text role exists to sit on that status\'s own background, not any other surface',
216
+ },
217
+ ];
218
+
219
+ // WCAG 2.x sRGB relative luminance over 0-255 channels.
220
+ function relativeLuminance({ r, g, b }) {
221
+ const linearize = (channel) => {
222
+ const c = channel / 255;
223
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
224
+ };
225
+ return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
226
+ }
227
+
228
+ // Takes two `#rrggbb` strings (no alpha; the caller composites first) and
229
+ // returns the raw ratio, unrounded.
230
+ function contrastRatio(hexA, hexB) {
231
+ const lumA = relativeLuminance(parseHex(hexA));
232
+ const lumB = relativeLuminance(parseHex(hexB));
233
+ const lighter = Math.max(lumA, lumB);
234
+ const darker = Math.min(lumA, lumB);
235
+ return (lighter + 0.05) / (darker + 0.05);
236
+ }
237
+
238
+ // `#rrggbb` or `#rrggbbaa` → `{ r, g, b, a }` with `a` in 0-1. `null` for
239
+ // anything else.
240
+ function parseHex(hex) {
241
+ if (typeof hex !== 'string') return null;
242
+ const match = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(hex);
243
+ if (!match) return null;
244
+ const digits = match[1];
245
+ const r = parseInt(digits.slice(0, 2), 16);
246
+ const g = parseInt(digits.slice(2, 4), 16);
247
+ const b = parseInt(digits.slice(4, 6), 16);
248
+ const a = digits.length === 8 ? parseInt(digits.slice(6, 8), 16) / 255 : 1;
249
+ return { r, g, b, a };
250
+ }
251
+
252
+ // The `#rrggbb` of `fg` drawn over `bg` with src-over (`out = fg*a + bg*(1-a)`).
253
+ function composite(fgHex, bgHex) {
254
+ const fg = parseHex(fgHex);
255
+ if (fg.a === 0) return bgHex;
256
+ if (fg.a === 1) return fgHex.slice(0, 7);
257
+ const bg = parseHex(bgHex);
258
+ const mix = (fgChannel, bgChannel) => Math.round(fgChannel * fg.a + bgChannel * (1 - fg.a));
259
+ const toHex = (value) => value.toString(16).padStart(2, '0');
260
+ return `#${toHex(mix(fg.r, bg.r))}${toHex(mix(fg.g, bg.g))}${toHex(mix(fg.b, bg.b))}`;
261
+ }
262
+
121
263
  // The Figma stop conditions as a pure checker: the COMPONENT_SET assertion against
122
- // the full variant matrix, the state matrix, and the focus ring. ZERO imports,
264
+ // the full variant matrix, the state matrix, the focus ring, and the focus ring's
265
+ // contrast against the surfaces it sits on. ZERO imports,
123
266
  // `export const`/`export function` only. This module is inlined, with
124
- // component-states.mjs above it, into the generated Figma script
267
+ // component-states.mjs and contrast.mjs above it, into the generated Figma script
125
268
  // (references/figma-checks.md) by build-figma-checks.mjs, so it must run in both
126
269
  // Node and the Figma plugin sandbox.
127
270
  //
128
271
  // The state table is not imported. `withTable({ resolveArchetype, BASELINE_STATES,
129
- // STATE_SYNONYMS, normalizeName })` takes it, and the fold, from the caller and
130
- // returns { checkSpec, checkBuilt }. In the generated script the caller passes the
272
+ // STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT })`
273
+ // takes it, the fold and the contrast maths from the caller and returns
274
+ // { checkSpec, checkBuilt, checkExisting }. In the generated script the caller passes the
131
275
  // inlined component-states.mjs names; in tests it passes the module's own exports.
132
276
  //
133
277
  // Spec: docs/specs/2026-10-07-figma-checks-as-scripts.md
134
278
  // Rules: references/figma-component-standards.md (State handling; audit items 8, 9)
135
279
  //
136
- // EXPECT: { name, matrix: { axis: [values] }, uiFramework, controlLayer? }
280
+ // Spec (focus contrast): docs/specs/2026-10-13-focus-ring-contrast.md
281
+ //
282
+ // EXPECT: { name, matrix: { axis: [values] }, uiFramework, controlLayer?, surfaces? }
283
+ // surfaces: colour-variable names of the page surfaces the component sits on.
137
284
  //
138
285
  // SNAPSHOT (what the reader returns for the COMPONENT_SET in `WIP: <name>`):
139
286
  // {
140
287
  // type, // the set's node type, 'COMPONENT_SET'
141
288
  // variantGroupProperties: { Axis: { values: [...] }, ... }, // only the keys are read
142
- // children: [ VARIANT, ... ]
289
+ // children: [ VARIANT, ... ],
290
+ // colors: { [variableName]: { modes: { [modeName]: '#rrggbb' | '#rrggbbaa' | null }, defaultMode } },
291
+ // missingSurfaces: [name, ...] // EXPECT.surfaces names with no local colour variable
143
292
  // }
293
+ // `colors` holds every colour variable read on focus variants and their
294
+ // controlLayer, plus each EXPECT.surfaces name that was found.
144
295
  // VARIANT (every child): { type, variantProperties: { Axis: 'Value' } }
145
296
  // A VARIANT whose state value folds to `focus` also carries FOCUS NODE fields
146
297
  // (below), plus `controlLayer` when EXPECT names one.
@@ -156,6 +307,9 @@ function missingStates(record) {
156
307
  // Variable names are resolved by the reader and only for focus variants and their controlLayer.
157
308
  //
158
309
  // RESULT: { pass, checks: [{ name, result: 'pass' | 'fail' | 'skip', detail }] }
310
+ // built mode returns four checks: structural-read-back, state-baseline,
311
+ // focus-indicator, focus-contrast. existing mode (a set already in the library,
312
+ // docs/specs/2026-10-13-figma-adherence-gate.md) returns the last three.
159
313
  // `pass` ignores skips. A failed assertion is data; nothing here throws.
160
314
 
161
315
  // The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
@@ -181,11 +335,17 @@ function frameworkRecipe(uiFramework) {
181
335
  : null;
182
336
  }
183
337
 
184
- // A focus variable is any variable whose name has a `focus` segment:
185
- // `border/focus`, `color/border/focus` and `color/focus/ring` count; `focusRing` and
186
- // a raw colour (null) do not.
338
+ // A focus variable is any variable whose name has `focus` as a whole word: in a
339
+ // `/` segment of its own, or inside one split on `-`, `_`, spaces or a camelCase
340
+ // boundary. `border/focus`, `color/focus/ring`, `color/focusRing` and
341
+ // `interaction/border-primary-focus` count; `color/bg/focused` and a raw colour
342
+ // (null) do not.
187
343
  function isFocusVariable(name) {
188
- return typeof name === 'string' && name.split('/').some((seg) => seg.trim().toLowerCase() === 'focus');
344
+ if (typeof name !== 'string') return false;
345
+ const words = name
346
+ .split('/')
347
+ .flatMap((seg) => seg.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[\s_-]+/));
348
+ return words.some((word) => word.toLowerCase() === 'focus');
189
349
  }
190
350
 
191
351
  // A choice control may carry selection on its own axis instead of as a `selected`
@@ -222,7 +382,34 @@ function lacksRing(target, recipe, isInput) {
222
382
  ];
223
383
  }
224
384
 
225
- function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName }) {
385
+ // The focus-variable names that make up the ring `lacksRing` accepted, for the
386
+ // contrast check. Same recipes, same tests.
387
+ function ringVariables(target, recipe, isInput) {
388
+ const children = target.children ?? [];
389
+ const focusStrokes = (node) => (node.strokeVariables ?? []).filter(isFocusVariable);
390
+ if (recipe === 'mui' && isInput) return focusStrokes(target);
391
+ if (recipe === 'outline') {
392
+ return [target, ...children].filter((n) => n.strokeAlign === 'OUTSIDE').flatMap(focusStrokes);
393
+ }
394
+ const shadows = (target.effects ?? [])
395
+ .filter((e) => e.type === 'DROP_SHADOW' && e.visible !== false && e.spread > 0 && isFocusVariable(e.colorVariable))
396
+ .map((e) => e.colorVariable);
397
+ const ringChildren = children
398
+ .filter((c) => c.layoutPositioning === 'ABSOLUTE' && c.strokeAlign === 'OUTSIDE')
399
+ .flatMap(focusStrokes);
400
+ return [...shadows, ...ringChildren];
401
+ }
402
+
403
+ function withTable({
404
+ resolveArchetype,
405
+ BASELINE_STATES,
406
+ STATE_SYNONYMS,
407
+ normalizeName,
408
+ contrastRatio,
409
+ composite,
410
+ parseHex,
411
+ AA_NON_TEXT,
412
+ }) {
226
413
  const fold = normalizeName;
227
414
  const foldState = (value) => {
228
415
  const name = fold(value);
@@ -242,6 +429,10 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
242
429
  .map(([k, v]) => `${k}=${v}`)
243
430
  .join(', ');
244
431
 
432
+ const isFocusVariant = (child) =>
433
+ child.type === 'COMPONENT' &&
434
+ Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
435
+
245
436
  function crossProduct(matrix) {
246
437
  let combos = [{}];
247
438
  for (const [axis, values] of Object.entries(matrix ?? {})) {
@@ -319,9 +510,6 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
319
510
  if (recipe === null) {
320
511
  return { name, result: 'skip', detail: `uiFramework "${expect.uiFramework}" is out of scope for the focus check` };
321
512
  }
322
- const isFocusVariant = (child) =>
323
- child.type === 'COMPONENT' &&
324
- Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
325
513
  const variants = (snapshot.children ?? []).filter(isFocusVariant);
326
514
  if (variants.length === 0) {
327
515
  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' };
@@ -344,41 +532,200 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
344
532
  : { name, result: 'pass', detail: `all ${variants.length} focus variants carry a ${recipe}-recipe focus indicator` };
345
533
  }
346
534
 
535
+ // WCAG 1.4.11: each ring colour against each named surface, in every mode either
536
+ // side has. Runs only on a ring focusIndicator accepted.
537
+ function focusContrast(snapshot, expect, indicator) {
538
+ const name = 'focus-contrast';
539
+ if (indicator.result !== 'pass') {
540
+ return { name, result: 'skip', detail: 'focus-indicator did not pass, so there is no ring to measure' };
541
+ }
542
+ const surfaces = expect.surfaces ?? [];
543
+ if (surfaces.length === 0) {
544
+ return {
545
+ name,
546
+ result: 'skip',
547
+ detail: 'no surfaces named; name the colour variables of the surfaces the component sits on (from the architect spec, or ask the user)',
548
+ };
549
+ }
550
+ const missing = snapshot.missingSurfaces ?? [];
551
+ if (missing.length) {
552
+ return { name, result: 'fail', detail: `no local colour variable named: ${missing.join(', ')}` };
553
+ }
554
+
555
+ const recipe = frameworkRecipe(expect.uiFramework);
556
+ const isInput = resolveArchetype({ name: expect.name }) === 'input';
557
+ const hasLayer = expect.controlLayer !== undefined && expect.controlLayer !== null;
558
+ const rings = new Set();
559
+ for (const variant of (snapshot.children ?? []).filter(isFocusVariant)) {
560
+ const target = hasLayer ? variant.controlLayer : variant;
561
+ if (target) for (const ring of ringVariables(target, recipe, isInput)) rings.add(ring);
562
+ }
563
+
564
+ const colors = snapshot.colors ?? {};
565
+ // A mode's value: the same-named mode after the fold, else the default.
566
+ const valueIn = (entry, foldedMode) => {
567
+ const modes = entry?.modes ?? {};
568
+ const hit = Object.keys(modes).find((m) => fold(m) === foldedMode);
569
+ if (hit !== undefined) return modes[hit];
570
+ return entry?.defaultMode !== undefined ? modes[entry.defaultMode] ?? null : null;
571
+ };
572
+ const skipped = { unresolved: 0, translucentSurface: 0 };
573
+ const failures = [];
574
+ const modesSeen = new Set();
575
+ let pairs = 0;
576
+ for (const ring of [...rings].sort()) {
577
+ for (const surface of [...surfaces].sort()) {
578
+ const ringEntry = colors[ring];
579
+ const surfaceEntry = colors[surface];
580
+ // Modes come from the side(s) with more than one: a single-mode collection
581
+ // reads the same value in every mode, so its own mode name adds nothing.
582
+ // When both are single-mode, the ring's one mode is the only comparison.
583
+ const entries = [ringEntry, surfaceEntry];
584
+ const multi = entries.filter((e) => Object.keys(e?.modes ?? {}).length > 1);
585
+ const single = Object.keys(ringEntry?.modes ?? {}).length ? [ringEntry] : [surfaceEntry];
586
+ const modeNames = new Map();
587
+ for (const entry of multi.length ? multi : single) {
588
+ for (const m of Object.keys(entry?.modes ?? {})) if (!modeNames.has(fold(m))) modeNames.set(fold(m), m);
589
+ }
590
+ if (modeNames.size === 0) {
591
+ skipped.unresolved += 1;
592
+ continue;
593
+ }
594
+ for (const [foldedMode, mode] of [...modeNames].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
595
+ const ringHex = valueIn(ringEntry, foldedMode);
596
+ const surfaceHex = valueIn(surfaceEntry, foldedMode);
597
+ const surfaceParsed = parseHex(surfaceHex);
598
+ if (parseHex(ringHex) === null || surfaceParsed === null) {
599
+ skipped.unresolved += 1;
600
+ continue;
601
+ }
602
+ if (surfaceParsed.a < 1) {
603
+ skipped.translucentSurface += 1;
604
+ continue;
605
+ }
606
+ const surfaceOpaque = surfaceHex.slice(0, 7);
607
+ const ratio = contrastRatio(composite(ringHex, surfaceOpaque), surfaceOpaque);
608
+ pairs += 1;
609
+ modesSeen.add(foldedMode);
610
+ if (ratio < AA_NON_TEXT) {
611
+ const shown = (Math.floor(ratio * 100) / 100).toFixed(2);
612
+ failures.push(`${ring} on ${surface} in ${mode}: ${shown}:1, needs ${AA_NON_TEXT}:1`);
613
+ }
614
+ }
615
+ }
616
+ }
617
+ const skipNote = [
618
+ skipped.unresolved && `${skipped.unresolved} skipped as unresolved or not a colour`,
619
+ skipped.translucentSurface && `${skipped.translucentSurface} skipped for a translucent surface`,
620
+ ].filter(Boolean);
621
+ if (failures.length) return { name, result: 'fail', detail: failures.join('; ') };
622
+ if (pairs === 0) {
623
+ return { name, result: 'skip', detail: `no ring/surface pair could be compared: ${skipNote.join(', ') || 'no ring colour found'}` };
624
+ }
625
+ const passed = `${pairs} ring/surface pair(s) clear ${AA_NON_TEXT}:1 across ${modesSeen.size} mode(s)`;
626
+ return { name, result: 'pass', detail: skipNote.length ? `${passed}; ${skipNote.join(', ')}` : passed };
627
+ }
628
+
347
629
  // Before anything is written: the matrix the spec asks for against the state table.
348
630
  function checkSpec(expect) {
349
631
  const entries = Object.entries(expect.matrix ?? {}).flatMap(([axis, values]) => values.map((v) => [axis, v]));
350
632
  return result([stateBaseline(expect, entries)]);
351
633
  }
352
634
 
635
+ const variantEntries = (snapshot) => (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
636
+
353
637
  // After the build: the node as read.
354
638
  function checkBuilt(snapshot, expect) {
355
- const entries = (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
356
- return result([structural(snapshot, expect), stateBaseline(expect, entries), focusIndicator(snapshot, expect)]);
639
+ const entries = variantEntries(snapshot);
640
+ const indicator = focusIndicator(snapshot, expect);
641
+ return result([
642
+ structural(snapshot, expect),
643
+ stateBaseline(expect, entries),
644
+ indicator,
645
+ focusContrast(snapshot, expect, indicator),
646
+ ]);
647
+ }
648
+
649
+ // An existing set has no spec matrix, so the structural check is not run; the
650
+ // other three read only the set itself.
651
+ function checkExisting(snapshot, expect) {
652
+ const entries = variantEntries(snapshot);
653
+ const indicator = focusIndicator(snapshot, expect);
654
+ return result([stateBaseline(expect, entries), indicator, focusContrast(snapshot, expect, indicator)]);
357
655
  }
358
656
 
359
- return { checkSpec, checkBuilt };
657
+ return { checkSpec, checkBuilt, checkExisting };
360
658
  }
361
659
 
362
- const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName });
660
+ const { checkSpec, checkBuilt, checkExisting } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });
661
+
662
+ // Figma plugin-API helpers for reading colour variables, shared by the Figma
663
+ // checks reader and the screen reader. This file is NOT a Node module:
664
+ // build-figma-checks.mjs inlines it into both generated scripts
665
+ // (references/figma-checks.md, references/figma-adherence.md). It depends on
666
+ // nothing else, so it carries its own mode-name fold. Async getters only
667
+ // (references/figma-scripting.md). Untested in Node, like the readers.
668
+
669
+ const foldModeName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
670
+
671
+ function colorHex(value) {
672
+ if (!value || typeof value !== 'object' || !('r' in value)) return null;
673
+ const byte = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
674
+ const alpha = value.a === undefined ? 1 : value.a;
675
+ return `#${byte(value.r)}${byte(value.g)}${byte(value.b)}${alpha < 1 ? byte(alpha) : ''}`;
676
+ }
677
+
678
+ // The value of `variable` in the mode named `modeName` (after the fold), else
679
+ // its collection's default mode, following aliases up to 10 hops.
680
+ async function resolveColor(variable, modeName, hops) {
681
+ if (!variable || hops > 10) return null;
682
+ const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
683
+ if (!collection) return null;
684
+ const mode = collection.modes.find((m) => foldModeName(m.name) === foldModeName(modeName));
685
+ const value = variable.valuesByMode[mode ? mode.modeId : collection.defaultModeId];
686
+ if (value && value.type === 'VARIABLE_ALIAS') {
687
+ return resolveColor(await figma.variables.getVariableByIdAsync(value.id), modeName, hops + 1);
688
+ }
689
+ return colorHex(value);
690
+ }
691
+
692
+ // { modes: { [modeName]: hex | null }, defaultMode } over the variable's own collection.
693
+ async function variableColors(variable) {
694
+ const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
695
+ if (!collection) return { modes: {}, defaultMode: undefined };
696
+ const modes = {};
697
+ for (const mode of collection.modes) modes[mode.name] = await resolveColor(variable, mode.name, 0);
698
+ const fallback = collection.modes.find((m) => m.modeId === collection.defaultModeId);
699
+ return { modes, defaultMode: fallback ? fallback.name : undefined };
700
+ }
363
701
 
364
702
  // Figma plugin-API reader for the stop-condition checks. This file is NOT a
365
703
  // Node module — build-figma-checks.mjs concatenates it after the inlined
366
- // component-states.mjs and figma-checks.mjs into references/figma-checks.md, and
704
+ // component-states.mjs, contrast.mjs, figma-checks.mjs and
705
+ // figma-variables-read.figma.js into references/figma-checks.md, and
367
706
  // the result runs inside figma_execute (dynamic-page mode). Constraints honored
368
707
  // here (see references/figma-scripting.md): async getters only, variable names
369
- // resolved with getVariableByIdAsync and only for focus variants and their
370
- // controlLayer. Untested in Node, like the doc-card renderer; the pure checker
371
- // it feeds is unit-tested.
708
+ // and per-mode colour values resolved with getVariableByIdAsync and only for
709
+ // focus variants and their controlLayer, plus the EXPECT.surfaces names, which
710
+ // are looked up among the file's LOCAL colour variables only (a surface from a
711
+ // published library reads as missing). Untested in Node, like the doc-card
712
+ // renderer; the pure checker it feeds is unit-tested.
372
713
  //
373
714
  // In-scope globals when assembled: normalizeName (inlined
374
- // component-states.mjs), checkSpec, checkBuilt (bound from withTable) and the
715
+ // component-states.mjs), variableColors (figma-variables-read.figma.js),
716
+ // checkSpec, checkBuilt, checkExisting (bound from withTable) and the
375
717
  // caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
376
718
  // shape is documented at the top of scripts/lib/figma-checks.mjs.
377
719
 
720
+ // Colour variables read on focus nodes, by name, for the contrast check.
721
+ const readVariables = new Map();
722
+
378
723
  async function variableName(alias) {
379
724
  if (!alias || !alias.id) return null;
380
725
  const variable = await figma.variables.getVariableByIdAsync(alias.id);
381
- return variable ? variable.name : null;
726
+ if (!variable) return null;
727
+ if (variable.resolvedType === 'COLOR') readVariables.set(variable.name, variable);
728
+ return variable.name;
382
729
  }
383
730
 
384
731
  // The fields the focus check reads from a variant, a control layer or a direct child.
@@ -438,18 +785,45 @@ function groupProperties(set) {
438
785
  }
439
786
  }
440
787
 
441
- async function readComponentSet(expect) {
788
+ // The set a build just made: the COMPONENT_SET in the `WIP: <name>` frame on the
789
+ // current page. A frame holding plain FRAMEs where the set should be is a failed
790
+ // assertion, not a missing target, so it comes back as `{ empty }` for the
791
+ // structural check to name.
792
+ async function findWipSet(expect) {
442
793
  await figma.currentPage.loadAsync();
443
794
  const frameName = `WIP: ${expect.name}`;
444
795
  const frame = figma.currentPage.findOne((n) => n.name === frameName);
445
796
  if (!frame) throw new Error(`no node named "${frameName}" on the current page`);
446
797
  const set = frame.type === 'COMPONENT_SET' ? frame : (frame.children || []).find((c) => c.type === 'COMPONENT_SET');
447
- // A frame holding plain FRAMEs where the set should be is a failed assertion,
448
- // not a missing target: report what is there so the structural check names it.
449
- if (!set) {
450
- const first = (frame.children || [])[0];
451
- return { type: first ? first.type : 'NONE', variantGroupProperties: {}, children: [] };
798
+ if (set) return { set };
799
+ const first = (frame.children || [])[0];
800
+ return { empty: { type: first ? first.type : 'NONE', variantGroupProperties: {}, children: [] } };
801
+ }
802
+
803
+ // A set already in the library: the first COMPONENT_SET whose name folds to the
804
+ // same name. With `pageName`, only that page is loaded (exact name, or a unique
805
+ // match on the trimmed name). Without it every page is loaded in turn until one
806
+ // has the set, which on a miss is the whole file; a whole-file load stalled the
807
+ // bridge on a large file, so callers pass the page.
808
+ async function findExistingSet(name, pageName) {
809
+ const want = normalizeName(name);
810
+ let pages = figma.root.children;
811
+ if (pageName !== undefined && pageName !== null) {
812
+ const exact = pages.filter((p) => p.name === pageName);
813
+ const trimmed = pages.filter((p) => p.name.trim() === String(pageName).trim());
814
+ pages = exact.length ? exact : trimmed.length === 1 ? trimmed : [];
815
+ if (!pages.length) throw new Error(`no page named "${pageName}"`);
816
+ }
817
+ for (const page of pages) {
818
+ await page.loadAsync();
819
+ const set = page.findAllWithCriteria({ types: ['COMPONENT_SET'] }).find((s) => normalizeName(s.name) === want);
820
+ if (set) return set;
452
821
  }
822
+ throw new Error(`no component set named "${name}"${pageName ? ` on page "${pageName}"` : ' in this file'}`);
823
+ }
824
+
825
+ async function readSet(set, expect) {
826
+ readVariables.clear();
453
827
 
454
828
  const isFocusState = (props) =>
455
829
  Object.entries(props || {}).some(([k, v]) => {
@@ -469,16 +843,34 @@ async function readComponentSet(expect) {
469
843
  }
470
844
  children.push(read);
471
845
  }
846
+ const colors = {};
847
+ for (const [name, variable] of readVariables) colors[name] = await variableColors(variable);
848
+ const missingSurfaces = [];
849
+ const surfaces = expect.surfaces || [];
850
+ if (surfaces.length) {
851
+ const local = await figma.variables.getLocalVariablesAsync('COLOR');
852
+ for (const name of surfaces) {
853
+ const variable = local.find((v) => v.name === name);
854
+ if (variable) colors[name] = await variableColors(variable);
855
+ else missingSurfaces.push(name);
856
+ }
857
+ }
472
858
  return {
473
859
  type: set.type,
474
860
  variantGroupProperties: groupProperties(set),
475
861
  children,
862
+ colors,
863
+ missingSurfaces,
476
864
  };
477
865
  }
478
866
 
479
867
  async function runFigmaChecks(EXPECT, MODE) {
480
868
  if (MODE === 'spec') return checkSpec(EXPECT);
481
- if (MODE === 'built') return checkBuilt(await readComponentSet(EXPECT), EXPECT);
482
- throw new Error(`MODE must be 'spec' or 'built', got ${JSON.stringify(MODE)}`);
869
+ if (MODE === 'built') {
870
+ const { set, empty } = await findWipSet(EXPECT);
871
+ return checkBuilt(set ? await readSet(set, EXPECT) : empty, EXPECT);
872
+ }
873
+ if (MODE === 'existing') return checkExisting(await readSet(await findExistingSet(EXPECT.name, EXPECT.page), EXPECT), EXPECT);
874
+ throw new Error(`MODE must be 'spec', 'built' or 'existing', got ${JSON.stringify(MODE)}`);
483
875
  }
484
876
  ```
@@ -586,6 +586,14 @@ For each generated artboard / doc card / icon grid, read the nodes back (via
586
586
  - **The result.** On a miss, stop and name the variant and what it lacks. On a
587
587
  pass, `component-builder` records `focus-indicator` as an attested check. A
588
588
  skip records nothing.
589
+
590
+ **The `focus-contrast` check** is the same script's measure of whether the ring
591
+ can be seen: each ring colour against each surface the architect names in
592
+ `surfaces`, at WCAG 1.4.11's 3:1, in every mode. The script is the rule for
593
+ which pairs and modes it compares and when it skips. A fail is a token problem,
594
+ not a build error: `figma-executor` returns `BLOCKED` at once, and inline you
595
+ stop the build. Neither changes a variable or swaps the ring to make it pass.
596
+ On a pass, `component-builder` records `focus-contrast` as an attested check.
589
597
  10. **Visual** — the screenshot (from the validation loop) shows no overlaps,
590
598
  misalignment, lopsided hug/fill sizing, or clipped strokes/focus rings.
591
599
  11. **Usage band rendered by the builder** — the card's `Usage` frame was
@@ -207,6 +207,7 @@ keyed by component?"
207
207
  | `state-baseline` | attested | `component-builder` |
208
208
  | `figma-name` | attested | `component-builder` |
209
209
  | `focus-indicator` | attested | `component-builder` |
210
+ | `focus-contrast` | attested | `component-builder` |
210
211
  | `review` | attested | the dispatching skill |
211
212
  | `build` | attested | `storybook-chromatic-builder` |
212
213
  | `orphan-token` | derived | not recorded: re-derived on every `verify:check` run |