@radicool/throughline 0.23.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 (47) hide show
  1. package/README.md +5 -5
  2. package/adapters/codex/prompts/component-builder.md +8 -4
  3. package/adapters/cursor/.cursor/rules/component-builder.mdc +8 -4
  4. package/adapters/generic/skills/component-builder/SKILL.md +8 -4
  5. package/package.json +1 -1
  6. package/references/figma-checks.md +357 -28
  7. package/references/figma-component-standards.md +8 -0
  8. package/references/proof-bundle.md +1 -0
  9. package/scripts/README.md +4 -4
  10. package/scripts/build-figma-checks.mjs +25 -12
  11. package/scripts/lib/contrast.mjs +5 -2
  12. package/scripts/lib/figma-checks-read.figma.js +59 -5
  13. package/scripts/lib/figma-checks.mjs +160 -15
  14. package/scripts/lib/fixtures/figma-checks/avatar-not-in-table.json +2 -1
  15. package/scripts/lib/fixtures/figma-checks/button-ios-swift-skipped.json +2 -1
  16. package/scripts/lib/fixtures/figma-checks/button-missing-default.json +2 -1
  17. package/scripts/lib/fixtures/figma-checks/button-mui-ring.json +2 -1
  18. package/scripts/lib/fixtures/figma-checks/button-no-focus-variant.json +2 -1
  19. package/scripts/lib/fixtures/figma-checks/button-plain-frames.json +2 -1
  20. package/scripts/lib/fixtures/figma-checks/button-ring-child-not-absolute.json +2 -1
  21. package/scripts/lib/fixtures/figma-checks/button-ring-hidden.json +2 -1
  22. package/scripts/lib/fixtures/figma-checks/button-ring-unbound-colour.json +4 -3
  23. package/scripts/lib/fixtures/figma-checks/button-shadcn-filled.json +2 -1
  24. package/scripts/lib/fixtures/figma-checks/button-sparse.json +2 -1
  25. package/scripts/lib/fixtures/figma-checks/button-spread-zero.json +2 -1
  26. package/scripts/lib/fixtures/figma-checks/button-transparent-ring-child.json +2 -1
  27. package/scripts/lib/fixtures/figma-checks/button-vanilla-inside.json +2 -1
  28. package/scripts/lib/fixtures/figma-checks/button-vanilla-outside.json +2 -1
  29. package/scripts/lib/fixtures/figma-checks/button-wrong-combination.json +2 -1
  30. package/scripts/lib/fixtures/figma-checks/checkbox-checked-axis.real.json +2 -1
  31. package/scripts/lib/fixtures/figma-checks/checkbox-checked-synonym.json +2 -1
  32. package/scripts/lib/fixtures/figma-checks/checkbox-missing-selected.json +2 -1
  33. package/scripts/lib/fixtures/figma-checks/indicator-failed.json +106 -0
  34. package/scripts/lib/fixtures/figma-checks/input-control-layer-missing.json +2 -1
  35. package/scripts/lib/fixtures/figma-checks/input-control-layer-ring-on-wrapper.json +2 -1
  36. package/scripts/lib/fixtures/figma-checks/input-control-layer.json +2 -1
  37. package/scripts/lib/fixtures/figma-checks/input-mui-weight-unbound.json +2 -1
  38. package/scripts/lib/fixtures/figma-checks/input-mui.json +2 -1
  39. package/scripts/lib/fixtures/figma-checks/ring-single-mode.json +112 -0
  40. package/scripts/lib/fixtures/figma-checks/ring-translucent.json +111 -0
  41. package/scripts/lib/fixtures/figma-checks/sample-button.real.json +2 -1
  42. package/scripts/lib/fixtures/figma-checks/sample-ring-on-muted-raw.json +113 -0
  43. package/scripts/lib/fixtures/figma-checks/sample-ring-passes.json +169 -0
  44. package/scripts/lib/fixtures/figma-checks/surface-missing.json +108 -0
  45. package/scripts/lib/fixtures/figma-checks/surface-single-mode.json +112 -0
  46. package/scripts/lib/fixtures/figma-checks/surface-translucent.json +112 -0
  47. package/scripts/lib/fixtures/figma-checks/zygarden-ring-light-fails.json +113 -0
package/README.md CHANGED
@@ -73,7 +73,7 @@ Everything above was created during a single working session and synced directly
73
73
 
74
74
  | | |
75
75
  |---|---|
76
- | **A supported agent** | Required — [Claude Code](https://docs.claude.com/en/docs/claude-code) (native plugin), or **Cursor**, **Codex**, or any **AGENTS.md**-aware agent via `npx @radicool/throughline@0.23.0 init`. |
76
+ | **A supported agent** | Required — [Claude Code](https://docs.claude.com/en/docs/claude-code) (native plugin), or **Cursor**, **Codex**, or any **AGENTS.md**-aware agent via `npx @radicool/throughline@0.24.0 init`. |
77
77
  | **Figma** | Required, **desktop app** (the browser version causes connection errors). **Professional plan or higher recommended** — multi-mode variables (Light/Dark, brand themes) need it. |
78
78
  | **Figma access token** | Optional — only for the few Figma calls the Desktop Bridge can't make, like reading a published library. Without it, you work through the bridge alone. The setup skill walks you through it; your token stays yours and is never shared in chat. |
79
79
  | **GitHub** (or similar) | Optional — only when you're ready to graduate to a real remote repo with PRs and CI. |
@@ -114,9 +114,9 @@ Update anytime with `/plugin marketplace update throughline-marketplace`.
114
114
  Run the installer in your project — it stamps in the skills, the reference docs, the scripts, and the Figma MCP config for your tool:
115
115
 
116
116
  ```
117
- npx @radicool/throughline@0.23.0 init --target=cursor # → .cursor/rules + .cursor/mcp.json
118
- npx @radicool/throughline@0.23.0 init --target=codex # → prompts/ + AGENTS.md index + codex-mcp.toml
119
- npx @radicool/throughline@0.23.0 init --target=generic # → skills/ + AGENTS.md index
117
+ npx @radicool/throughline@0.24.0 init --target=cursor # → .cursor/rules + .cursor/mcp.json
118
+ npx @radicool/throughline@0.24.0 init --target=codex # → prompts/ + AGENTS.md index + codex-mcp.toml
119
+ npx @radicool/throughline@0.24.0 init --target=generic # → skills/ + AGENTS.md index
120
120
  ```
121
121
 
122
122
  It's safe to re-run (it merges `AGENTS.md` and `.cursor/mcp.json` non-destructively) and stages everything the skills read into `.throughline/`. Then open the `figma-environment-setup` skill/rule/prompt for your tool to begin. For Codex, add the printed `codex-mcp.toml` block to your Codex config to enable Figma access.
@@ -212,7 +212,7 @@ Future improvements and planned capabilities. Have a request? **[Open an issue](
212
212
  - **Deeper Code Connect coverage** — richer Figma-to-code mappings as more plans support it.
213
213
  - **Expanded component starters** — a larger foundational kit out of the box.
214
214
  - **Richer status & auditing** — more from `/design-system-status`, including drift detection between Figma and code.
215
- - **Built-in accessibility checks** — the color half is shipped. Eight semantic text-on-surface pairs are checked against WCAG AA's 4.5:1 when tokens are created in Figma, and derived again from the token source when they sync to code — in every mode, so a system that clears in light and fails in dark fails. A failing pair stops the build rather than warning. Components are checked too: on web frameworks, a Figma build stops when a focus state has no visible ring, and in JSX code the adherence gate is built to fail an icon-only button with no accessible name and a status badge that relies on color alone — proven by unit tests. The one real app it has run on had almost nothing for it to check, so it hasn't caught a bug in the wild yet. Still ahead: focus-ring contrast (WCAG's 3:1 for non-text).
215
+ - **Built-in accessibility checks** — the color half is shipped. Eight semantic text-on-surface pairs are checked against WCAG AA's 4.5:1 when tokens are created in Figma, and derived again from the token source when they sync to code — in every mode, so a system that clears in light and fails in dark fails. A failing pair stops the build rather than warning. Components are checked too: on web frameworks, a Figma build stops when a focus state has no visible ring, and in JSX code the adherence gate is built to fail an icon-only button with no accessible name and a status badge that relies on color alone — proven by unit tests. A Figma build also stops when a focus ring falls below WCAG's 3:1 against a surface the architect names for it, in any mode. The one real app it has run on had almost nothing for it to check, so it hasn't caught a bug in the wild yet.
216
216
  - **Token fan-out to more platforms** — the DTCG token source is platform-neutral, and web targets are in daily use. Every target is **validated per build, not assumed**: `tokens:validate-output` checks generated output against its source, native Swift and Kotlin as well as web CSS, because the stock transforms have been measured emitting wrong-but-compiling values. iOS/Swift is a curated adapter whose Style Dictionary configuration ships as tested code, and Android/Kotlin uses the same configuration through the Tier 2 protocol. JavaScript theme output (MUI, a Tailwind v3 config) isn't checked yet.
217
217
  - **Native component code generation** — producing a SwiftUI view or a Compose composable the way Storybook components are produced for React. This does not exist yet; it is a separate, larger effort than token fan-out, and the two were previously described as one roadmap item.
218
218
 
@@ -177,8 +177,12 @@ deterministic naming):
177
177
  way the executor does: `'spec'` mode before the first write and `'built'` mode
178
178
  in the self-verify loop, with `EXPECT` built from your own matrix. You are the
179
179
  architect here, so you name `controlLayer` yourself for a wrapped control (an
180
- Input with a label or helper text). A `fail` on the `focus-indicator` check
181
- stops the build, as it would for the executor.
180
+ Input with a label or helper text), and `surfaces` yourself: the colour-variable
181
+ names of the page surfaces the component sits on. A `fail` on the
182
+ `focus-indicator` check stops the build, as it would for the executor. A `fail`
183
+ on `focus-contrast` stops it too, naming its detail. A ring below 3:1 is fixed by
184
+ a token value, so don't change a variable or swap the ring to another one to make
185
+ it pass; a surface the file doesn't have is a wrong name in `surfaces`.
182
186
 
183
187
  Checkpoint after each component: show all variants, confirm before the next.
184
188
 
@@ -366,8 +370,8 @@ any component built with the toggle + manual-swap fallback is listed in
366
370
  component-builder --subject <Name> --entry <tmp>.json`. Build `<tmp>.json` with
367
371
  `at` (an ISO timestamp you supply), `changed` (what was built), the attested checks `structural-read-back`,
368
372
  `state-baseline` and `figma-name` from what the `figma-executor` returned (or
369
- what your inline read-back found), `focus-indicator` when that check ran and
370
- passed (not when it was skipped),
373
+ what your inline read-back found), `focus-indicator` and `focus-contrast`
374
+ each when that check ran and passed (not when it was skipped),
371
375
  `review` when a reviewer ran, `screenshot` when one was captured, and
372
376
  `advancedBecause`. The entry shape is
373
377
  `.throughline/references/proof-bundle.md` — don't restate it here. Write
@@ -181,8 +181,12 @@ deterministic naming):
181
181
  way the executor does: `'spec'` mode before the first write and `'built'` mode
182
182
  in the self-verify loop, with `EXPECT` built from your own matrix. You are the
183
183
  architect here, so you name `controlLayer` yourself for a wrapped control (an
184
- Input with a label or helper text). A `fail` on the `focus-indicator` check
185
- stops the build, as it would for the executor.
184
+ Input with a label or helper text), and `surfaces` yourself: the colour-variable
185
+ names of the page surfaces the component sits on. A `fail` on the
186
+ `focus-indicator` check stops the build, as it would for the executor. A `fail`
187
+ on `focus-contrast` stops it too, naming its detail. A ring below 3:1 is fixed by
188
+ a token value, so don't change a variable or swap the ring to another one to make
189
+ it pass; a surface the file doesn't have is a wrong name in `surfaces`.
186
190
 
187
191
  Checkpoint after each component: show all variants, confirm before the next.
188
192
 
@@ -370,8 +374,8 @@ any component built with the toggle + manual-swap fallback is listed in
370
374
  component-builder --subject <Name> --entry <tmp>.json`. Build `<tmp>.json` with
371
375
  `at` (an ISO timestamp you supply), `changed` (what was built), the attested checks `structural-read-back`,
372
376
  `state-baseline` and `figma-name` from what the `figma-executor` returned (or
373
- what your inline read-back found), `focus-indicator` when that check ran and
374
- passed (not when it was skipped),
377
+ what your inline read-back found), `focus-indicator` and `focus-contrast`
378
+ each when that check ran and passed (not when it was skipped),
375
379
  `review` when a reviewer ran, `screenshot` when one was captured, and
376
380
  `advancedBecause`. The entry shape is
377
381
  `.throughline/references/proof-bundle.md` — don't restate it here. Write
@@ -177,8 +177,12 @@ deterministic naming):
177
177
  way the executor does: `'spec'` mode before the first write and `'built'` mode
178
178
  in the self-verify loop, with `EXPECT` built from your own matrix. You are the
179
179
  architect here, so you name `controlLayer` yourself for a wrapped control (an
180
- Input with a label or helper text). A `fail` on the `focus-indicator` check
181
- stops the build, as it would for the executor.
180
+ Input with a label or helper text), and `surfaces` yourself: the colour-variable
181
+ names of the page surfaces the component sits on. A `fail` on the
182
+ `focus-indicator` check stops the build, as it would for the executor. A `fail`
183
+ on `focus-contrast` stops it too, naming its detail. A ring below 3:1 is fixed by
184
+ a token value, so don't change a variable or swap the ring to another one to make
185
+ it pass; a surface the file doesn't have is a wrong name in `surfaces`.
182
186
 
183
187
  Checkpoint after each component: show all variants, confirm before the next.
184
188
 
@@ -366,8 +370,8 @@ any component built with the toggle + manual-swap fallback is listed in
366
370
  component-builder --subject <Name> --entry <tmp>.json`. Build `<tmp>.json` with
367
371
  `at` (an ISO timestamp you supply), `changed` (what was built), the attested checks `structural-read-back`,
368
372
  `state-baseline` and `figma-name` from what the `figma-executor` returned (or
369
- what your inline read-back found), `focus-indicator` when that check ran and
370
- passed (not when it was skipped),
373
+ what your inline read-back found), `focus-indicator` and `focus-contrast`
374
+ each when that check ran and passed (not when it was skipped),
371
375
  `review` when a reviewer ran, `screenshot` when one was captured, and
372
376
  `advancedBecause`. The entry shape is
373
377
  `.throughline/references/proof-bundle.md` — don't restate it here. Write
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radicool/throughline",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Build a complete design system end to end — author in Figma, sync tokens to code, generate Storybook. Usable from Claude Code, Cursor, Codex, or any AGENTS.md agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,30 +1,39 @@
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).
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.
21
27
  - `const MODE = 'spec'` or `'built'`.
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
@@ -118,29 +127,159 @@ function missingStates(record) {
118
127
  return baseline.filter((state) => !recordedKeys.has(normalizeName(state)));
119
128
  }
120
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
+
121
251
  // 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,
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,
123
254
  // `export const`/`export function` only. This module is inlined, with
124
- // component-states.mjs above it, into the generated Figma script
255
+ // component-states.mjs and contrast.mjs above it, into the generated Figma script
125
256
  // (references/figma-checks.md) by build-figma-checks.mjs, so it must run in both
126
257
  // Node and the Figma plugin sandbox.
127
258
  //
128
259
  // 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
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
131
263
  // inlined component-states.mjs names; in tests it passes the module's own exports.
132
264
  //
133
265
  // Spec: docs/specs/2026-10-07-figma-checks-as-scripts.md
134
266
  // Rules: references/figma-component-standards.md (State handling; audit items 8, 9)
135
267
  //
136
- // EXPECT: { name, matrix: { axis: [values] }, uiFramework, controlLayer? }
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.
137
272
  //
138
273
  // SNAPSHOT (what the reader returns for the COMPONENT_SET in `WIP: <name>`):
139
274
  // {
140
275
  // type, // the set's node type, 'COMPONENT_SET'
141
276
  // variantGroupProperties: { Axis: { values: [...] }, ... }, // only the keys are read
142
- // children: [ VARIANT, ... ]
277
+ // children: [ VARIANT, ... ],
278
+ // colors: { [variableName]: { modes: { [modeName]: '#rrggbb' | '#rrggbbaa' | null }, defaultMode } },
279
+ // missingSurfaces: [name, ...] // EXPECT.surfaces names with no local colour variable
143
280
  // }
281
+ // `colors` holds every colour variable read on focus variants and their
282
+ // controlLayer, plus each EXPECT.surfaces name that was found.
144
283
  // VARIANT (every child): { type, variantProperties: { Axis: 'Value' } }
145
284
  // A VARIANT whose state value folds to `focus` also carries FOCUS NODE fields
146
285
  // (below), plus `controlLayer` when EXPECT names one.
@@ -156,6 +295,8 @@ function missingStates(record) {
156
295
  // Variable names are resolved by the reader and only for focus variants and their controlLayer.
157
296
  //
158
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.
159
300
  // `pass` ignores skips. A failed assertion is data; nothing here throws.
160
301
 
161
302
  // The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
@@ -181,11 +322,17 @@ function frameworkRecipe(uiFramework) {
181
322
  : null;
182
323
  }
183
324
 
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.
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.
187
330
  function isFocusVariable(name) {
188
- return typeof name === 'string' && name.split('/').some((seg) => seg.trim().toLowerCase() === 'focus');
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');
189
336
  }
190
337
 
191
338
  // A choice control may carry selection on its own axis instead of as a `selected`
@@ -222,7 +369,34 @@ function lacksRing(target, recipe, isInput) {
222
369
  ];
223
370
  }
224
371
 
225
- function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName }) {
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
+ }) {
226
400
  const fold = normalizeName;
227
401
  const foldState = (value) => {
228
402
  const name = fold(value);
@@ -242,6 +416,10 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
242
416
  .map(([k, v]) => `${k}=${v}`)
243
417
  .join(', ');
244
418
 
419
+ const isFocusVariant = (child) =>
420
+ child.type === 'COMPONENT' &&
421
+ Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
422
+
245
423
  function crossProduct(matrix) {
246
424
  let combos = [{}];
247
425
  for (const [axis, values] of Object.entries(matrix ?? {})) {
@@ -319,9 +497,6 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
319
497
  if (recipe === null) {
320
498
  return { name, result: 'skip', detail: `uiFramework "${expect.uiFramework}" is out of scope for the focus check` };
321
499
  }
322
- const isFocusVariant = (child) =>
323
- child.type === 'COMPONENT' &&
324
- Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
325
500
  const variants = (snapshot.children ?? []).filter(isFocusVariant);
326
501
  if (variants.length === 0) {
327
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' };
@@ -344,6 +519,100 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
344
519
  : { name, result: 'pass', detail: `all ${variants.length} focus variants carry a ${recipe}-recipe focus indicator` };
345
520
  }
346
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
+
347
616
  // Before anything is written: the matrix the spec asks for against the state table.
348
617
  function checkSpec(expect) {
349
618
  const entries = Object.entries(expect.matrix ?? {}).flatMap(([axis, values]) => values.map((v) => [axis, v]));
@@ -353,32 +622,77 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
353
622
  // After the build: the node as read.
354
623
  function checkBuilt(snapshot, expect) {
355
624
  const entries = (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
356
- return result([structural(snapshot, expect), stateBaseline(expect, entries), focusIndicator(snapshot, expect)]);
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
+ ]);
357
632
  }
358
633
 
359
634
  return { checkSpec, checkBuilt };
360
635
  }
361
636
 
362
- const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName });
637
+ const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });
363
638
 
364
639
  // Figma plugin-API reader for the stop-condition checks. This file is NOT a
365
640
  // 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
641
+ // component-states.mjs, contrast.mjs and figma-checks.mjs into
642
+ // references/figma-checks.md, and
367
643
  // the result runs inside figma_execute (dynamic-page mode). Constraints honored
368
644
  // 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.
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.
372
650
  //
373
651
  // In-scope globals when assembled: normalizeName (inlined
374
652
  // component-states.mjs), checkSpec, checkBuilt (bound from withTable) and the
375
653
  // caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
376
654
  // shape is documented at the top of scripts/lib/figma-checks.mjs.
377
655
 
656
+ // Colour variables read on focus nodes, by name, for the contrast check.
657
+ const readVariables = new Map();
658
+
378
659
  async function variableName(alias) {
379
660
  if (!alias || !alias.id) return null;
380
661
  const variable = await figma.variables.getVariableByIdAsync(alias.id);
381
- return variable ? variable.name : null;
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 };
382
696
  }
383
697
 
384
698
  // The fields the focus check reads from a variant, a control layer or a direct child.
@@ -439,6 +753,7 @@ function groupProperties(set) {
439
753
  }
440
754
 
441
755
  async function readComponentSet(expect) {
756
+ readVariables.clear();
442
757
  await figma.currentPage.loadAsync();
443
758
  const frameName = `WIP: ${expect.name}`;
444
759
  const frame = figma.currentPage.findOne((n) => n.name === frameName);
@@ -469,10 +784,24 @@ async function readComponentSet(expect) {
469
784
  }
470
785
  children.push(read);
471
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
+ }
472
799
  return {
473
800
  type: set.type,
474
801
  variantGroupProperties: groupProperties(set),
475
802
  children,
803
+ colors,
804
+ missingSurfaces,
476
805
  };
477
806
  }
478
807