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