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