@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.
- package/README.md +5 -5
- package/adapters/codex/prompts/component-builder.md +8 -4
- package/adapters/cursor/.cursor/rules/component-builder.mdc +8 -4
- package/adapters/generic/skills/component-builder/SKILL.md +8 -4
- package/package.json +1 -1
- package/references/figma-checks.md +357 -28
- package/references/figma-component-standards.md +8 -0
- package/references/proof-bundle.md +1 -0
- package/scripts/README.md +4 -4
- package/scripts/build-figma-checks.mjs +25 -12
- package/scripts/lib/contrast.mjs +5 -2
- package/scripts/lib/figma-checks-read.figma.js +59 -5
- package/scripts/lib/figma-checks.mjs +160 -15
- package/scripts/lib/fixtures/figma-checks/avatar-not-in-table.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ios-swift-skipped.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-missing-default.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-mui-ring.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-no-focus-variant.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-plain-frames.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ring-child-not-absolute.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ring-hidden.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ring-unbound-colour.json +4 -3
- package/scripts/lib/fixtures/figma-checks/button-shadcn-filled.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-sparse.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-spread-zero.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-transparent-ring-child.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-vanilla-inside.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-vanilla-outside.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-wrong-combination.json +2 -1
- package/scripts/lib/fixtures/figma-checks/checkbox-checked-axis.real.json +2 -1
- package/scripts/lib/fixtures/figma-checks/checkbox-checked-synonym.json +2 -1
- package/scripts/lib/fixtures/figma-checks/checkbox-missing-selected.json +2 -1
- package/scripts/lib/fixtures/figma-checks/indicator-failed.json +106 -0
- package/scripts/lib/fixtures/figma-checks/input-control-layer-missing.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-control-layer-ring-on-wrapper.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-control-layer.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-mui-weight-unbound.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-mui.json +2 -1
- package/scripts/lib/fixtures/figma-checks/ring-single-mode.json +112 -0
- package/scripts/lib/fixtures/figma-checks/ring-translucent.json +111 -0
- package/scripts/lib/fixtures/figma-checks/sample-button.real.json +2 -1
- package/scripts/lib/fixtures/figma-checks/sample-ring-on-muted-raw.json +113 -0
- package/scripts/lib/fixtures/figma-checks/sample-ring-passes.json +169 -0
- package/scripts/lib/fixtures/figma-checks/surface-missing.json +108 -0
- package/scripts/lib/fixtures/figma-checks/surface-single-mode.json +112 -0
- package/scripts/lib/fixtures/figma-checks/surface-translucent.json +112 -0
- package/scripts/lib/fixtures/figma-checks/zygarden-ring-light-fails.json +113 -0
|
@@ -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 |
|
package/scripts/README.md
CHANGED
|
@@ -23,15 +23,15 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
23
23
|
| `docs-lint.mjs` | Copy lint for .doc.json records — warnings only, always exits 0 on a parseable record; the mechanical subset of `references/doc-writing-standard.md`. | `docs:lint` |
|
|
24
24
|
| `lib/doc-record.mjs` | Canonical record load + `canonicalFingerprint` (sha256 over the record minus `provenance`). The fingerprint every surface is stamped with. | copied alongside docs-check.mjs |
|
|
25
25
|
| `lib/doc-card-render.figma.js` | Figma renderer template for the doc card's `Usage` band and header. Inlined into `references/doc-card-builder.md`; never executed as a module. | plugin-internal (not installed) |
|
|
26
|
-
| `lib/figma-checks.mjs` | Pure checker for the Figma stop conditions: the COMPONENT_SET assertion against the full matrix, the state matrix, and the focus ring, plus the focus-framework rules. Inlined into `references/figma-checks.md`; unit-tested against `lib/fixtures/figma-checks/`. | plugin-internal (not installed) |
|
|
26
|
+
| `lib/figma-checks.mjs` | Pure checker for the Figma stop conditions: the COMPONENT_SET assertion against the full matrix, the state matrix, the focus ring, and the focus ring's 3:1 contrast against the surfaces it's named to sit on, plus the focus-framework rules. Inlined into `references/figma-checks.md`; unit-tested against `lib/fixtures/figma-checks/`. | plugin-internal (not installed) |
|
|
27
27
|
| `lib/figma-checks-read.figma.js` | Figma reader that turns the live COMPONENT_SET into the checker's snapshot JSON. Inlined into `references/figma-checks.md`; never executed as a module. | plugin-internal (not installed) |
|
|
28
28
|
| `lib/doc-card-plan.mjs` | Pure layout planner for the doc card's `Usage` band + `DOC_CARD_RENDERER_VERSION` (single source of the layout version). Inlined into `references/doc-card-builder.md`; imported by `docs-check.mjs`. | copied alongside docs-check.mjs; also inlined into the generated builder |
|
|
29
29
|
| `verify-check.mjs` | The verification proof bundle: records each stage's entry, and re-derives what it can rather than trusting the record. Fails on an orphaned token, a component documented without its archetype's baseline interaction states, a name that drifts between the manifest, its record and its code surface, or a recorded result the rerun contradicts. | `verify:check` |
|
|
30
30
|
| `lib/proof.mjs` | The proof store's vocabulary and mechanics: the stage list, entry validation, latest-wins merge, and the fingerprint the manifest pointer carries. | copied alongside `verify-check.mjs` |
|
|
31
31
|
| `lib/component-states.mjs` | The archetype table and resolver behind `state-incomplete` — which baseline interaction states each kind of component owes, and when to abstain. `validate-adherence.mjs` also uses it to find Badges. | copied alongside `verify-check.mjs` and `validate-adherence.mjs` |
|
|
32
|
-
| `lib/contrast.mjs` | The WCAG contrast maths and the semantic colour pairs behind `color-contrast` — which text role must clear which surface, in every mode, and where the rule deliberately abstains. | copied alongside `verify-check.mjs
|
|
32
|
+
| `lib/contrast.mjs` | The WCAG contrast maths and the semantic colour pairs behind `color-contrast` — which text role must clear which surface, in every mode, and where the rule deliberately abstains. Also inlined into `references/figma-checks.md` for the focus-ring contrast check. | copied alongside `verify-check.mjs`; also inlined into the generated Figma checks |
|
|
33
33
|
| `build-doc-card-builder.mjs` | Generate `references/doc-card-builder.md` from the planner + the Figma renderer template (`lib/doc-card-render.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
|
|
34
|
-
| `build-figma-checks.mjs` | Generate `references/figma-checks.md`, the `figma_execute` snippet for the Figma stop conditions, by inlining `lib/component-states.mjs` and `lib/figma-checks.mjs` above the reader (`lib/figma-checks-read.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
|
|
34
|
+
| `build-figma-checks.mjs` | Generate `references/figma-checks.md`, the `figma_execute` snippet for the Figma stop conditions, by inlining `lib/component-states.mjs`, `lib/contrast.mjs` and `lib/figma-checks.mjs` above the reader (`lib/figma-checks-read.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
|
|
35
35
|
| `build-state-baseline.mjs` | Generate `references/state-baseline.md`, the table of which components owe which interaction states, from `lib/component-states.mjs`. The Figma executor reads the table; `verify:check` reads the module. `--check` gates CI. | plugin-internal (not installed) |
|
|
36
36
|
| `build-native-adapter-config.mjs` | Generate `references/native-adapter-config.md` by slicing `lib/sd-native.mjs` on its `@doc-section` markers and interleaving each fragment under its prose. Fails when module code falls outside every section, so the doc cannot silently ship incomplete. `--check` gates CI. | plugin-internal (not installed) |
|
|
37
37
|
|
|
@@ -249,7 +249,7 @@ the committed `adapters/<target>/` tree plus the runtime payload
|
|
|
249
249
|
(`references/` + `scripts/`, minus `scripts/adapters/`) into `.throughline/`,
|
|
250
250
|
rewriting `${CLAUDE_PLUGIN_ROOT}` → `.throughline`:
|
|
251
251
|
|
|
252
|
-
npx @radicool/throughline@0.
|
|
252
|
+
npx @radicool/throughline@0.24.0 init --target=cursor|codex|generic
|
|
253
253
|
|
|
254
254
|
See `scripts/install.mjs` (pure core + CLI + `install.test.mjs`).
|
|
255
255
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generates references/figma-checks.md — the canonical figma_execute snippet
|
|
2
2
|
// that runs the Figma stop conditions (COMPONENT_SET assertion, state matrix,
|
|
3
|
-
// focus ring) — by inlining the state table
|
|
3
|
+
// focus ring, focus-ring contrast) — by inlining the state table
|
|
4
|
+
// (lib/component-states.mjs), the contrast maths (lib/contrast.mjs) and the
|
|
4
5
|
// pure checker (lib/figma-checks.mjs) above the Figma reader
|
|
5
6
|
// (lib/figma-checks-read.figma.js). Mirrors build-doc-card-builder.mjs: run
|
|
6
7
|
// bare to write, run with --check to gate CI. Zero dependencies.
|
|
@@ -10,40 +11,50 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
10
11
|
|
|
11
12
|
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
13
|
const STATES = join(REPO_ROOT, 'scripts', 'lib', 'component-states.mjs');
|
|
14
|
+
const CONTRAST = join(REPO_ROOT, 'scripts', 'lib', 'contrast.mjs');
|
|
13
15
|
const CHECKER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks.mjs');
|
|
14
16
|
const READER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks-read.figma.js');
|
|
15
17
|
const OUT = join(REPO_ROOT, 'references', 'figma-checks.md');
|
|
16
18
|
|
|
17
|
-
const BIND = 'const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName });';
|
|
19
|
+
const BIND = 'const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });';
|
|
18
20
|
|
|
19
21
|
const HEADER = [
|
|
20
22
|
'# Figma stop-condition checks (GENERATED)',
|
|
21
23
|
'',
|
|
22
24
|
'> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/component-states.mjs`',
|
|
23
|
-
'> (the state table), `scripts/lib/
|
|
24
|
-
'>
|
|
25
|
+
'> (the state table), `scripts/lib/contrast.mjs` (the WCAG contrast maths),',
|
|
26
|
+
'> `scripts/lib/figma-checks.mjs` (the pure checker, unit-tested in Node) +',
|
|
27
|
+
'> `scripts/lib/figma-checks-read.figma.js` (the Figma reader). Regenerate with',
|
|
25
28
|
'> `node scripts/build-figma-checks.mjs`; CI gates freshness with `--check`.',
|
|
26
29
|
'',
|
|
27
30
|
'The canonical `figma_execute` snippet for the checks that can stop a component',
|
|
28
31
|
'build: the COMPONENT_SET assertion against the full variant matrix, the state',
|
|
29
|
-
'matrix,
|
|
30
|
-
'
|
|
32
|
+
'matrix, the focus ring, and the focus ring\'s contrast against the surfaces it',
|
|
33
|
+
'sits on (WCAG 1.4.11, 3:1, in every mode). The script is the rule; do not re-write',
|
|
34
|
+
'these checks by hand.',
|
|
31
35
|
'',
|
|
32
36
|
'## How to call it',
|
|
33
37
|
'',
|
|
34
38
|
'1. Paste the snippet below into `figma_execute`, with the two slots filled above it:',
|
|
35
|
-
' - `const EXPECT = { name, matrix, uiFramework, controlLayer }` — `name` is the',
|
|
39
|
+
' - `const EXPECT = { name, matrix, uiFramework, controlLayer, surfaces }` — `name` is the',
|
|
36
40
|
' component\'s real name; `matrix` is `{ axis: [values] }` for every axis of the',
|
|
37
41
|
' set; `uiFramework` is `project.uiFramework` from `design-system.json`;',
|
|
38
42
|
' `controlLayer` is the layer that is the actual control in a wrapped control,',
|
|
39
|
-
' copied from the architect\'s spec when it gives one (omit it otherwise)
|
|
43
|
+
' copied from the architect\'s spec when it gives one (omit it otherwise);',
|
|
44
|
+
' `surfaces` is the list of colour-variable names of the page surfaces the',
|
|
45
|
+
' component sits on, copied from the architect\'s spec. Without it the',
|
|
46
|
+
' focus-contrast check is skipped. Surfaces are looked up among the file\'s',
|
|
47
|
+
' local variables only.',
|
|
40
48
|
' - `const MODE = \'spec\'` or `\'built\'`.',
|
|
41
49
|
'2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass',
|
|
42
50
|
' `timeout: 30000`.',
|
|
43
51
|
'3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is',
|
|
44
52
|
' `pass`, `fail` or `skip`; `pass` ignores skips. On `fail`, fix what each failing',
|
|
45
53
|
' check\'s `detail` names and run it again, within the ~3-pass budget; after that,',
|
|
46
|
-
' `BLOCKED` naming the failing checks\' `detail`.',
|
|
54
|
+
' `BLOCKED` naming the failing checks\' `detail`. A `focus-contrast` fail is',
|
|
55
|
+
' the exception: return `BLOCKED` at once, naming its detail. A ring below 3:1 is',
|
|
56
|
+
' fixed by a token value, which no rebuild changes, and a missing surface is a',
|
|
57
|
+
' wrong name in the spec. Never edit a variable to make it pass.',
|
|
47
58
|
'4. `\'spec\'` reads no node and runs before the first write. `\'built\'` reads the',
|
|
48
59
|
' COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs',
|
|
49
60
|
' in the self-verify loop. It throws only if that frame is missing; a frame with',
|
|
@@ -66,20 +77,22 @@ const FOOTER = ['```', ''].join('\n');
|
|
|
66
77
|
const stripExports = (src) =>
|
|
67
78
|
src.replace(/^export const /gm, 'const ').replace(/^export function /gm, 'function ');
|
|
68
79
|
|
|
69
|
-
export function buildFigmaChecks({ statesSource, checkerSource, readerSource }) {
|
|
80
|
+
export function buildFigmaChecks({ statesSource, contrastSource, checkerSource, readerSource }) {
|
|
70
81
|
const states = stripExports(statesSource);
|
|
82
|
+
const contrast = stripExports(contrastSource);
|
|
71
83
|
const checker = stripExports(checkerSource);
|
|
72
|
-
for (const [name, src] of [['component-states.mjs', states], ['figma-checks.mjs', checker], ['figma-checks-read.figma.js', readerSource]]) {
|
|
84
|
+
for (const [name, src] of [['component-states.mjs', states], ['contrast.mjs', contrast], ['figma-checks.mjs', checker], ['figma-checks-read.figma.js', readerSource]]) {
|
|
73
85
|
if (/^\s*(import|export)\b/m.test(src)) {
|
|
74
86
|
throw new Error(`${name} must stay import-free (only top-level \`export const\`/\`export function\` allowed) — it is inlined into the Figma snippet where no module system exists`);
|
|
75
87
|
}
|
|
76
88
|
}
|
|
77
|
-
return `${HEADER}\n${states}\n${checker}\n${BIND}\n\n${readerSource}${FOOTER}`;
|
|
89
|
+
return `${HEADER}\n${states}\n${contrast}\n${checker}\n${BIND}\n\n${readerSource}${FOOTER}`;
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
81
93
|
const result = buildFigmaChecks({
|
|
82
94
|
statesSource: readFileSync(STATES, 'utf8'),
|
|
95
|
+
contrastSource: readFileSync(CONTRAST, 'utf8'),
|
|
83
96
|
checkerSource: readFileSync(CHECKER, 'utf8'),
|
|
84
97
|
readerSource: readFileSync(READER, 'utf8'),
|
|
85
98
|
});
|
package/scripts/lib/contrast.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// MAINTENANCE: a change to the semantic role set must change this table.
|
|
7
7
|
|
|
8
8
|
export const AA_NORMAL_TEXT = 4.5;
|
|
9
|
+
// WCAG 1.4.11 non-text contrast, used by the Figma `focus-contrast` check.
|
|
10
|
+
export const AA_NON_TEXT = 3;
|
|
9
11
|
|
|
10
12
|
// `text/disabled` is excluded: WCAG 1.4.3 exempts inactive user-interface
|
|
11
13
|
// components from the contrast minimum, and a disabled role is deliberately
|
|
@@ -15,8 +17,9 @@ export const AA_NORMAL_TEXT = 4.5;
|
|
|
15
17
|
// questions) — a system is free to use those roles for non-text surfaces, so
|
|
16
18
|
// pairing them risks the first false failures in the table.
|
|
17
19
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
+
// This table is text-on-surface only. Focus-ring contrast, the 3:1 non-text case
|
|
21
|
+
// (WCAG 1.4.11), is checked in the Figma checks (`figma-checks.mjs`,
|
|
22
|
+
// `focus-contrast`), where the build knows which ring sits on which surface.
|
|
20
23
|
//
|
|
21
24
|
// One threshold is applied to every pair because a colour role does not say
|
|
22
25
|
// what text size it is used at — `text/primary` is body copy as well as
|
|
@@ -1,21 +1,60 @@
|
|
|
1
1
|
// Figma plugin-API reader for the stop-condition checks. This file is NOT a
|
|
2
2
|
// Node module — build-figma-checks.mjs concatenates it after the inlined
|
|
3
|
-
// component-states.mjs and figma-checks.mjs into
|
|
3
|
+
// component-states.mjs, contrast.mjs and figma-checks.mjs into
|
|
4
|
+
// references/figma-checks.md, and
|
|
4
5
|
// the result runs inside figma_execute (dynamic-page mode). Constraints honored
|
|
5
6
|
// here (see references/figma-scripting.md): async getters only, variable names
|
|
6
|
-
// resolved with getVariableByIdAsync and only for
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// and per-mode colour values resolved with getVariableByIdAsync and only for
|
|
8
|
+
// focus variants and their controlLayer, plus the EXPECT.surfaces names, which
|
|
9
|
+
// are looked up among the file's LOCAL colour variables only (a surface from a
|
|
10
|
+
// published library reads as missing). Untested in Node, like the doc-card
|
|
11
|
+
// renderer; the pure checker it feeds is unit-tested.
|
|
9
12
|
//
|
|
10
13
|
// In-scope globals when assembled: normalizeName (inlined
|
|
11
14
|
// component-states.mjs), checkSpec, checkBuilt (bound from withTable) and the
|
|
12
15
|
// caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
|
|
13
16
|
// shape is documented at the top of scripts/lib/figma-checks.mjs.
|
|
14
17
|
|
|
18
|
+
// Colour variables read on focus nodes, by name, for the contrast check.
|
|
19
|
+
const readVariables = new Map();
|
|
20
|
+
|
|
15
21
|
async function variableName(alias) {
|
|
16
22
|
if (!alias || !alias.id) return null;
|
|
17
23
|
const variable = await figma.variables.getVariableByIdAsync(alias.id);
|
|
18
|
-
|
|
24
|
+
if (!variable) return null;
|
|
25
|
+
if (variable.resolvedType === 'COLOR') readVariables.set(variable.name, variable);
|
|
26
|
+
return variable.name;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function colorHex(value) {
|
|
30
|
+
if (!value || typeof value !== 'object' || !('r' in value)) return null;
|
|
31
|
+
const byte = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
32
|
+
const alpha = value.a === undefined ? 1 : value.a;
|
|
33
|
+
return `#${byte(value.r)}${byte(value.g)}${byte(value.b)}${alpha < 1 ? byte(alpha) : ''}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The value of `variable` in the mode named `modeName` (after the fold), else
|
|
37
|
+
// its collection's default mode, following aliases up to 10 hops.
|
|
38
|
+
async function resolveColor(variable, modeName, hops) {
|
|
39
|
+
if (!variable || hops > 10) return null;
|
|
40
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
41
|
+
if (!collection) return null;
|
|
42
|
+
const mode = collection.modes.find((m) => normalizeName(m.name) === normalizeName(modeName));
|
|
43
|
+
const value = variable.valuesByMode[mode ? mode.modeId : collection.defaultModeId];
|
|
44
|
+
if (value && value.type === 'VARIABLE_ALIAS') {
|
|
45
|
+
return resolveColor(await figma.variables.getVariableByIdAsync(value.id), modeName, hops + 1);
|
|
46
|
+
}
|
|
47
|
+
return colorHex(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// { modes: { [modeName]: hex | null }, defaultMode } over the variable's own collection.
|
|
51
|
+
async function variableColors(variable) {
|
|
52
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
53
|
+
if (!collection) return { modes: {}, defaultMode: undefined };
|
|
54
|
+
const modes = {};
|
|
55
|
+
for (const mode of collection.modes) modes[mode.name] = await resolveColor(variable, mode.name, 0);
|
|
56
|
+
const fallback = collection.modes.find((m) => m.modeId === collection.defaultModeId);
|
|
57
|
+
return { modes, defaultMode: fallback ? fallback.name : undefined };
|
|
19
58
|
}
|
|
20
59
|
|
|
21
60
|
// The fields the focus check reads from a variant, a control layer or a direct child.
|
|
@@ -76,6 +115,7 @@ function groupProperties(set) {
|
|
|
76
115
|
}
|
|
77
116
|
|
|
78
117
|
async function readComponentSet(expect) {
|
|
118
|
+
readVariables.clear();
|
|
79
119
|
await figma.currentPage.loadAsync();
|
|
80
120
|
const frameName = `WIP: ${expect.name}`;
|
|
81
121
|
const frame = figma.currentPage.findOne((n) => n.name === frameName);
|
|
@@ -106,10 +146,24 @@ async function readComponentSet(expect) {
|
|
|
106
146
|
}
|
|
107
147
|
children.push(read);
|
|
108
148
|
}
|
|
149
|
+
const colors = {};
|
|
150
|
+
for (const [name, variable] of readVariables) colors[name] = await variableColors(variable);
|
|
151
|
+
const missingSurfaces = [];
|
|
152
|
+
const surfaces = expect.surfaces || [];
|
|
153
|
+
if (surfaces.length) {
|
|
154
|
+
const local = await figma.variables.getLocalVariablesAsync('COLOR');
|
|
155
|
+
for (const name of surfaces) {
|
|
156
|
+
const variable = local.find((v) => v.name === name);
|
|
157
|
+
if (variable) colors[name] = await variableColors(variable);
|
|
158
|
+
else missingSurfaces.push(name);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
109
161
|
return {
|
|
110
162
|
type: set.type,
|
|
111
163
|
variantGroupProperties: groupProperties(set),
|
|
112
164
|
children,
|
|
165
|
+
colors,
|
|
166
|
+
missingSurfaces,
|
|
113
167
|
};
|
|
114
168
|
}
|
|
115
169
|
|
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
// The Figma stop conditions as a pure checker: the COMPONENT_SET assertion against
|
|
2
|
-
// the full variant matrix, the state matrix, and the focus ring
|
|
2
|
+
// the full variant matrix, the state matrix, the focus ring, and the focus ring's
|
|
3
|
+
// contrast against the surfaces it sits on. ZERO imports,
|
|
3
4
|
// `export const`/`export function` only. This module is inlined, with
|
|
4
|
-
// component-states.mjs above it, into the generated Figma script
|
|
5
|
+
// component-states.mjs and contrast.mjs above it, into the generated Figma script
|
|
5
6
|
// (references/figma-checks.md) by build-figma-checks.mjs, so it must run in both
|
|
6
7
|
// Node and the Figma plugin sandbox.
|
|
7
8
|
//
|
|
8
9
|
// The state table is not imported. `withTable({ resolveArchetype, BASELINE_STATES,
|
|
9
|
-
// STATE_SYNONYMS, normalizeName
|
|
10
|
-
//
|
|
10
|
+
// STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT })`
|
|
11
|
+
// takes it, the fold and the contrast maths from the caller and returns
|
|
12
|
+
// { checkSpec, checkBuilt }. In the generated script the caller passes the
|
|
11
13
|
// inlined component-states.mjs names; in tests it passes the module's own exports.
|
|
12
14
|
//
|
|
13
15
|
// Spec: docs/specs/2026-10-07-figma-checks-as-scripts.md
|
|
14
16
|
// Rules: references/figma-component-standards.md (State handling; audit items 8, 9)
|
|
15
17
|
//
|
|
16
|
-
//
|
|
18
|
+
// Spec (focus contrast): docs/specs/2026-10-13-focus-ring-contrast.md
|
|
19
|
+
//
|
|
20
|
+
// EXPECT: { name, matrix: { axis: [values] }, uiFramework, controlLayer?, surfaces? }
|
|
21
|
+
// surfaces: colour-variable names of the page surfaces the component sits on.
|
|
17
22
|
//
|
|
18
23
|
// SNAPSHOT (what the reader returns for the COMPONENT_SET in `WIP: <name>`):
|
|
19
24
|
// {
|
|
20
25
|
// type, // the set's node type, 'COMPONENT_SET'
|
|
21
26
|
// variantGroupProperties: { Axis: { values: [...] }, ... }, // only the keys are read
|
|
22
|
-
// children: [ VARIANT, ... ]
|
|
27
|
+
// children: [ VARIANT, ... ],
|
|
28
|
+
// colors: { [variableName]: { modes: { [modeName]: '#rrggbb' | '#rrggbbaa' | null }, defaultMode } },
|
|
29
|
+
// missingSurfaces: [name, ...] // EXPECT.surfaces names with no local colour variable
|
|
23
30
|
// }
|
|
31
|
+
// `colors` holds every colour variable read on focus variants and their
|
|
32
|
+
// controlLayer, plus each EXPECT.surfaces name that was found.
|
|
24
33
|
// VARIANT (every child): { type, variantProperties: { Axis: 'Value' } }
|
|
25
34
|
// A VARIANT whose state value folds to `focus` also carries FOCUS NODE fields
|
|
26
35
|
// (below), plus `controlLayer` when EXPECT names one.
|
|
@@ -36,6 +45,8 @@
|
|
|
36
45
|
// Variable names are resolved by the reader and only for focus variants and their controlLayer.
|
|
37
46
|
//
|
|
38
47
|
// RESULT: { pass, checks: [{ name, result: 'pass' | 'fail' | 'skip', detail }] }
|
|
48
|
+
// built mode returns four checks: structural-read-back, state-baseline,
|
|
49
|
+
// focus-indicator, focus-contrast.
|
|
39
50
|
// `pass` ignores skips. A failed assertion is data; nothing here throws.
|
|
40
51
|
|
|
41
52
|
// The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
|
|
@@ -61,11 +72,17 @@ function frameworkRecipe(uiFramework) {
|
|
|
61
72
|
: null;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
// A focus variable is any variable whose name has
|
|
65
|
-
//
|
|
66
|
-
//
|
|
75
|
+
// A focus variable is any variable whose name has `focus` as a whole word: in a
|
|
76
|
+
// `/` segment of its own, or inside one split on `-`, `_`, spaces or a camelCase
|
|
77
|
+
// boundary. `border/focus`, `color/focus/ring`, `color/focusRing` and
|
|
78
|
+
// `interaction/border-primary-focus` count; `color/bg/focused` and a raw colour
|
|
79
|
+
// (null) do not.
|
|
67
80
|
function isFocusVariable(name) {
|
|
68
|
-
|
|
81
|
+
if (typeof name !== 'string') return false;
|
|
82
|
+
const words = name
|
|
83
|
+
.split('/')
|
|
84
|
+
.flatMap((seg) => seg.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[\s_-]+/));
|
|
85
|
+
return words.some((word) => word.toLowerCase() === 'focus');
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
// A choice control may carry selection on its own axis instead of as a `selected`
|
|
@@ -102,7 +119,34 @@ function lacksRing(target, recipe, isInput) {
|
|
|
102
119
|
];
|
|
103
120
|
}
|
|
104
121
|
|
|
105
|
-
|
|
122
|
+
// The focus-variable names that make up the ring `lacksRing` accepted, for the
|
|
123
|
+
// contrast check. Same recipes, same tests.
|
|
124
|
+
function ringVariables(target, recipe, isInput) {
|
|
125
|
+
const children = target.children ?? [];
|
|
126
|
+
const focusStrokes = (node) => (node.strokeVariables ?? []).filter(isFocusVariable);
|
|
127
|
+
if (recipe === 'mui' && isInput) return focusStrokes(target);
|
|
128
|
+
if (recipe === 'outline') {
|
|
129
|
+
return [target, ...children].filter((n) => n.strokeAlign === 'OUTSIDE').flatMap(focusStrokes);
|
|
130
|
+
}
|
|
131
|
+
const shadows = (target.effects ?? [])
|
|
132
|
+
.filter((e) => e.type === 'DROP_SHADOW' && e.visible !== false && e.spread > 0 && isFocusVariable(e.colorVariable))
|
|
133
|
+
.map((e) => e.colorVariable);
|
|
134
|
+
const ringChildren = children
|
|
135
|
+
.filter((c) => c.layoutPositioning === 'ABSOLUTE' && c.strokeAlign === 'OUTSIDE')
|
|
136
|
+
.flatMap(focusStrokes);
|
|
137
|
+
return [...shadows, ...ringChildren];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function withTable({
|
|
141
|
+
resolveArchetype,
|
|
142
|
+
BASELINE_STATES,
|
|
143
|
+
STATE_SYNONYMS,
|
|
144
|
+
normalizeName,
|
|
145
|
+
contrastRatio,
|
|
146
|
+
composite,
|
|
147
|
+
parseHex,
|
|
148
|
+
AA_NON_TEXT,
|
|
149
|
+
}) {
|
|
106
150
|
const fold = normalizeName;
|
|
107
151
|
const foldState = (value) => {
|
|
108
152
|
const name = fold(value);
|
|
@@ -122,6 +166,10 @@ export function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, n
|
|
|
122
166
|
.map(([k, v]) => `${k}=${v}`)
|
|
123
167
|
.join(', ');
|
|
124
168
|
|
|
169
|
+
const isFocusVariant = (child) =>
|
|
170
|
+
child.type === 'COMPONENT' &&
|
|
171
|
+
Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
|
|
172
|
+
|
|
125
173
|
function crossProduct(matrix) {
|
|
126
174
|
let combos = [{}];
|
|
127
175
|
for (const [axis, values] of Object.entries(matrix ?? {})) {
|
|
@@ -199,9 +247,6 @@ export function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, n
|
|
|
199
247
|
if (recipe === null) {
|
|
200
248
|
return { name, result: 'skip', detail: `uiFramework "${expect.uiFramework}" is out of scope for the focus check` };
|
|
201
249
|
}
|
|
202
|
-
const isFocusVariant = (child) =>
|
|
203
|
-
child.type === 'COMPONENT' &&
|
|
204
|
-
Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
|
|
205
250
|
const variants = (snapshot.children ?? []).filter(isFocusVariant);
|
|
206
251
|
if (variants.length === 0) {
|
|
207
252
|
return { name, result: 'fail', detail: 'no COMPONENT variant has a state of focus, so no ring can be read; the state check stops on this' };
|
|
@@ -224,6 +269,100 @@ export function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, n
|
|
|
224
269
|
: { name, result: 'pass', detail: `all ${variants.length} focus variants carry a ${recipe}-recipe focus indicator` };
|
|
225
270
|
}
|
|
226
271
|
|
|
272
|
+
// WCAG 1.4.11: each ring colour against each named surface, in every mode either
|
|
273
|
+
// side has. Runs only on a ring focusIndicator accepted.
|
|
274
|
+
function focusContrast(snapshot, expect, indicator) {
|
|
275
|
+
const name = 'focus-contrast';
|
|
276
|
+
if (indicator.result !== 'pass') {
|
|
277
|
+
return { name, result: 'skip', detail: 'focus-indicator did not pass, so there is no ring to measure' };
|
|
278
|
+
}
|
|
279
|
+
const surfaces = expect.surfaces ?? [];
|
|
280
|
+
if (surfaces.length === 0) {
|
|
281
|
+
return {
|
|
282
|
+
name,
|
|
283
|
+
result: 'skip',
|
|
284
|
+
detail: 'no surfaces named; the architect names the colour variables of the surfaces the component sits on',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const missing = snapshot.missingSurfaces ?? [];
|
|
288
|
+
if (missing.length) {
|
|
289
|
+
return { name, result: 'fail', detail: `no local colour variable named: ${missing.join(', ')}` };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const recipe = frameworkRecipe(expect.uiFramework);
|
|
293
|
+
const isInput = resolveArchetype({ name: expect.name }) === 'input';
|
|
294
|
+
const hasLayer = expect.controlLayer !== undefined && expect.controlLayer !== null;
|
|
295
|
+
const rings = new Set();
|
|
296
|
+
for (const variant of (snapshot.children ?? []).filter(isFocusVariant)) {
|
|
297
|
+
const target = hasLayer ? variant.controlLayer : variant;
|
|
298
|
+
if (target) for (const ring of ringVariables(target, recipe, isInput)) rings.add(ring);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const colors = snapshot.colors ?? {};
|
|
302
|
+
// A mode's value: the same-named mode after the fold, else the default.
|
|
303
|
+
const valueIn = (entry, foldedMode) => {
|
|
304
|
+
const modes = entry?.modes ?? {};
|
|
305
|
+
const hit = Object.keys(modes).find((m) => fold(m) === foldedMode);
|
|
306
|
+
if (hit !== undefined) return modes[hit];
|
|
307
|
+
return entry?.defaultMode !== undefined ? modes[entry.defaultMode] ?? null : null;
|
|
308
|
+
};
|
|
309
|
+
const skipped = { unresolved: 0, translucentSurface: 0 };
|
|
310
|
+
const failures = [];
|
|
311
|
+
const modesSeen = new Set();
|
|
312
|
+
let pairs = 0;
|
|
313
|
+
for (const ring of [...rings].sort()) {
|
|
314
|
+
for (const surface of [...surfaces].sort()) {
|
|
315
|
+
const ringEntry = colors[ring];
|
|
316
|
+
const surfaceEntry = colors[surface];
|
|
317
|
+
// Modes come from the side(s) with more than one: a single-mode collection
|
|
318
|
+
// reads the same value in every mode, so its own mode name adds nothing.
|
|
319
|
+
// When both are single-mode, the ring's one mode is the only comparison.
|
|
320
|
+
const entries = [ringEntry, surfaceEntry];
|
|
321
|
+
const multi = entries.filter((e) => Object.keys(e?.modes ?? {}).length > 1);
|
|
322
|
+
const single = Object.keys(ringEntry?.modes ?? {}).length ? [ringEntry] : [surfaceEntry];
|
|
323
|
+
const modeNames = new Map();
|
|
324
|
+
for (const entry of multi.length ? multi : single) {
|
|
325
|
+
for (const m of Object.keys(entry?.modes ?? {})) if (!modeNames.has(fold(m))) modeNames.set(fold(m), m);
|
|
326
|
+
}
|
|
327
|
+
if (modeNames.size === 0) {
|
|
328
|
+
skipped.unresolved += 1;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
for (const [foldedMode, mode] of [...modeNames].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
|
|
332
|
+
const ringHex = valueIn(ringEntry, foldedMode);
|
|
333
|
+
const surfaceHex = valueIn(surfaceEntry, foldedMode);
|
|
334
|
+
const surfaceParsed = parseHex(surfaceHex);
|
|
335
|
+
if (parseHex(ringHex) === null || surfaceParsed === null) {
|
|
336
|
+
skipped.unresolved += 1;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (surfaceParsed.a < 1) {
|
|
340
|
+
skipped.translucentSurface += 1;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const surfaceOpaque = surfaceHex.slice(0, 7);
|
|
344
|
+
const ratio = contrastRatio(composite(ringHex, surfaceOpaque), surfaceOpaque);
|
|
345
|
+
pairs += 1;
|
|
346
|
+
modesSeen.add(foldedMode);
|
|
347
|
+
if (ratio < AA_NON_TEXT) {
|
|
348
|
+
const shown = (Math.floor(ratio * 100) / 100).toFixed(2);
|
|
349
|
+
failures.push(`${ring} on ${surface} in ${mode}: ${shown}:1, needs ${AA_NON_TEXT}:1`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const skipNote = [
|
|
355
|
+
skipped.unresolved && `${skipped.unresolved} skipped as unresolved or not a colour`,
|
|
356
|
+
skipped.translucentSurface && `${skipped.translucentSurface} skipped for a translucent surface`,
|
|
357
|
+
].filter(Boolean);
|
|
358
|
+
if (failures.length) return { name, result: 'fail', detail: failures.join('; ') };
|
|
359
|
+
if (pairs === 0) {
|
|
360
|
+
return { name, result: 'skip', detail: `no ring/surface pair could be compared: ${skipNote.join(', ') || 'no ring colour found'}` };
|
|
361
|
+
}
|
|
362
|
+
const passed = `${pairs} ring/surface pair(s) clear ${AA_NON_TEXT}:1 across ${modesSeen.size} mode(s)`;
|
|
363
|
+
return { name, result: 'pass', detail: skipNote.length ? `${passed}; ${skipNote.join(', ')}` : passed };
|
|
364
|
+
}
|
|
365
|
+
|
|
227
366
|
// Before anything is written: the matrix the spec asks for against the state table.
|
|
228
367
|
function checkSpec(expect) {
|
|
229
368
|
const entries = Object.entries(expect.matrix ?? {}).flatMap(([axis, values]) => values.map((v) => [axis, v]));
|
|
@@ -233,7 +372,13 @@ export function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, n
|
|
|
233
372
|
// After the build: the node as read.
|
|
234
373
|
function checkBuilt(snapshot, expect) {
|
|
235
374
|
const entries = (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
|
|
236
|
-
|
|
375
|
+
const indicator = focusIndicator(snapshot, expect);
|
|
376
|
+
return result([
|
|
377
|
+
structural(snapshot, expect),
|
|
378
|
+
stateBaseline(expect, entries),
|
|
379
|
+
indicator,
|
|
380
|
+
focusContrast(snapshot, expect, indicator),
|
|
381
|
+
]);
|
|
237
382
|
}
|
|
238
383
|
|
|
239
384
|
return { checkSpec, checkBuilt };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"covers": "shadow with a raw colour and a variable with no focus
|
|
2
|
+
"covers": "shadow with a raw colour and a variable with no focus word",
|
|
3
3
|
"expect": {
|
|
4
4
|
"name": "Button",
|
|
5
5
|
"matrix": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"type": "DROP_SHADOW",
|
|
67
67
|
"visible": true,
|
|
68
68
|
"spread": 3,
|
|
69
|
-
"colorVariable": "color/
|
|
69
|
+
"colorVariable": "color/border/default"
|
|
70
70
|
}
|
|
71
71
|
],
|
|
72
72
|
"strokeAlign": "INSIDE",
|
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
"expected": {
|
|
94
94
|
"structural-read-back": "pass",
|
|
95
95
|
"state-baseline": "pass",
|
|
96
|
-
"focus-indicator": "fail"
|
|
96
|
+
"focus-indicator": "fail",
|
|
97
|
+
"focus-contrast": "skip"
|
|
97
98
|
}
|
|
98
99
|
}
|