@radicool/throughline 0.21.4 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +5 -5
  2. package/adapters/codex/prompts/component-builder.md +12 -7
  3. package/adapters/codex/prompts/token-sync-layer.md +4 -1
  4. package/adapters/cursor/.cursor/rules/component-builder.mdc +12 -7
  5. package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +4 -1
  6. package/adapters/generic/skills/component-builder/SKILL.md +12 -7
  7. package/adapters/generic/skills/token-sync-layer/SKILL.md +4 -1
  8. package/package.json +1 -1
  9. package/references/figma-checks.md +484 -0
  10. package/references/figma-component-standards.md +21 -53
  11. package/references/figma-scripting.md +8 -7
  12. package/references/state-baseline.md +27 -0
  13. package/scripts/README.md +5 -1
  14. package/scripts/build-figma-checks.mjs +98 -0
  15. package/scripts/build-state-baseline.mjs +71 -0
  16. package/scripts/install.mjs +9 -4
  17. package/scripts/lib/component-states.mjs +28 -7
  18. package/scripts/lib/figma-checks-read.figma.js +120 -0
  19. package/scripts/lib/figma-checks.mjs +240 -0
  20. package/scripts/lib/fixtures/figma-checks/avatar-not-in-table.json +43 -0
  21. package/scripts/lib/fixtures/figma-checks/button-ios-swift-skipped.json +85 -0
  22. package/scripts/lib/fixtures/figma-checks/button-missing-default.json +71 -0
  23. package/scripts/lib/fixtures/figma-checks/button-mui-ring.json +92 -0
  24. package/scripts/lib/fixtures/figma-checks/button-no-focus-variant.json +71 -0
  25. package/scripts/lib/fixtures/figma-checks/button-plain-frames.json +92 -0
  26. package/scripts/lib/fixtures/figma-checks/button-ring-child-not-absolute.json +94 -0
  27. package/scripts/lib/fixtures/figma-checks/button-ring-hidden.json +92 -0
  28. package/scripts/lib/fixtures/figma-checks/button-ring-unbound-colour.json +98 -0
  29. package/scripts/lib/fixtures/figma-checks/button-shadcn-filled.json +92 -0
  30. package/scripts/lib/fixtures/figma-checks/button-sparse.json +134 -0
  31. package/scripts/lib/fixtures/figma-checks/button-spread-zero.json +92 -0
  32. package/scripts/lib/fixtures/figma-checks/button-transparent-ring-child.json +94 -0
  33. package/scripts/lib/fixtures/figma-checks/button-vanilla-inside.json +87 -0
  34. package/scripts/lib/fixtures/figma-checks/button-vanilla-outside.json +87 -0
  35. package/scripts/lib/fixtures/figma-checks/button-wrong-combination.json +92 -0
  36. package/scripts/lib/fixtures/figma-checks/checkbox-checked-axis.real.json +245 -0
  37. package/scripts/lib/fixtures/figma-checks/checkbox-checked-synonym.json +87 -0
  38. package/scripts/lib/fixtures/figma-checks/checkbox-missing-selected.json +79 -0
  39. package/scripts/lib/fixtures/figma-checks/input-control-layer-missing.json +66 -0
  40. package/scripts/lib/fixtures/figma-checks/input-control-layer-ring-on-wrapper.json +79 -0
  41. package/scripts/lib/fixtures/figma-checks/input-control-layer.json +79 -0
  42. package/scripts/lib/fixtures/figma-checks/input-mui-weight-unbound.json +66 -0
  43. package/scripts/lib/fixtures/figma-checks/input-mui.json +66 -0
  44. package/scripts/lib/fixtures/figma-checks/sample-button.real.json +1497 -0
@@ -0,0 +1,484 @@
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/figma-checks.mjs` (the pure checker, unit-tested in
5
+ > Node) + `scripts/lib/figma-checks-read.figma.js` (the Figma reader). Regenerate with
6
+ > `node scripts/build-figma-checks.mjs`; CI gates freshness with `--check`.
7
+
8
+ The canonical `figma_execute` snippet for the checks that can stop a component
9
+ build: the COMPONENT_SET assertion against the full variant matrix, the state
10
+ matrix, and the focus ring. The script is the rule; do not re-write these checks
11
+ by hand.
12
+
13
+ ## How to call it
14
+
15
+ 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
17
+ component's real name; `matrix` is `{ axis: [values] }` for every axis of the
18
+ set; `uiFramework` is `project.uiFramework` from `design-system.json`;
19
+ `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
+ - `const MODE = 'spec'` or `'built'`.
22
+ 2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass
23
+ `timeout: 30000`.
24
+ 3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is
25
+ `pass`, `fail` or `skip`; `pass` ignores skips. On `fail`, fix what each failing
26
+ check's `detail` names and run it again, within the ~3-pass budget; after that,
27
+ `BLOCKED` naming the failing checks' `detail`.
28
+ 4. `'spec'` reads no node and runs before the first write. `'built'` reads the
29
+ COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs
30
+ in the self-verify loop. It throws only if that frame is missing; a frame with
31
+ no set, or a set Figma reports as conflicting, fails the structural check.
32
+
33
+ ```js
34
+ const EXPECT = { name: 'Chip', matrix: { state: ['default', 'hover'] }, uiFramework: 'shadcn' };
35
+ const MODE = 'spec';
36
+ // … the generated snippet …
37
+ return await runFigmaChecks(EXPECT, MODE);
38
+ ```
39
+
40
+ ## The snippet
41
+
42
+ ```js
43
+ // The archetype baseline for `state-incomplete` (`docs/specs/2026-09-12-verification-proof-bundle.md`,
44
+ // step 3). The prose in `references/figma-component-standards.md` (State handling) points at
45
+ // `references/state-baseline.md`, which is generated from this file.
46
+ //
47
+ // `default` is deliberately excluded from every baseline below: documenting the
48
+ // resting state is not documentation, and the Figma matrix requirement for it is
49
+ // the executor's, not this table's. Conditional states (`loading`, `error`,
50
+ // `success`) are deliberately excluded too — whether one applies to a given
51
+ // component is a design judgment, not something a baseline can assert. `selected`
52
+ // is not conditional for a choice control: a checkbox, radio, toggle, switch or
53
+ // chip that cannot be selected is not one.
54
+ //
55
+ // This table is the single source for both checks. `scripts/build-state-baseline.mjs`
56
+ // renders it into `references/state-baseline.md`, which the Figma executor reads;
57
+ // CI fails when that file is stale. MAINTENANCE: change this table, then run it.
58
+
59
+ // Defined here rather than imported from source-scan.mjs so this module has no
60
+ // imports and can be inlined into the Figma checks script. A test keeps the two
61
+ // copies in step.
62
+ const normalizeName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
63
+
64
+ const ARCHETYPES = ['button', 'input', 'choice', 'card', 'modal', 'badge', 'other'];
65
+
66
+ const BASELINE_STATES = {
67
+ button: ['hover', 'focus', 'active', 'disabled'],
68
+ input: ['hover', 'focus', 'disabled'],
69
+ choice: ['hover', 'focus', 'active', 'selected', 'disabled'],
70
+ card: [],
71
+ modal: [],
72
+ badge: [],
73
+ other: [],
74
+ };
75
+
76
+ const NAME_SYNONYMS = {
77
+ button: 'button',
78
+ input: 'input',
79
+ textfield: 'input',
80
+ textinput: 'input',
81
+ checkbox: 'choice',
82
+ radio: 'choice',
83
+ toggle: 'choice',
84
+ switch: 'choice',
85
+ chip: 'choice',
86
+ card: 'card',
87
+ modal: 'modal',
88
+ dialog: 'modal',
89
+ badge: 'badge',
90
+ tag: 'badge',
91
+ };
92
+
93
+ // Other names a recorded state goes by. shadcn and Radix call a checked checkbox
94
+ // `checked` and a pressed-in toggle `on`; both are `selected` here. `pressed` is
95
+ // not one: the standards use it for `active`.
96
+ const STATE_SYNONYMS = {
97
+ checked: 'selected',
98
+ on: 'selected',
99
+ };
100
+
101
+ function resolveArchetype(record) {
102
+ if (ARCHETYPES.includes(record.archetype)) return record.archetype;
103
+ const bySynonym = NAME_SYNONYMS[normalizeName(record.name)];
104
+ return bySynonym ?? null;
105
+ }
106
+
107
+ function missingStates(record) {
108
+ const archetype = resolveArchetype(record);
109
+ if (archetype === null) return [];
110
+ const baseline = BASELINE_STATES[archetype];
111
+ if (baseline.length === 0) return [];
112
+ const recordedKeys = new Set(
113
+ Object.keys(record.states ?? {}).map((key) => {
114
+ const name = normalizeName(key);
115
+ return STATE_SYNONYMS[name] ?? name;
116
+ }),
117
+ );
118
+ return baseline.filter((state) => !recordedKeys.has(normalizeName(state)));
119
+ }
120
+
121
+ // The Figma stop conditions as a pure checker: the COMPONENT_SET assertion against
122
+ // the full variant matrix, the state matrix, and the focus ring. ZERO imports,
123
+ // `export const`/`export function` only. This module is inlined, with
124
+ // component-states.mjs above it, into the generated Figma script
125
+ // (references/figma-checks.md) by build-figma-checks.mjs, so it must run in both
126
+ // Node and the Figma plugin sandbox.
127
+ //
128
+ // The state table is not imported. `withTable({ resolveArchetype, BASELINE_STATES,
129
+ // STATE_SYNONYMS, normalizeName })` takes it, and the fold, from the caller and
130
+ // returns { checkSpec, checkBuilt }. In the generated script the caller passes the
131
+ // inlined component-states.mjs names; in tests it passes the module's own exports.
132
+ //
133
+ // Spec: docs/specs/2026-10-07-figma-checks-as-scripts.md
134
+ // Rules: references/figma-component-standards.md (State handling; audit items 8, 9)
135
+ //
136
+ // EXPECT: { name, matrix: { axis: [values] }, uiFramework, controlLayer? }
137
+ //
138
+ // SNAPSHOT (what the reader returns for the COMPONENT_SET in `WIP: <name>`):
139
+ // {
140
+ // type, // the set's node type, 'COMPONENT_SET'
141
+ // variantGroupProperties: { Axis: { values: [...] }, ... }, // only the keys are read
142
+ // children: [ VARIANT, ... ]
143
+ // }
144
+ // VARIANT (every child): { type, variantProperties: { Axis: 'Value' } }
145
+ // A VARIANT whose state value folds to `focus` also carries FOCUS NODE fields
146
+ // (below), plus `controlLayer` when EXPECT names one.
147
+ // FOCUS NODE (the variant itself, and the controlLayer subtree):
148
+ // {
149
+ // effects: [{ type, visible, spread, colorVariable }], // colorVariable: bound colour variable name or null
150
+ // strokeAlign, // 'INSIDE' | 'OUTSIDE' | 'CENTER'
151
+ // strokeVariables: [name, ...], // names of variables bound to stroke colours
152
+ // strokeWeightBound, // boundVariables.strokeWeight is present
153
+ // children: [{ layoutPositioning, strokeAlign, strokeVariables, strokeWeightBound }] // direct children only
154
+ // }
155
+ // VARIANT.controlLayer: a FOCUS NODE, or null when EXPECT names a layer the variant does not have.
156
+ // Variable names are resolved by the reader and only for focus variants and their controlLayer.
157
+ //
158
+ // RESULT: { pass, checks: [{ name, result: 'pass' | 'fail' | 'skip', detail }] }
159
+ // `pass` ignores skips. A failed assertion is data; nothing here throws.
160
+
161
+ // The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
162
+ // null `uiFramework` uses the `ring` recipe. Any other value is out of scope.
163
+ // ring - DROP_SHADOW effect with a focus-bound colour and spread > 0, or an
164
+ // ABSOLUTE ring child with an OUTSIDE stroke bound to a focus variable
165
+ // mui - an input: the control's own stroke and its weight are bound; any other
166
+ // component uses the ring recipe
167
+ // outline - the control or a direct child carries an OUTSIDE stroke bound to a
168
+ // focus variable
169
+ const FRAMEWORKS = {
170
+ shadcn: { recipe: 'ring' },
171
+ tailwind: { recipe: 'ring' },
172
+ mui: { recipe: 'mui' },
173
+ 'vanilla-css': { recipe: 'outline' },
174
+ vanilla: { recipe: 'outline' },
175
+ };
176
+
177
+ function frameworkRecipe(uiFramework) {
178
+ if (uiFramework === undefined || uiFramework === null) return 'ring';
179
+ return Object.prototype.hasOwnProperty.call(FRAMEWORKS, uiFramework)
180
+ ? FRAMEWORKS[uiFramework].recipe
181
+ : null;
182
+ }
183
+
184
+ // A focus variable is any variable whose name has a `focus` segment:
185
+ // `border/focus`, `color/border/focus` and `color/focus/ring` count; `focusRing` and
186
+ // a raw colour (null) do not.
187
+ function isFocusVariable(name) {
188
+ return typeof name === 'string' && name.split('/').some((seg) => seg.trim().toLowerCase() === 'focus');
189
+ }
190
+
191
+ // A choice control may carry selection on its own axis instead of as a `selected`
192
+ // value on the state axis: the sample's Checkbox has `checked: false | true |
193
+ // indeterminate`. An axis with one of these names satisfies `selected`.
194
+ const SELECTION_AXES = ['selected', 'checked', 'on'];
195
+
196
+ const hasFocusStroke = (node) => (node.strokeVariables ?? []).some(isFocusVariable);
197
+
198
+ function lacksRing(target, recipe, isInput) {
199
+ const children = target.children ?? [];
200
+ if (recipe === 'mui' && isInput) {
201
+ const lacks = [];
202
+ if (!hasFocusStroke(target)) lacks.push('a stroke bound to a focus variable');
203
+ if (!target.strokeWeightBound) lacks.push('a stroke weight bound to a variable');
204
+ return lacks;
205
+ }
206
+ if (recipe === 'outline') {
207
+ const outside = (n) => n.strokeAlign === 'OUTSIDE' && hasFocusStroke(n);
208
+ return outside(target) || children.some(outside)
209
+ ? []
210
+ : ['an OUTSIDE stroke bound to a focus variable on the control or a direct child'];
211
+ }
212
+ const shadow = (target.effects ?? []).some(
213
+ (e) => e.type === 'DROP_SHADOW' && e.visible !== false && e.spread > 0 && isFocusVariable(e.colorVariable),
214
+ );
215
+ const ringChild = children.some(
216
+ (c) => c.layoutPositioning === 'ABSOLUTE' && c.strokeAlign === 'OUTSIDE' && hasFocusStroke(c),
217
+ );
218
+ return shadow || ringChild
219
+ ? []
220
+ : [
221
+ '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',
222
+ ];
223
+ }
224
+
225
+ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName }) {
226
+ const fold = normalizeName;
227
+ const foldState = (value) => {
228
+ const name = fold(value);
229
+ return STATE_SYNONYMS[name] ?? name;
230
+ };
231
+ const result = (checks) => ({ pass: checks.every((c) => c.result !== 'fail'), checks });
232
+ const label = (props) =>
233
+ Object.entries(props ?? {})
234
+ .map(([k, v]) => `${k}=${v}`)
235
+ .join(', ');
236
+
237
+ // Combinations as comparable strings, axes sorted by folded name.
238
+ const comboKey = (props) =>
239
+ Object.entries(props ?? {})
240
+ .map(([k, v]) => [fold(k), fold(v)])
241
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
242
+ .map(([k, v]) => `${k}=${v}`)
243
+ .join(', ');
244
+
245
+ function crossProduct(matrix) {
246
+ let combos = [{}];
247
+ for (const [axis, values] of Object.entries(matrix ?? {})) {
248
+ combos = combos.flatMap((combo) => values.map((value) => ({ ...combo, [axis]: value })));
249
+ }
250
+ return combos;
251
+ }
252
+
253
+ function structural(snapshot, expect) {
254
+ const name = 'structural-read-back';
255
+ const problems = [];
256
+ if (snapshot.type !== 'COMPONENT_SET') problems.push(`the node is ${snapshot.type}, not a COMPONENT_SET`);
257
+ const children = snapshot.children ?? [];
258
+ const notComponents = children.filter((c) => c.type !== 'COMPONENT');
259
+ if (notComponents.length > 0) {
260
+ problems.push(`${notComponents.length} of ${children.length} children are not COMPONENTs (${[...new Set(notComponents.map((c) => c.type))].join(', ')})`);
261
+ }
262
+
263
+ const builtAxes = Object.keys(snapshot.variantGroupProperties ?? {}).map(fold).sort();
264
+ const wantAxes = Object.keys(expect.matrix ?? {}).map(fold).sort();
265
+ const missingAxes = wantAxes.filter((a) => !builtAxes.includes(a));
266
+ const extraAxes = builtAxes.filter((a) => !wantAxes.includes(a));
267
+ if (missingAxes.length) problems.push(`missing axes: ${missingAxes.join(', ')}`);
268
+ if (extraAxes.length) problems.push(`extra axes: ${extraAxes.join(', ')}`);
269
+
270
+ const want = new Map(crossProduct(expect.matrix).map((combo) => [comboKey(combo), label(combo)]));
271
+ const seen = new Map();
272
+ const duplicates = [];
273
+ for (const child of children) {
274
+ const key = comboKey(child.variantProperties);
275
+ if (seen.has(key)) duplicates.push(label(child.variantProperties));
276
+ else seen.set(key, label(child.variantProperties));
277
+ }
278
+ const missing = [...want].filter(([key]) => !seen.has(key)).map(([, text]) => text);
279
+ const extra = [...seen].filter(([key]) => !want.has(key)).map(([, text]) => text);
280
+ if (missing.length) problems.push(`missing combinations: ${missing.join(' | ')}`);
281
+ if (extra.length) problems.push(`extra combinations: ${extra.join(' | ')}`);
282
+ if (duplicates.length) problems.push(`duplicate combinations: ${duplicates.join(' | ')}`);
283
+
284
+ return problems.length
285
+ ? { name, result: 'fail', detail: problems.join('; ') }
286
+ : { name, result: 'pass', detail: `COMPONENT_SET with ${children.length} COMPONENT variants matching the matrix` };
287
+ }
288
+
289
+ // `entries` are [axisName, value] pairs; only the `state` axis is read.
290
+ function stateBaseline(expect, entries) {
291
+ const name = 'state-baseline';
292
+ const archetype = resolveArchetype({ name: expect.name });
293
+ if (archetype === null) {
294
+ return { name, result: 'skip', detail: `"${expect.name}" is not a name in the state table` };
295
+ }
296
+ const baseline = BASELINE_STATES[archetype];
297
+ if (baseline.length === 0) {
298
+ return { name, result: 'skip', detail: `"${expect.name}" is a ${archetype}, which owes no states beyond default` };
299
+ }
300
+ const present = new Set(entries.filter(([axis]) => fold(axis) === 'state').map(([, value]) => foldState(value)));
301
+ if (entries.some(([axis]) => SELECTION_AXES.includes(fold(axis)))) present.add('selected');
302
+ const required = ['default', ...baseline];
303
+ const missing = required.filter((s) => !present.has(foldState(s)));
304
+ return missing.length
305
+ ? { name, result: 'fail', detail: `the state axis is missing: ${missing.join(', ')}` }
306
+ : { name, result: 'pass', detail: `the state axis has default and every ${archetype} state` };
307
+ }
308
+
309
+ function focusIndicator(snapshot, expect) {
310
+ const name = 'focus-indicator';
311
+ const archetype = resolveArchetype({ name: expect.name });
312
+ if (archetype === null) {
313
+ return { name, result: 'skip', detail: `"${expect.name}" is not a name in the state table` };
314
+ }
315
+ if (BASELINE_STATES[archetype].length === 0) {
316
+ return { name, result: 'skip', detail: `"${expect.name}" is a ${archetype}, which owes no states, so no focus ring is checked` };
317
+ }
318
+ const recipe = frameworkRecipe(expect.uiFramework);
319
+ if (recipe === null) {
320
+ return { name, result: 'skip', detail: `uiFramework "${expect.uiFramework}" is out of scope for the focus check` };
321
+ }
322
+ const isFocusVariant = (child) =>
323
+ child.type === 'COMPONENT' &&
324
+ Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
325
+ const variants = (snapshot.children ?? []).filter(isFocusVariant);
326
+ if (variants.length === 0) {
327
+ 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' };
328
+ }
329
+ const misses = [];
330
+ for (const variant of variants) {
331
+ let target = variant;
332
+ if (expect.controlLayer !== undefined && expect.controlLayer !== null) {
333
+ target = variant.controlLayer;
334
+ if (!target) {
335
+ misses.push(`${label(variant.variantProperties)}: no layer named "${expect.controlLayer}"`);
336
+ continue;
337
+ }
338
+ }
339
+ const lacks = lacksRing(target, recipe, archetype === 'input');
340
+ if (lacks.length) misses.push(`${label(variant.variantProperties)}: lacks ${lacks.join(' and ')}`);
341
+ }
342
+ return misses.length
343
+ ? { name, result: 'fail', detail: misses.join('; ') }
344
+ : { name, result: 'pass', detail: `all ${variants.length} focus variants carry a ${recipe}-recipe focus indicator` };
345
+ }
346
+
347
+ // Before anything is written: the matrix the spec asks for against the state table.
348
+ function checkSpec(expect) {
349
+ const entries = Object.entries(expect.matrix ?? {}).flatMap(([axis, values]) => values.map((v) => [axis, v]));
350
+ return result([stateBaseline(expect, entries)]);
351
+ }
352
+
353
+ // After the build: the node as read.
354
+ function checkBuilt(snapshot, expect) {
355
+ const entries = (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
356
+ return result([structural(snapshot, expect), stateBaseline(expect, entries), focusIndicator(snapshot, expect)]);
357
+ }
358
+
359
+ return { checkSpec, checkBuilt };
360
+ }
361
+
362
+ const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName });
363
+
364
+ // Figma plugin-API reader for the stop-condition checks. This file is NOT a
365
+ // Node module — build-figma-checks.mjs concatenates it after the inlined
366
+ // component-states.mjs and figma-checks.mjs into references/figma-checks.md, and
367
+ // the result runs inside figma_execute (dynamic-page mode). Constraints honored
368
+ // here (see references/figma-scripting.md): async getters only, variable names
369
+ // resolved with getVariableByIdAsync and only for focus variants and their
370
+ // controlLayer. Untested in Node, like the doc-card renderer; the pure checker
371
+ // it feeds is unit-tested.
372
+ //
373
+ // In-scope globals when assembled: normalizeName (inlined
374
+ // component-states.mjs), checkSpec, checkBuilt (bound from withTable) and the
375
+ // caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
376
+ // shape is documented at the top of scripts/lib/figma-checks.mjs.
377
+
378
+ async function variableName(alias) {
379
+ if (!alias || !alias.id) return null;
380
+ const variable = await figma.variables.getVariableByIdAsync(alias.id);
381
+ return variable ? variable.name : null;
382
+ }
383
+
384
+ // The fields the focus check reads from a variant, a control layer or a direct child.
385
+ async function readFocusNode(node, { withEffects }) {
386
+ const strokes = (node.boundVariables && node.boundVariables.strokes) || [];
387
+ const strokeVariables = [];
388
+ for (const alias of strokes) {
389
+ const name = await variableName(alias);
390
+ if (name !== null) strokeVariables.push(name);
391
+ }
392
+ const bound = node.boundVariables || {};
393
+ const out = {
394
+ strokeAlign: node.strokeAlign,
395
+ strokeVariables,
396
+ strokeWeightBound: Boolean(
397
+ bound.strokeWeight || bound.strokeTopWeight || bound.strokeRightWeight || bound.strokeBottomWeight || bound.strokeLeftWeight,
398
+ ),
399
+ };
400
+ if (withEffects) {
401
+ out.effects = [];
402
+ for (const effect of node.effects || []) {
403
+ const colorAlias = effect.boundVariables && effect.boundVariables.color;
404
+ out.effects.push({
405
+ type: effect.type,
406
+ visible: effect.visible,
407
+ spread: effect.spread,
408
+ colorVariable: await variableName(colorAlias),
409
+ });
410
+ }
411
+ } else {
412
+ out.layoutPositioning = node.layoutPositioning;
413
+ }
414
+ return out;
415
+ }
416
+
417
+ async function readFocusTree(node) {
418
+ const focus = await readFocusNode(node, { withEffects: true });
419
+ focus.children = [];
420
+ for (const child of node.children || []) {
421
+ focus.children.push(await readFocusNode(child, { withEffects: false }));
422
+ }
423
+ return focus;
424
+ }
425
+
426
+ // The getter throws when Figma flags the set as conflicting (duplicate variant
427
+ // combinations). Fall back to the axes the children declare, so the structural
428
+ // check names the duplicates instead of the read throwing.
429
+ function groupProperties(set) {
430
+ try {
431
+ return set.variantGroupProperties;
432
+ } catch {
433
+ const axes = {};
434
+ for (const child of set.children) {
435
+ for (const axis of Object.keys(child.variantProperties || {})) axes[axis] = { values: [] };
436
+ }
437
+ return axes;
438
+ }
439
+ }
440
+
441
+ async function readComponentSet(expect) {
442
+ await figma.currentPage.loadAsync();
443
+ const frameName = `WIP: ${expect.name}`;
444
+ const frame = figma.currentPage.findOne((n) => n.name === frameName);
445
+ if (!frame) throw new Error(`no node named "${frameName}" on the current page`);
446
+ const set = frame.type === 'COMPONENT_SET' ? frame : (frame.children || []).find((c) => c.type === 'COMPONENT_SET');
447
+ // A frame holding plain FRAMEs where the set should be is a failed assertion,
448
+ // not a missing target: report what is there so the structural check names it.
449
+ if (!set) {
450
+ const first = (frame.children || [])[0];
451
+ return { type: first ? first.type : 'NONE', variantGroupProperties: {}, children: [] };
452
+ }
453
+
454
+ const isFocusState = (props) =>
455
+ Object.entries(props || {}).some(([k, v]) => {
456
+ const state = normalizeName(v);
457
+ return normalizeName(k) === 'state' && state === 'focus';
458
+ });
459
+
460
+ const children = [];
461
+ for (const child of set.children) {
462
+ const read = { type: child.type, variantProperties: child.variantProperties };
463
+ if (child.type === 'COMPONENT' && isFocusState(child.variantProperties)) {
464
+ Object.assign(read, await readFocusTree(child));
465
+ if (expect.controlLayer !== undefined && expect.controlLayer !== null) {
466
+ const layer = child.findOne((n) => n.name === expect.controlLayer);
467
+ read.controlLayer = layer ? await readFocusTree(layer) : null;
468
+ }
469
+ }
470
+ children.push(read);
471
+ }
472
+ return {
473
+ type: set.type,
474
+ variantGroupProperties: groupProperties(set),
475
+ children,
476
+ };
477
+ }
478
+
479
+ async function runFigmaChecks(EXPECT, MODE) {
480
+ if (MODE === 'spec') return checkSpec(EXPECT);
481
+ if (MODE === 'built') return checkBuilt(await readComponentSet(EXPECT), EXPECT);
482
+ throw new Error(`MODE must be 'spec' or 'built', got ${JSON.stringify(MODE)}`);
483
+ }
484
+ ```
@@ -123,19 +123,17 @@ run, and it mirrors how the states/types map to code props.
123
123
 
124
124
  - **Always include every relevant state for the component — completeness is the
125
125
  default, not a judgment call.** A component's `state` axis must enumerate its full
126
- applicable interaction surface, not just `default`. The baseline interactive set
127
- is **default, hover, focus, active (pressed), disabled**; add the **conditional**
128
- states whenever they apply to that component: **loading** (anything that triggers
129
- async work — buttons, submit inputs), **selected** (toggles, segmented controls,
130
- list/menu items, chips), and **success / error** (validated inputs, form fields,
131
- async-result buttons). Decide *which* conditional states apply, but never drop a
132
- state that does apply to keep the matrix small.
133
- - **Button:** default, hover, focus, active (pressed), disabled — plus loading
134
- (and selected / success / error where the button supports them).
135
- - **Input / text field:** default, hover, focus, disabled — plus error, success,
136
- and (where async) loading.
137
- - **Checkbox / radio / toggle / chip:** default, hover, focus, active, disabled —
138
- plus selected (and indeterminate where it applies).
126
+ applicable interaction surface, not just `default`. The states a component owes
127
+ come from the table in `references/state-baseline.md` (see below); add the
128
+ **conditional** states whenever they apply to that component: **loading** (anything
129
+ that triggers async work — buttons, submit inputs), **selected** for components the
130
+ table does not cover (segmented controls, list/menu items), and **success / error**
131
+ (validated inputs, form fields, async-result buttons). Decide *which* conditional
132
+ states apply, but never drop a state that does apply to keep the matrix small.
133
+ - **Which components owe which states is one table**, shared with the CLI's
134
+ `state-incomplete` check: `references/state-baseline.md`, generated from
135
+ `scripts/lib/component-states.mjs`. Add loading, success / error, and
136
+ indeterminate where they apply; those are not in the table.
139
137
  - Keep these on a `state` variant axis where they change appearance meaningfully;
140
138
  each becomes a column in the set per "Component set arrangement".
141
139
  - Distinguish *component states* (part of the component's definition) from
@@ -557,9 +555,11 @@ For each generated artboard / doc card / icon grid, read the nodes back (via
557
555
  a header container whose surface fill differs from the component area (both
558
556
  token-bound). A card with no header/component segmentation is a fail.
559
557
  8. **States complete** — each component set's `state` axis includes every relevant
560
- state for that component (default/hover/focus/active/disabled plus the applicable
561
- conditional states — loading/selected/success/error), with variants (incl. each
562
- size) as rows and states as columns. A set shipping only `default` is a fail.
558
+ state for that component (every state the table in `references/state-baseline.md`
559
+ owes it, plus default, plus the applicable loading/success/error), with variants (incl. each
560
+ size) as rows and states as columns. A set shipping only `default` is a fail. The
561
+ programmatic half (the state matrix and the `COMPONENT_SET` structure) is the
562
+ script in `references/figma-checks.md`; the script is the rule.
563
563
  9. **Focus state matches the library idiom** — the focus state is built as
564
564
  `project.uiFramework`'s real pattern (see "State handling"), not a house-style
565
565
  stroke, and with no padded wrapper inflating the component. Shadow-based recipes: a
@@ -572,49 +572,17 @@ For each generated artboard / doc card / icon grid, read the nodes back (via
572
572
  wrapper, an inside-aligned stroke, or a transparent variant with no ring at all must
573
573
  be retrofitted to the current recipe (see "State handling").
574
574
 
575
- **The `focus-indicator` check** is the programmatic half of this item. Run it
576
- with `figma_execute`. What a miss does depends on who designed the ring:
575
+ **The `focus-indicator` check** is the programmatic half of this item, run by
576
+ the script in `references/figma-checks.md`; the script is the rule for which
577
+ frameworks and components it covers, how it finds the focus variants and the
578
+ control, what counts as a focus variable, and what each recipe must carry. What
579
+ a miss does depends on who designed the ring:
577
580
  - **`figma-executor` returns `BLOCKED`** on a miss, naming the variant. It
578
581
  builds from an architect spec, and adding a ring the spec didn't give is a
579
582
  design decision it doesn't make.
580
583
  - **Building inline**, you designed the ring yourself, so a miss is a build
581
584
  error: fix and re-audit like any other item. If the ring is still missing
582
585
  when the pass budget below runs out, **stop the build** rather than hand off.
583
- - **Scope.** It runs only for a component whose name folds (lowercase,
584
- punctuation stripped) to a Button, an Input / text field, or a Checkbox /
585
- radio / toggle / chip, the same exact-name match as the state check. A Switch
586
- or a TextInput isn't in that list, so neither Figma check reads it (the CLI's
587
- `state-incomplete` does). It also
588
- needs `project.uiFramework` to be exactly `shadcn`, `tailwind`, `mui`,
589
- `vanilla-css` or `vanilla`, or absent or `null`. Anything else (`ios-swift`,
590
- a tier-2 library, any other value) is skipped, and the skip is said.
591
- - **What it reads.** Every child `COMPONENT` of the set whose
592
- `variantProperties` has a key folding to `state` with a value folding to
593
- exactly `focus`. `Focused` or `focus-visible` isn't read. The ring is looked
594
- for on that variant, or, where the variant wraps the control in other content
595
- (an Input with a label or helper text), on the child frame that is the
596
- control, the node the recipe above puts the ring on. Reading **zero** focus
597
- variants on an in-scope component is not a pass: it means the `state` axis
598
- has no `focus` value, which the state check stops on.
599
- - **A focus variable** is any variable whose name has a `focus` segment:
600
- `border/focus`, `color/border/focus`, `color/focus/ring` and
601
- `color/focus/ringOnFill` all count. Systems name their ring role differently,
602
- and a collection prefix is common. A raw colour, or a variable with no `focus`
603
- segment, doesn't count.
604
- - **Ring recipes** (`shadcn`, `tailwind`, absent or `null`; `mui` on anything
605
- but an input). The control has either a `DROP_SHADOW` effect with
606
- `visible !== false`, `spread > 0` and `boundVariables.color` on a focus
607
- variable, or a direct child with `layoutPositioning = "ABSOLUTE"` and
608
- `strokeAlign = "OUTSIDE"` whose stroke is bound to a focus variable.
609
- `spread > 0` matters: binding an effect's colour can silently zero its spread
610
- (see `figma-scripting.md`), which leaves a ring that is bound and invisible.
611
- Only the ring is asserted. The recipe's border recolour is styling, and a
612
- control whose border keeps its normal colour beside a correct ring passes.
613
- - **`mui` inputs.** The control's own stroke is bound to a focus variable, and its
614
- weight to a variable (the recipe's `width/focus`).
615
- - **`vanilla-css` / `vanilla`.** The control or a direct child carries a stroke
616
- bound to a focus variable with `strokeAlign = "OUTSIDE"`. The `offset/focus` gap
617
- isn't asserted.
618
586
  - **The result.** On a miss, stop and name the variant and what it lacks. On a
619
587
  pass, `component-builder` records `focus-indicator` as an attested check. A
620
588
  skip records nothing.
@@ -68,7 +68,8 @@ wrong document.
68
68
  Run this before the first write, and again right before any replace — the
69
69
  build-verify-then-replace finalize deletes the existing node, so it is the write
70
70
  that destroys work in the wrong file. The `figma-executor` agent runs it as step 1
71
- of its contract. **When a skill builds inline instead** (a host with no subagent
71
+ of its contract; the `architect` and `reviewer` agents, which read Figma but cannot
72
+ navigate, run the compare and return `BLOCKED` on a mismatch. **When a skill builds inline instead** (a host with no subagent
72
73
  dispatch, or a write-back the skill does itself), nothing runs it for you: the
73
74
  skill has to.
74
75
 
@@ -329,12 +330,12 @@ uses **build-verify-then-replace**:
329
330
  2. **Verify the working frame green before touching anything real** — a
330
331
  screenshot alone is **insufficient**: 10 tone-colored frames render
331
332
  identically to 10 real variants, so a fast model can build plain frames and
332
- self-report success. The gate is a **programmatic read-back** (`figma_execute`)
333
- that asserts `node.type === 'COMPONENT_SET'` (never `'FRAME'`), the child count
334
- matches the variant matrix with every child a `'COMPONENT'`,
335
- `variantGroupProperties` names the expected axes, and a spot-check of ≥2
336
- variants shows fills/strokes/radius bound to variables (not raw values) with
337
- the expected `clipsContent`. A `figma_capture_screenshot` is a **secondary**
333
+ self-report success. The gate is a **programmatic read-back** (`figma_execute`):
334
+ for a component set, run the checks in `references/figma-checks.md` (the script
335
+ is the rule for the `COMPONENT_SET` structure, the state matrix and the focus
336
+ ring), plus a spot-check of ≥2 variants showing fills/strokes/radius bound to
337
+ variables (not raw values) with the expected `clipsContent`. A
338
+ `figma_capture_screenshot` is a **secondary**
338
339
  check (create → read-back → screenshot → iterate, max ~3), never the sole one.
339
340
  3. **Only then finalize:** remove/replace any existing same-named component,
340
341
  rename the working frame's component set to the real `<ComponentName>`, **and
@@ -0,0 +1,27 @@
1
+ <!-- GENERATED by scripts/build-state-baseline.mjs from scripts/lib/component-states.mjs. Do not edit; run: node scripts/build-state-baseline.mjs -->
2
+ # State baseline
3
+
4
+ Which interaction states a component owes. This is the one list both checks read:
5
+ `verify:check` (`state-incomplete`) reads it from `scripts/lib/component-states.mjs`,
6
+ and the Figma checks script (`references/figma-checks.md`) inlines the same module,
7
+ so this table is the human-readable view of both. CI fails when it is stale.
8
+
9
+ A component name matches a row when it folds (lowercase, punctuation and spaces
10
+ stripped) to exactly one of that row's names. `Text Field` folds to `textfield`.
11
+ Matching is exact on purpose: `IconButton` matches nothing, and a component that
12
+ matches nothing owes no states.
13
+
14
+ | Archetype | Names | States owed |
15
+ |---|---|---|
16
+ | button | `button` | `hover`, `focus`, `active`, `disabled` |
17
+ | input | `input`, `textfield`, `textinput` | `hover`, `focus`, `disabled` |
18
+ | choice | `checkbox`, `radio`, `toggle`, `switch`, `chip` | `hover`, `focus`, `active`, `selected`, `disabled` |
19
+
20
+ Every state in the table must be a value on the component's `state` axis, with one
21
+ exception in Figma: a choice control may carry `selected` on its own axis named
22
+ `selected`, `checked` or `on`. On the
23
+ Figma side the matrix also carries `default` as the resting state; the CLI does not
24
+ count it. `checked` counts as `selected`, and `on` counts as `selected`, since that is what shadcn and Radix call it. Conditional states (`loading`, `error`, `success`) are not in the table:
25
+ whether one applies is a design judgment.
26
+
27
+ Names that fold to `card`, `modal`, `dialog`, `badge`, `tag` owe none, so neither check asserts states on them.