@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.
- package/README.md +7 -5
- package/adapters/codex/AGENTS.md +1 -0
- package/adapters/codex/prompts/check-figma.md +41 -0
- package/adapters/codex/prompts/component-builder.md +8 -4
- package/adapters/cursor/.cursor/commands/check-figma.md +41 -0
- package/adapters/cursor/.cursor/rules/component-builder.mdc +8 -4
- package/adapters/generic/AGENTS.md +1 -0
- package/adapters/generic/commands/check-figma.md +41 -0
- package/adapters/generic/skills/component-builder/SKILL.md +8 -4
- package/package.json +1 -1
- package/references/figma-adherence.md +442 -0
- package/references/figma-checks.md +432 -40
- package/references/figma-component-standards.md +8 -0
- package/references/proof-bundle.md +1 -0
- package/scripts/README.md +7 -4
- package/scripts/build-figma-checks.mjs +139 -28
- package/scripts/install.mjs +5 -1
- package/scripts/lib/contrast.mjs +5 -2
- package/scripts/lib/figma-adherence-read.figma.js +207 -0
- package/scripts/lib/figma-adherence.mjs +146 -0
- package/scripts/lib/figma-checks-read.figma.js +68 -14
- package/scripts/lib/figma-checks.mjs +173 -17
- package/scripts/lib/figma-variables-read.figma.js +39 -0
- package/scripts/lib/fixtures/figma-adherence/clean.json +90 -0
- package/scripts/lib/fixtures/figma-adherence/detached-library.json +116 -0
- package/scripts/lib/fixtures/figma-adherence/ds01-gaps.json +84 -0
- package/scripts/lib/fixtures/figma-adherence/inert-all.json +45 -0
- package/scripts/lib/fixtures/figma-adherence/mixed-text.json +61 -0
- package/scripts/lib/fixtures/figma-adherence/nothing-scanned.json +49 -0
- package/scripts/lib/fixtures/figma-adherence/off-scale-gap.json +65 -0
- package/scripts/lib/fixtures/figma-adherence/override-raw.json +72 -0
- package/scripts/lib/fixtures/figma-adherence/padding-raw.json +74 -0
- package/scripts/lib/fixtures/figma-adherence/raw-colour-token.json +70 -0
- package/scripts/lib/fixtures/figma-adherence/root-radius.json +88 -0
- package/scripts/lib/fixtures/figma-adherence/skip-colour.json +86 -0
- package/scripts/lib/fixtures/figma-adherence/zygarden-override-bound.json +85 -0
- package/scripts/lib/fixtures/figma-adherence/zygarden-text-raw.json +208 -0
- 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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The adherence gate for Figma screens as a pure checker: detached components,
|
|
2
|
+
// raw colours a token has, and raw gaps, padding and radii a token has. ZERO
|
|
3
|
+
// imports, `export const`/`export function` only. It is inlined, with the shared
|
|
4
|
+
// variable reader and the screen reader below it, into the generated Figma script
|
|
5
|
+
// (references/figma-adherence.md) by build-figma-checks.mjs, so it must run in
|
|
6
|
+
// both Node and the Figma plugin sandbox.
|
|
7
|
+
//
|
|
8
|
+
// Spec: docs/specs/2026-10-13-figma-adherence-gate.md
|
|
9
|
+
// The code-side counterpart is scripts/validate-adherence.mjs. Both follow its
|
|
10
|
+
// spec's Decision 3: a raw value fails only when a token has it; any other raw
|
|
11
|
+
// value is an advisory.
|
|
12
|
+
//
|
|
13
|
+
// SNAPSHOT (what the reader returns):
|
|
14
|
+
// {
|
|
15
|
+
// fileKey, // for links; the caller passes it (TARGET.fileKey)
|
|
16
|
+
// nodes: [{
|
|
17
|
+
// id, path, // path: 'Page / Screen / … / Node'
|
|
18
|
+
// isRoot, // the screen frame itself
|
|
19
|
+
// inInstance, // an overridden node inside an INSTANCE (or the instance itself)
|
|
20
|
+
// underDetached, // a detached frame, or anything inside one
|
|
21
|
+
// overridden: ['fills' | 'strokes'], // inInstance nodes only
|
|
22
|
+
// paints: [{ kind: 'fill' | 'stroke', hex, bound, styled }], // visible SOLID paints only;
|
|
23
|
+
// // hex is '#rrggbb', or '#rrggbbaa' when the paint's opacity < 1
|
|
24
|
+
// mixedPaints, // fills is figma.mixed (text with mixed runs)
|
|
25
|
+
// gap: { value, bound, renders } | null,
|
|
26
|
+
// padding: [{ side, value, bound }],
|
|
27
|
+
// radius: [{ corner, value, bound }],
|
|
28
|
+
// detached: { kind: 'local' | 'library', name, id, key } | null,
|
|
29
|
+
// }],
|
|
30
|
+
// tokens: {
|
|
31
|
+
// colours: { '#rrggbb': [variableName] }, // every mode's value
|
|
32
|
+
// gap: { '<number>': [variableName] }, // FLOAT scoped GAP or ALL_SCOPES
|
|
33
|
+
// radius: { '<number>': [variableName] }, // FLOAT scoped CORNER_RADIUS or ALL_SCOPES
|
|
34
|
+
// },
|
|
35
|
+
// }
|
|
36
|
+
//
|
|
37
|
+
// RESULT: { pass, failures, advisories, counts, skipped }
|
|
38
|
+
// failures/advisories: [{ rule, id, path, detail, link }], sorted by path then detail
|
|
39
|
+
// counts: { [rule]: { checked, failed } }
|
|
40
|
+
// `pass` is false when any failure exists. A failed assertion is data; nothing throws.
|
|
41
|
+
|
|
42
|
+
export const RULES = ['detached-component', 'raw-colour', 'raw-dimension'];
|
|
43
|
+
|
|
44
|
+
const PAINT_FIELD = { fill: 'fills', stroke: 'strokes' };
|
|
45
|
+
|
|
46
|
+
// Which dimension tables a number variable belongs in. An explicit scope decides.
|
|
47
|
+
// A variable left at ALL_SCOPES (Figma's default) counts only when its name says
|
|
48
|
+
// what it is: on Zygarden every number variable is ALL_SCOPES, and without the
|
|
49
|
+
// name test a 72 px gap failed with "bind text/7xl". Letter-spacing says "spacing"
|
|
50
|
+
// but is type, not layout.
|
|
51
|
+
const SPACING_WORDS = /spac|gap|inset|stack|inline|padding|gutter/i;
|
|
52
|
+
const NOT_SPACING = /letter/i;
|
|
53
|
+
const RADIUS_WORDS = /radius|corner|round/i;
|
|
54
|
+
export function dimensionBuckets(name, scopes = []) {
|
|
55
|
+
const all = scopes.includes('ALL_SCOPES');
|
|
56
|
+
return {
|
|
57
|
+
gap: scopes.includes('GAP') || (all && SPACING_WORDS.test(name) && !NOT_SPACING.test(name)),
|
|
58
|
+
radius: scopes.includes('CORNER_RADIUS') || (all && RADIUS_WORDS.test(name)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function checkScreens(snapshot, { skip = [] } = {}) {
|
|
63
|
+
const nodes = snapshot.nodes ?? [];
|
|
64
|
+
const tokens = snapshot.tokens ?? {};
|
|
65
|
+
const colours = tokens.colours ?? {};
|
|
66
|
+
const gapTokens = tokens.gap ?? {};
|
|
67
|
+
const radiusTokens = tokens.radius ?? {};
|
|
68
|
+
const on = (rule) => !skip.includes(rule);
|
|
69
|
+
const link = (id) => `https://www.figma.com/design/${snapshot.fileKey}?node-id=${String(id).replace(/:/g, '-')}`;
|
|
70
|
+
|
|
71
|
+
const failures = [];
|
|
72
|
+
const advisories = [];
|
|
73
|
+
const counts = Object.fromEntries(RULES.filter(on).map((rule) => [rule, { checked: 0, failed: 0 }]));
|
|
74
|
+
const entry = (rule, node, detail) => ({ rule, id: node.id, path: node.path, detail, link: link(node.id) });
|
|
75
|
+
const fail = (rule, node, detail) => {
|
|
76
|
+
counts[rule].failed += 1;
|
|
77
|
+
failures.push(entry(rule, node, detail));
|
|
78
|
+
};
|
|
79
|
+
const advise = (rule, node, detail) => advisories.push(entry(rule, node, detail));
|
|
80
|
+
|
|
81
|
+
// A bound value passes. A raw one fails when a token has it, naming the token;
|
|
82
|
+
// otherwise it is an advisory.
|
|
83
|
+
const raw = (rule, node, table, value, bound, what) => {
|
|
84
|
+
counts[rule].checked += 1;
|
|
85
|
+
if (bound) return;
|
|
86
|
+
const names = table[String(value)];
|
|
87
|
+
if (names && names.length) fail(rule, node, `${what}: bind ${names.join(', ')}`);
|
|
88
|
+
else advise(rule, node, rule === 'raw-colour' ? `${what}: no colour variable has this value` : `off-scale ${what}`);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for (const node of nodes) {
|
|
92
|
+
if (on('detached-component')) {
|
|
93
|
+
counts['detached-component'].checked += 1;
|
|
94
|
+
const d = node.detached;
|
|
95
|
+
if (d) {
|
|
96
|
+
let from;
|
|
97
|
+
if (d.kind === 'library') from = `a library component (key ${d.key})`;
|
|
98
|
+
else if (d.name) from = d.name;
|
|
99
|
+
else from = `a local component on another page (id ${d.id})`;
|
|
100
|
+
fail('detached-component', node, `detached from ${from}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Inside a detached copy, the one detached-component failure is the finding:
|
|
105
|
+
// re-instancing fixes every raw value under it. On 01. Design System's
|
|
106
|
+
// Archtypes page, checking them anyway turned 8 detached frames into ~200 failures.
|
|
107
|
+
if (node.underDetached) continue;
|
|
108
|
+
|
|
109
|
+
if (on('raw-colour')) {
|
|
110
|
+
if (node.mixedPaints) advise('raw-colour', node, 'mixed paints not read');
|
|
111
|
+
for (const paint of node.paints ?? []) {
|
|
112
|
+
if (node.inInstance && !(node.overridden ?? []).includes(PAINT_FIELD[paint.kind])) continue;
|
|
113
|
+
raw('raw-colour', node, colours, paint.hex, paint.bound || paint.styled, `${paint.kind} ${paint.hex}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (on('raw-dimension') && !node.inInstance) {
|
|
118
|
+
const gap = node.gap;
|
|
119
|
+
if (gap && gap.renders && gap.value > 0) raw('raw-dimension', node, gapTokens, gap.value, gap.bound, `gap ${gap.value}`);
|
|
120
|
+
for (const p of node.padding ?? []) {
|
|
121
|
+
if (p.value > 0) raw('raw-dimension', node, gapTokens, p.value, p.bound, `padding-${p.side} ${p.value}`);
|
|
122
|
+
}
|
|
123
|
+
if (!node.isRoot) {
|
|
124
|
+
for (const r of node.radius ?? []) {
|
|
125
|
+
if (r.value > 0) raw('raw-dimension', node, radiusTokens, r.value, r.bound, `radius-${r.corner} ${r.value}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Every enabled rule must have had something to check (code gate Decision 7).
|
|
132
|
+
const inert = (rule, detail) => failures.push({ rule, id: null, path: '', detail, link: null });
|
|
133
|
+
if (nodes.length === 0) inert('nothing-scanned', 'no node was read; check the page and screen names');
|
|
134
|
+
if (on('raw-colour') && Object.keys(colours).length === 0) {
|
|
135
|
+
inert('colour-rule-inert', 'the file has no local colour variables, so no raw colour could be matched to one');
|
|
136
|
+
}
|
|
137
|
+
if (on('raw-dimension') && Object.keys(gapTokens).length === 0 && Object.keys(radiusTokens).length === 0) {
|
|
138
|
+
inert('dimension-rule-inert', 'the file has no local number variable for spacing or radius: none is scoped GAP or CORNER_RADIUS, and none left at ALL_SCOPES is named for spacing or radius');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
142
|
+
const byPath = (a, b) => cmp(a.path, b.path) || cmp(a.detail, b.detail);
|
|
143
|
+
failures.sort(byPath);
|
|
144
|
+
advisories.sort(byPath);
|
|
145
|
+
return { pass: failures.length === 0, failures, advisories, counts, skipped: RULES.filter((rule) => !on(rule)) };
|
|
146
|
+
}
|
|
@@ -1,21 +1,30 @@
|
|
|
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
|
|
3
|
+
// component-states.mjs, contrast.mjs, figma-checks.mjs and
|
|
4
|
+
// figma-variables-read.figma.js into 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
|
-
// component-states.mjs),
|
|
14
|
+
// component-states.mjs), variableColors (figma-variables-read.figma.js),
|
|
15
|
+
// checkSpec, checkBuilt, checkExisting (bound from withTable) and the
|
|
12
16
|
// caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
|
|
13
17
|
// shape is documented at the top of scripts/lib/figma-checks.mjs.
|
|
14
18
|
|
|
19
|
+
// Colour variables read on focus nodes, by name, for the contrast check.
|
|
20
|
+
const readVariables = new Map();
|
|
21
|
+
|
|
15
22
|
async function variableName(alias) {
|
|
16
23
|
if (!alias || !alias.id) return null;
|
|
17
24
|
const variable = await figma.variables.getVariableByIdAsync(alias.id);
|
|
18
|
-
|
|
25
|
+
if (!variable) return null;
|
|
26
|
+
if (variable.resolvedType === 'COLOR') readVariables.set(variable.name, variable);
|
|
27
|
+
return variable.name;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
// The fields the focus check reads from a variant, a control layer or a direct child.
|
|
@@ -75,18 +84,45 @@ function groupProperties(set) {
|
|
|
75
84
|
}
|
|
76
85
|
}
|
|
77
86
|
|
|
78
|
-
|
|
87
|
+
// The set a build just made: the COMPONENT_SET in the `WIP: <name>` frame on the
|
|
88
|
+
// current page. A frame holding plain FRAMEs where the set should be is a failed
|
|
89
|
+
// assertion, not a missing target, so it comes back as `{ empty }` for the
|
|
90
|
+
// structural check to name.
|
|
91
|
+
async function findWipSet(expect) {
|
|
79
92
|
await figma.currentPage.loadAsync();
|
|
80
93
|
const frameName = `WIP: ${expect.name}`;
|
|
81
94
|
const frame = figma.currentPage.findOne((n) => n.name === frameName);
|
|
82
95
|
if (!frame) throw new Error(`no node named "${frameName}" on the current page`);
|
|
83
96
|
const set = frame.type === 'COMPONENT_SET' ? frame : (frame.children || []).find((c) => c.type === 'COMPONENT_SET');
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
if (set) return { set };
|
|
98
|
+
const first = (frame.children || [])[0];
|
|
99
|
+
return { empty: { type: first ? first.type : 'NONE', variantGroupProperties: {}, children: [] } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A set already in the library: the first COMPONENT_SET whose name folds to the
|
|
103
|
+
// same name. With `pageName`, only that page is loaded (exact name, or a unique
|
|
104
|
+
// match on the trimmed name). Without it every page is loaded in turn until one
|
|
105
|
+
// has the set, which on a miss is the whole file; a whole-file load stalled the
|
|
106
|
+
// bridge on a large file, so callers pass the page.
|
|
107
|
+
async function findExistingSet(name, pageName) {
|
|
108
|
+
const want = normalizeName(name);
|
|
109
|
+
let pages = figma.root.children;
|
|
110
|
+
if (pageName !== undefined && pageName !== null) {
|
|
111
|
+
const exact = pages.filter((p) => p.name === pageName);
|
|
112
|
+
const trimmed = pages.filter((p) => p.name.trim() === String(pageName).trim());
|
|
113
|
+
pages = exact.length ? exact : trimmed.length === 1 ? trimmed : [];
|
|
114
|
+
if (!pages.length) throw new Error(`no page named "${pageName}"`);
|
|
89
115
|
}
|
|
116
|
+
for (const page of pages) {
|
|
117
|
+
await page.loadAsync();
|
|
118
|
+
const set = page.findAllWithCriteria({ types: ['COMPONENT_SET'] }).find((s) => normalizeName(s.name) === want);
|
|
119
|
+
if (set) return set;
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`no component set named "${name}"${pageName ? ` on page "${pageName}"` : ' in this file'}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function readSet(set, expect) {
|
|
125
|
+
readVariables.clear();
|
|
90
126
|
|
|
91
127
|
const isFocusState = (props) =>
|
|
92
128
|
Object.entries(props || {}).some(([k, v]) => {
|
|
@@ -106,15 +142,33 @@ async function readComponentSet(expect) {
|
|
|
106
142
|
}
|
|
107
143
|
children.push(read);
|
|
108
144
|
}
|
|
145
|
+
const colors = {};
|
|
146
|
+
for (const [name, variable] of readVariables) colors[name] = await variableColors(variable);
|
|
147
|
+
const missingSurfaces = [];
|
|
148
|
+
const surfaces = expect.surfaces || [];
|
|
149
|
+
if (surfaces.length) {
|
|
150
|
+
const local = await figma.variables.getLocalVariablesAsync('COLOR');
|
|
151
|
+
for (const name of surfaces) {
|
|
152
|
+
const variable = local.find((v) => v.name === name);
|
|
153
|
+
if (variable) colors[name] = await variableColors(variable);
|
|
154
|
+
else missingSurfaces.push(name);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
109
157
|
return {
|
|
110
158
|
type: set.type,
|
|
111
159
|
variantGroupProperties: groupProperties(set),
|
|
112
160
|
children,
|
|
161
|
+
colors,
|
|
162
|
+
missingSurfaces,
|
|
113
163
|
};
|
|
114
164
|
}
|
|
115
165
|
|
|
116
166
|
async function runFigmaChecks(EXPECT, MODE) {
|
|
117
167
|
if (MODE === 'spec') return checkSpec(EXPECT);
|
|
118
|
-
if (MODE === 'built')
|
|
119
|
-
|
|
168
|
+
if (MODE === 'built') {
|
|
169
|
+
const { set, empty } = await findWipSet(EXPECT);
|
|
170
|
+
return checkBuilt(set ? await readSet(set, EXPECT) : empty, EXPECT);
|
|
171
|
+
}
|
|
172
|
+
if (MODE === 'existing') return checkExisting(await readSet(await findExistingSet(EXPECT.name, EXPECT.page), EXPECT), EXPECT);
|
|
173
|
+
throw new Error(`MODE must be 'spec', 'built' or 'existing', got ${JSON.stringify(MODE)}`);
|
|
120
174
|
}
|
|
@@ -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, checkExisting }. 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,9 @@
|
|
|
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. existing mode (a set already in the library,
|
|
50
|
+
// docs/specs/2026-10-13-figma-adherence-gate.md) returns the last three.
|
|
39
51
|
// `pass` ignores skips. A failed assertion is data; nothing here throws.
|
|
40
52
|
|
|
41
53
|
// The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
|
|
@@ -61,11 +73,17 @@ function frameworkRecipe(uiFramework) {
|
|
|
61
73
|
: null;
|
|
62
74
|
}
|
|
63
75
|
|
|
64
|
-
// A focus variable is any variable whose name has
|
|
65
|
-
//
|
|
66
|
-
//
|
|
76
|
+
// A focus variable is any variable whose name has `focus` as a whole word: in a
|
|
77
|
+
// `/` segment of its own, or inside one split on `-`, `_`, spaces or a camelCase
|
|
78
|
+
// boundary. `border/focus`, `color/focus/ring`, `color/focusRing` and
|
|
79
|
+
// `interaction/border-primary-focus` count; `color/bg/focused` and a raw colour
|
|
80
|
+
// (null) do not.
|
|
67
81
|
function isFocusVariable(name) {
|
|
68
|
-
|
|
82
|
+
if (typeof name !== 'string') return false;
|
|
83
|
+
const words = name
|
|
84
|
+
.split('/')
|
|
85
|
+
.flatMap((seg) => seg.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[\s_-]+/));
|
|
86
|
+
return words.some((word) => word.toLowerCase() === 'focus');
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
// A choice control may carry selection on its own axis instead of as a `selected`
|
|
@@ -102,7 +120,34 @@ function lacksRing(target, recipe, isInput) {
|
|
|
102
120
|
];
|
|
103
121
|
}
|
|
104
122
|
|
|
105
|
-
|
|
123
|
+
// The focus-variable names that make up the ring `lacksRing` accepted, for the
|
|
124
|
+
// contrast check. Same recipes, same tests.
|
|
125
|
+
function ringVariables(target, recipe, isInput) {
|
|
126
|
+
const children = target.children ?? [];
|
|
127
|
+
const focusStrokes = (node) => (node.strokeVariables ?? []).filter(isFocusVariable);
|
|
128
|
+
if (recipe === 'mui' && isInput) return focusStrokes(target);
|
|
129
|
+
if (recipe === 'outline') {
|
|
130
|
+
return [target, ...children].filter((n) => n.strokeAlign === 'OUTSIDE').flatMap(focusStrokes);
|
|
131
|
+
}
|
|
132
|
+
const shadows = (target.effects ?? [])
|
|
133
|
+
.filter((e) => e.type === 'DROP_SHADOW' && e.visible !== false && e.spread > 0 && isFocusVariable(e.colorVariable))
|
|
134
|
+
.map((e) => e.colorVariable);
|
|
135
|
+
const ringChildren = children
|
|
136
|
+
.filter((c) => c.layoutPositioning === 'ABSOLUTE' && c.strokeAlign === 'OUTSIDE')
|
|
137
|
+
.flatMap(focusStrokes);
|
|
138
|
+
return [...shadows, ...ringChildren];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function withTable({
|
|
142
|
+
resolveArchetype,
|
|
143
|
+
BASELINE_STATES,
|
|
144
|
+
STATE_SYNONYMS,
|
|
145
|
+
normalizeName,
|
|
146
|
+
contrastRatio,
|
|
147
|
+
composite,
|
|
148
|
+
parseHex,
|
|
149
|
+
AA_NON_TEXT,
|
|
150
|
+
}) {
|
|
106
151
|
const fold = normalizeName;
|
|
107
152
|
const foldState = (value) => {
|
|
108
153
|
const name = fold(value);
|
|
@@ -122,6 +167,10 @@ export function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, n
|
|
|
122
167
|
.map(([k, v]) => `${k}=${v}`)
|
|
123
168
|
.join(', ');
|
|
124
169
|
|
|
170
|
+
const isFocusVariant = (child) =>
|
|
171
|
+
child.type === 'COMPONENT' &&
|
|
172
|
+
Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
|
|
173
|
+
|
|
125
174
|
function crossProduct(matrix) {
|
|
126
175
|
let combos = [{}];
|
|
127
176
|
for (const [axis, values] of Object.entries(matrix ?? {})) {
|
|
@@ -199,9 +248,6 @@ export function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, n
|
|
|
199
248
|
if (recipe === null) {
|
|
200
249
|
return { name, result: 'skip', detail: `uiFramework "${expect.uiFramework}" is out of scope for the focus check` };
|
|
201
250
|
}
|
|
202
|
-
const isFocusVariant = (child) =>
|
|
203
|
-
child.type === 'COMPONENT' &&
|
|
204
|
-
Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
|
|
205
251
|
const variants = (snapshot.children ?? []).filter(isFocusVariant);
|
|
206
252
|
if (variants.length === 0) {
|
|
207
253
|
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,17 +270,127 @@ export function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, n
|
|
|
224
270
|
: { name, result: 'pass', detail: `all ${variants.length} focus variants carry a ${recipe}-recipe focus indicator` };
|
|
225
271
|
}
|
|
226
272
|
|
|
273
|
+
// WCAG 1.4.11: each ring colour against each named surface, in every mode either
|
|
274
|
+
// side has. Runs only on a ring focusIndicator accepted.
|
|
275
|
+
function focusContrast(snapshot, expect, indicator) {
|
|
276
|
+
const name = 'focus-contrast';
|
|
277
|
+
if (indicator.result !== 'pass') {
|
|
278
|
+
return { name, result: 'skip', detail: 'focus-indicator did not pass, so there is no ring to measure' };
|
|
279
|
+
}
|
|
280
|
+
const surfaces = expect.surfaces ?? [];
|
|
281
|
+
if (surfaces.length === 0) {
|
|
282
|
+
return {
|
|
283
|
+
name,
|
|
284
|
+
result: 'skip',
|
|
285
|
+
detail: 'no surfaces named; name the colour variables of the surfaces the component sits on (from the architect spec, or ask the user)',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const missing = snapshot.missingSurfaces ?? [];
|
|
289
|
+
if (missing.length) {
|
|
290
|
+
return { name, result: 'fail', detail: `no local colour variable named: ${missing.join(', ')}` };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const recipe = frameworkRecipe(expect.uiFramework);
|
|
294
|
+
const isInput = resolveArchetype({ name: expect.name }) === 'input';
|
|
295
|
+
const hasLayer = expect.controlLayer !== undefined && expect.controlLayer !== null;
|
|
296
|
+
const rings = new Set();
|
|
297
|
+
for (const variant of (snapshot.children ?? []).filter(isFocusVariant)) {
|
|
298
|
+
const target = hasLayer ? variant.controlLayer : variant;
|
|
299
|
+
if (target) for (const ring of ringVariables(target, recipe, isInput)) rings.add(ring);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const colors = snapshot.colors ?? {};
|
|
303
|
+
// A mode's value: the same-named mode after the fold, else the default.
|
|
304
|
+
const valueIn = (entry, foldedMode) => {
|
|
305
|
+
const modes = entry?.modes ?? {};
|
|
306
|
+
const hit = Object.keys(modes).find((m) => fold(m) === foldedMode);
|
|
307
|
+
if (hit !== undefined) return modes[hit];
|
|
308
|
+
return entry?.defaultMode !== undefined ? modes[entry.defaultMode] ?? null : null;
|
|
309
|
+
};
|
|
310
|
+
const skipped = { unresolved: 0, translucentSurface: 0 };
|
|
311
|
+
const failures = [];
|
|
312
|
+
const modesSeen = new Set();
|
|
313
|
+
let pairs = 0;
|
|
314
|
+
for (const ring of [...rings].sort()) {
|
|
315
|
+
for (const surface of [...surfaces].sort()) {
|
|
316
|
+
const ringEntry = colors[ring];
|
|
317
|
+
const surfaceEntry = colors[surface];
|
|
318
|
+
// Modes come from the side(s) with more than one: a single-mode collection
|
|
319
|
+
// reads the same value in every mode, so its own mode name adds nothing.
|
|
320
|
+
// When both are single-mode, the ring's one mode is the only comparison.
|
|
321
|
+
const entries = [ringEntry, surfaceEntry];
|
|
322
|
+
const multi = entries.filter((e) => Object.keys(e?.modes ?? {}).length > 1);
|
|
323
|
+
const single = Object.keys(ringEntry?.modes ?? {}).length ? [ringEntry] : [surfaceEntry];
|
|
324
|
+
const modeNames = new Map();
|
|
325
|
+
for (const entry of multi.length ? multi : single) {
|
|
326
|
+
for (const m of Object.keys(entry?.modes ?? {})) if (!modeNames.has(fold(m))) modeNames.set(fold(m), m);
|
|
327
|
+
}
|
|
328
|
+
if (modeNames.size === 0) {
|
|
329
|
+
skipped.unresolved += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
for (const [foldedMode, mode] of [...modeNames].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
|
|
333
|
+
const ringHex = valueIn(ringEntry, foldedMode);
|
|
334
|
+
const surfaceHex = valueIn(surfaceEntry, foldedMode);
|
|
335
|
+
const surfaceParsed = parseHex(surfaceHex);
|
|
336
|
+
if (parseHex(ringHex) === null || surfaceParsed === null) {
|
|
337
|
+
skipped.unresolved += 1;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (surfaceParsed.a < 1) {
|
|
341
|
+
skipped.translucentSurface += 1;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const surfaceOpaque = surfaceHex.slice(0, 7);
|
|
345
|
+
const ratio = contrastRatio(composite(ringHex, surfaceOpaque), surfaceOpaque);
|
|
346
|
+
pairs += 1;
|
|
347
|
+
modesSeen.add(foldedMode);
|
|
348
|
+
if (ratio < AA_NON_TEXT) {
|
|
349
|
+
const shown = (Math.floor(ratio * 100) / 100).toFixed(2);
|
|
350
|
+
failures.push(`${ring} on ${surface} in ${mode}: ${shown}:1, needs ${AA_NON_TEXT}:1`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const skipNote = [
|
|
356
|
+
skipped.unresolved && `${skipped.unresolved} skipped as unresolved or not a colour`,
|
|
357
|
+
skipped.translucentSurface && `${skipped.translucentSurface} skipped for a translucent surface`,
|
|
358
|
+
].filter(Boolean);
|
|
359
|
+
if (failures.length) return { name, result: 'fail', detail: failures.join('; ') };
|
|
360
|
+
if (pairs === 0) {
|
|
361
|
+
return { name, result: 'skip', detail: `no ring/surface pair could be compared: ${skipNote.join(', ') || 'no ring colour found'}` };
|
|
362
|
+
}
|
|
363
|
+
const passed = `${pairs} ring/surface pair(s) clear ${AA_NON_TEXT}:1 across ${modesSeen.size} mode(s)`;
|
|
364
|
+
return { name, result: 'pass', detail: skipNote.length ? `${passed}; ${skipNote.join(', ')}` : passed };
|
|
365
|
+
}
|
|
366
|
+
|
|
227
367
|
// Before anything is written: the matrix the spec asks for against the state table.
|
|
228
368
|
function checkSpec(expect) {
|
|
229
369
|
const entries = Object.entries(expect.matrix ?? {}).flatMap(([axis, values]) => values.map((v) => [axis, v]));
|
|
230
370
|
return result([stateBaseline(expect, entries)]);
|
|
231
371
|
}
|
|
232
372
|
|
|
373
|
+
const variantEntries = (snapshot) => (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
|
|
374
|
+
|
|
233
375
|
// After the build: the node as read.
|
|
234
376
|
function checkBuilt(snapshot, expect) {
|
|
235
|
-
const entries = (snapshot
|
|
236
|
-
|
|
377
|
+
const entries = variantEntries(snapshot);
|
|
378
|
+
const indicator = focusIndicator(snapshot, expect);
|
|
379
|
+
return result([
|
|
380
|
+
structural(snapshot, expect),
|
|
381
|
+
stateBaseline(expect, entries),
|
|
382
|
+
indicator,
|
|
383
|
+
focusContrast(snapshot, expect, indicator),
|
|
384
|
+
]);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// An existing set has no spec matrix, so the structural check is not run; the
|
|
388
|
+
// other three read only the set itself.
|
|
389
|
+
function checkExisting(snapshot, expect) {
|
|
390
|
+
const entries = variantEntries(snapshot);
|
|
391
|
+
const indicator = focusIndicator(snapshot, expect);
|
|
392
|
+
return result([stateBaseline(expect, entries), indicator, focusContrast(snapshot, expect, indicator)]);
|
|
237
393
|
}
|
|
238
394
|
|
|
239
|
-
return { checkSpec, checkBuilt };
|
|
395
|
+
return { checkSpec, checkBuilt, checkExisting };
|
|
240
396
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Figma plugin-API helpers for reading colour variables, shared by the Figma
|
|
2
|
+
// checks reader and the screen reader. This file is NOT a Node module:
|
|
3
|
+
// build-figma-checks.mjs inlines it into both generated scripts
|
|
4
|
+
// (references/figma-checks.md, references/figma-adherence.md). It depends on
|
|
5
|
+
// nothing else, so it carries its own mode-name fold. Async getters only
|
|
6
|
+
// (references/figma-scripting.md). Untested in Node, like the readers.
|
|
7
|
+
|
|
8
|
+
const foldModeName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
9
|
+
|
|
10
|
+
function colorHex(value) {
|
|
11
|
+
if (!value || typeof value !== 'object' || !('r' in value)) return null;
|
|
12
|
+
const byte = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
13
|
+
const alpha = value.a === undefined ? 1 : value.a;
|
|
14
|
+
return `#${byte(value.r)}${byte(value.g)}${byte(value.b)}${alpha < 1 ? byte(alpha) : ''}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The value of `variable` in the mode named `modeName` (after the fold), else
|
|
18
|
+
// its collection's default mode, following aliases up to 10 hops.
|
|
19
|
+
async function resolveColor(variable, modeName, hops) {
|
|
20
|
+
if (!variable || hops > 10) return null;
|
|
21
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
22
|
+
if (!collection) return null;
|
|
23
|
+
const mode = collection.modes.find((m) => foldModeName(m.name) === foldModeName(modeName));
|
|
24
|
+
const value = variable.valuesByMode[mode ? mode.modeId : collection.defaultModeId];
|
|
25
|
+
if (value && value.type === 'VARIABLE_ALIAS') {
|
|
26
|
+
return resolveColor(await figma.variables.getVariableByIdAsync(value.id), modeName, hops + 1);
|
|
27
|
+
}
|
|
28
|
+
return colorHex(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// { modes: { [modeName]: hex | null }, defaultMode } over the variable's own collection.
|
|
32
|
+
async function variableColors(variable) {
|
|
33
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
34
|
+
if (!collection) return { modes: {}, defaultMode: undefined };
|
|
35
|
+
const modes = {};
|
|
36
|
+
for (const mode of collection.modes) modes[mode.name] = await resolveColor(variable, mode.name, 0);
|
|
37
|
+
const fallback = collection.modes.find((m) => m.modeId === collection.defaultModeId);
|
|
38
|
+
return { modes, defaultMode: fallback ? fallback.name : undefined };
|
|
39
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"covers": "everything bound passes with no advisories",
|
|
3
|
+
"snapshot": {
|
|
4
|
+
"fileKey": "FILE",
|
|
5
|
+
"screens": [
|
|
6
|
+
{
|
|
7
|
+
"id": "0:1",
|
|
8
|
+
"name": "Screen"
|
|
9
|
+
}
|
|
10
|
+
],
|
|
11
|
+
"nodes": [
|
|
12
|
+
{
|
|
13
|
+
"id": "1:1",
|
|
14
|
+
"path": "Templates / Screen / Node",
|
|
15
|
+
"isRoot": false,
|
|
16
|
+
"inInstance": false,
|
|
17
|
+
"overridden": [],
|
|
18
|
+
"paints": [
|
|
19
|
+
{
|
|
20
|
+
"kind": "fill",
|
|
21
|
+
"hex": "#ffffff",
|
|
22
|
+
"bound": true,
|
|
23
|
+
"styled": false
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"kind": "stroke",
|
|
27
|
+
"hex": "#111827",
|
|
28
|
+
"bound": false,
|
|
29
|
+
"styled": true
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"mixedPaints": false,
|
|
33
|
+
"gap": {
|
|
34
|
+
"value": 12,
|
|
35
|
+
"bound": true,
|
|
36
|
+
"renders": true
|
|
37
|
+
},
|
|
38
|
+
"padding": [
|
|
39
|
+
{
|
|
40
|
+
"side": "top",
|
|
41
|
+
"value": 16,
|
|
42
|
+
"bound": true
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"radius": [
|
|
46
|
+
{
|
|
47
|
+
"corner": "all",
|
|
48
|
+
"value": 8,
|
|
49
|
+
"bound": true
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"detached": null
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"tokens": {
|
|
56
|
+
"colours": {
|
|
57
|
+
"#ffffff": [
|
|
58
|
+
"color/bg/canvas"
|
|
59
|
+
],
|
|
60
|
+
"#111827": [
|
|
61
|
+
"color/text/primary"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"gap": {
|
|
65
|
+
"8": [
|
|
66
|
+
"spacing/2"
|
|
67
|
+
],
|
|
68
|
+
"12": [
|
|
69
|
+
"spacing/3"
|
|
70
|
+
],
|
|
71
|
+
"16": [
|
|
72
|
+
"spacing/4"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"radius": {
|
|
76
|
+
"8": [
|
|
77
|
+
"radius/md"
|
|
78
|
+
],
|
|
79
|
+
"32": [
|
|
80
|
+
"radius/xl"
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"expected": {
|
|
86
|
+
"pass": true,
|
|
87
|
+
"failures": [],
|
|
88
|
+
"advisories": 0
|
|
89
|
+
}
|
|
90
|
+
}
|