@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.
- package/README.md +5 -5
- package/adapters/codex/prompts/component-builder.md +12 -7
- package/adapters/codex/prompts/token-sync-layer.md +4 -1
- package/adapters/cursor/.cursor/rules/component-builder.mdc +12 -7
- package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +4 -1
- package/adapters/generic/skills/component-builder/SKILL.md +12 -7
- package/adapters/generic/skills/token-sync-layer/SKILL.md +4 -1
- package/package.json +1 -1
- package/references/figma-checks.md +484 -0
- package/references/figma-component-standards.md +21 -53
- package/references/figma-scripting.md +8 -7
- package/references/state-baseline.md +27 -0
- package/scripts/README.md +5 -1
- package/scripts/build-figma-checks.mjs +98 -0
- package/scripts/build-state-baseline.mjs +71 -0
- package/scripts/install.mjs +9 -4
- package/scripts/lib/component-states.mjs +28 -7
- package/scripts/lib/figma-checks-read.figma.js +120 -0
- package/scripts/lib/figma-checks.mjs +240 -0
- package/scripts/lib/fixtures/figma-checks/avatar-not-in-table.json +43 -0
- package/scripts/lib/fixtures/figma-checks/button-ios-swift-skipped.json +85 -0
- package/scripts/lib/fixtures/figma-checks/button-missing-default.json +71 -0
- package/scripts/lib/fixtures/figma-checks/button-mui-ring.json +92 -0
- package/scripts/lib/fixtures/figma-checks/button-no-focus-variant.json +71 -0
- package/scripts/lib/fixtures/figma-checks/button-plain-frames.json +92 -0
- package/scripts/lib/fixtures/figma-checks/button-ring-child-not-absolute.json +94 -0
- package/scripts/lib/fixtures/figma-checks/button-ring-hidden.json +92 -0
- package/scripts/lib/fixtures/figma-checks/button-ring-unbound-colour.json +98 -0
- package/scripts/lib/fixtures/figma-checks/button-shadcn-filled.json +92 -0
- package/scripts/lib/fixtures/figma-checks/button-sparse.json +134 -0
- package/scripts/lib/fixtures/figma-checks/button-spread-zero.json +92 -0
- package/scripts/lib/fixtures/figma-checks/button-transparent-ring-child.json +94 -0
- package/scripts/lib/fixtures/figma-checks/button-vanilla-inside.json +87 -0
- package/scripts/lib/fixtures/figma-checks/button-vanilla-outside.json +87 -0
- package/scripts/lib/fixtures/figma-checks/button-wrong-combination.json +92 -0
- package/scripts/lib/fixtures/figma-checks/checkbox-checked-axis.real.json +245 -0
- package/scripts/lib/fixtures/figma-checks/checkbox-checked-synonym.json +87 -0
- package/scripts/lib/fixtures/figma-checks/checkbox-missing-selected.json +79 -0
- package/scripts/lib/fixtures/figma-checks/input-control-layer-missing.json +66 -0
- package/scripts/lib/fixtures/figma-checks/input-control-layer-ring-on-wrapper.json +79 -0
- package/scripts/lib/fixtures/figma-checks/input-control-layer.json +79 -0
- package/scripts/lib/fixtures/figma-checks/input-mui-weight-unbound.json +66 -0
- package/scripts/lib/fixtures/figma-checks/input-mui.json +66 -0
- package/scripts/lib/fixtures/figma-checks/sample-button.real.json +1497 -0
package/scripts/README.md
CHANGED
|
@@ -23,12 +23,16 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
23
23
|
| `docs-lint.mjs` | Copy lint for .doc.json records — warnings only, always exits 0 on a parseable record; the mechanical subset of `references/doc-writing-standard.md`. | `docs:lint` |
|
|
24
24
|
| `lib/doc-record.mjs` | Canonical record load + `canonicalFingerprint` (sha256 over the record minus `provenance`). The fingerprint every surface is stamped with. | copied alongside docs-check.mjs |
|
|
25
25
|
| `lib/doc-card-render.figma.js` | Figma renderer template for the doc card's `Usage` band and header. Inlined into `references/doc-card-builder.md`; never executed as a module. | plugin-internal (not installed) |
|
|
26
|
+
| `lib/figma-checks.mjs` | Pure checker for the Figma stop conditions: the COMPONENT_SET assertion against the full matrix, the state matrix, and the focus ring, plus the focus-framework rules. Inlined into `references/figma-checks.md`; unit-tested against `lib/fixtures/figma-checks/`. | plugin-internal (not installed) |
|
|
27
|
+
| `lib/figma-checks-read.figma.js` | Figma reader that turns the live COMPONENT_SET into the checker's snapshot JSON. Inlined into `references/figma-checks.md`; never executed as a module. | plugin-internal (not installed) |
|
|
26
28
|
| `lib/doc-card-plan.mjs` | Pure layout planner for the doc card's `Usage` band + `DOC_CARD_RENDERER_VERSION` (single source of the layout version). Inlined into `references/doc-card-builder.md`; imported by `docs-check.mjs`. | copied alongside docs-check.mjs; also inlined into the generated builder |
|
|
27
29
|
| `verify-check.mjs` | The verification proof bundle: records each stage's entry, and re-derives what it can rather than trusting the record. Fails on an orphaned token, a component documented without its archetype's baseline interaction states, a name that drifts between the manifest, its record and its code surface, or a recorded result the rerun contradicts. | `verify:check` |
|
|
28
30
|
| `lib/proof.mjs` | The proof store's vocabulary and mechanics: the stage list, entry validation, latest-wins merge, and the fingerprint the manifest pointer carries. | copied alongside `verify-check.mjs` |
|
|
29
31
|
| `lib/component-states.mjs` | The archetype table and resolver behind `state-incomplete` — which baseline interaction states each kind of component owes, and when to abstain. `validate-adherence.mjs` also uses it to find Badges. | copied alongside `verify-check.mjs` and `validate-adherence.mjs` |
|
|
30
32
|
| `lib/contrast.mjs` | The WCAG contrast maths and the semantic colour pairs behind `color-contrast` — which text role must clear which surface, in every mode, and where the rule deliberately abstains. | copied alongside `verify-check.mjs` |
|
|
31
33
|
| `build-doc-card-builder.mjs` | Generate `references/doc-card-builder.md` from the planner + the Figma renderer template (`lib/doc-card-render.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
|
|
34
|
+
| `build-figma-checks.mjs` | Generate `references/figma-checks.md`, the `figma_execute` snippet for the Figma stop conditions, by inlining `lib/component-states.mjs` and `lib/figma-checks.mjs` above the reader (`lib/figma-checks-read.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
|
|
35
|
+
| `build-state-baseline.mjs` | Generate `references/state-baseline.md`, the table of which components owe which interaction states, from `lib/component-states.mjs`. The Figma executor reads the table; `verify:check` reads the module. `--check` gates CI. | plugin-internal (not installed) |
|
|
32
36
|
| `build-native-adapter-config.mjs` | Generate `references/native-adapter-config.md` by slicing `lib/sd-native.mjs` on its `@doc-section` markers and interleaving each fragment under its prose. Fails when module code falls outside every section, so the doc cannot silently ship incomplete. `--check` gates CI. | plugin-internal (not installed) |
|
|
33
37
|
|
|
34
38
|
**Documentation scripts — install as a set.** Copying these files without
|
|
@@ -245,7 +249,7 @@ the committed `adapters/<target>/` tree plus the runtime payload
|
|
|
245
249
|
(`references/` + `scripts/`, minus `scripts/adapters/`) into `.throughline/`,
|
|
246
250
|
rewriting `${CLAUDE_PLUGIN_ROOT}` → `.throughline`:
|
|
247
251
|
|
|
248
|
-
npx @radicool/throughline@0.
|
|
252
|
+
npx @radicool/throughline@0.23.0 init --target=cursor|codex|generic
|
|
249
253
|
|
|
250
254
|
See `scripts/install.mjs` (pure core + CLI + `install.test.mjs`).
|
|
251
255
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Generates references/figma-checks.md — the canonical figma_execute snippet
|
|
2
|
+
// that runs the Figma stop conditions (COMPONENT_SET assertion, state matrix,
|
|
3
|
+
// focus ring) — by inlining the state table (lib/component-states.mjs) and the
|
|
4
|
+
// pure checker (lib/figma-checks.mjs) above the Figma reader
|
|
5
|
+
// (lib/figma-checks-read.figma.js). Mirrors build-doc-card-builder.mjs: run
|
|
6
|
+
// bare to write, run with --check to gate CI. Zero dependencies.
|
|
7
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const STATES = join(REPO_ROOT, 'scripts', 'lib', 'component-states.mjs');
|
|
13
|
+
const CHECKER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks.mjs');
|
|
14
|
+
const READER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks-read.figma.js');
|
|
15
|
+
const OUT = join(REPO_ROOT, 'references', 'figma-checks.md');
|
|
16
|
+
|
|
17
|
+
const BIND = 'const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName });';
|
|
18
|
+
|
|
19
|
+
const HEADER = [
|
|
20
|
+
'# Figma stop-condition checks (GENERATED)',
|
|
21
|
+
'',
|
|
22
|
+
'> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/component-states.mjs`',
|
|
23
|
+
'> (the state table), `scripts/lib/figma-checks.mjs` (the pure checker, unit-tested in',
|
|
24
|
+
'> Node) + `scripts/lib/figma-checks-read.figma.js` (the Figma reader). Regenerate with',
|
|
25
|
+
'> `node scripts/build-figma-checks.mjs`; CI gates freshness with `--check`.',
|
|
26
|
+
'',
|
|
27
|
+
'The canonical `figma_execute` snippet for the checks that can stop a component',
|
|
28
|
+
'build: the COMPONENT_SET assertion against the full variant matrix, the state',
|
|
29
|
+
'matrix, and the focus ring. The script is the rule; do not re-write these checks',
|
|
30
|
+
'by hand.',
|
|
31
|
+
'',
|
|
32
|
+
'## How to call it',
|
|
33
|
+
'',
|
|
34
|
+
'1. Paste the snippet below into `figma_execute`, with the two slots filled above it:',
|
|
35
|
+
' - `const EXPECT = { name, matrix, uiFramework, controlLayer }` — `name` is the',
|
|
36
|
+
' component\'s real name; `matrix` is `{ axis: [values] }` for every axis of the',
|
|
37
|
+
' set; `uiFramework` is `project.uiFramework` from `design-system.json`;',
|
|
38
|
+
' `controlLayer` is the layer that is the actual control in a wrapped control,',
|
|
39
|
+
' copied from the architect\'s spec when it gives one (omit it otherwise).',
|
|
40
|
+
' - `const MODE = \'spec\'` or `\'built\'`.',
|
|
41
|
+
'2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass',
|
|
42
|
+
' `timeout: 30000`.',
|
|
43
|
+
'3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is',
|
|
44
|
+
' `pass`, `fail` or `skip`; `pass` ignores skips. On `fail`, fix what each failing',
|
|
45
|
+
' check\'s `detail` names and run it again, within the ~3-pass budget; after that,',
|
|
46
|
+
' `BLOCKED` naming the failing checks\' `detail`.',
|
|
47
|
+
'4. `\'spec\'` reads no node and runs before the first write. `\'built\'` reads the',
|
|
48
|
+
' COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs',
|
|
49
|
+
' in the self-verify loop. It throws only if that frame is missing; a frame with',
|
|
50
|
+
' no set, or a set Figma reports as conflicting, fails the structural check.',
|
|
51
|
+
'',
|
|
52
|
+
'```js',
|
|
53
|
+
'const EXPECT = { name: \'Chip\', matrix: { state: [\'default\', \'hover\'] }, uiFramework: \'shadcn\' };',
|
|
54
|
+
'const MODE = \'spec\';',
|
|
55
|
+
'// … the generated snippet …',
|
|
56
|
+
'return await runFigmaChecks(EXPECT, MODE);',
|
|
57
|
+
'```',
|
|
58
|
+
'',
|
|
59
|
+
'## The snippet',
|
|
60
|
+
'',
|
|
61
|
+
'```js',
|
|
62
|
+
].join('\n');
|
|
63
|
+
|
|
64
|
+
const FOOTER = ['```', ''].join('\n');
|
|
65
|
+
|
|
66
|
+
const stripExports = (src) =>
|
|
67
|
+
src.replace(/^export const /gm, 'const ').replace(/^export function /gm, 'function ');
|
|
68
|
+
|
|
69
|
+
export function buildFigmaChecks({ statesSource, checkerSource, readerSource }) {
|
|
70
|
+
const states = stripExports(statesSource);
|
|
71
|
+
const checker = stripExports(checkerSource);
|
|
72
|
+
for (const [name, src] of [['component-states.mjs', states], ['figma-checks.mjs', checker], ['figma-checks-read.figma.js', readerSource]]) {
|
|
73
|
+
if (/^\s*(import|export)\b/m.test(src)) {
|
|
74
|
+
throw new Error(`${name} must stay import-free (only top-level \`export const\`/\`export function\` allowed) — it is inlined into the Figma snippet where no module system exists`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return `${HEADER}\n${states}\n${checker}\n${BIND}\n\n${readerSource}${FOOTER}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
81
|
+
const result = buildFigmaChecks({
|
|
82
|
+
statesSource: readFileSync(STATES, 'utf8'),
|
|
83
|
+
checkerSource: readFileSync(CHECKER, 'utf8'),
|
|
84
|
+
readerSource: readFileSync(READER, 'utf8'),
|
|
85
|
+
});
|
|
86
|
+
if (process.argv.includes('--check')) {
|
|
87
|
+
let onDisk = null;
|
|
88
|
+
try { onDisk = readFileSync(OUT, 'utf8'); } catch { /* missing counts as drift */ }
|
|
89
|
+
if (onDisk !== result) {
|
|
90
|
+
console.error('✗ references/figma-checks.md out of date; run: node scripts/build-figma-checks.mjs');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
console.log('✓ figma checks in sync');
|
|
94
|
+
} else {
|
|
95
|
+
writeFileSync(OUT, result);
|
|
96
|
+
console.log('✓ wrote references/figma-checks.md');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Generates references/state-baseline.md from scripts/lib/component-states.mjs.
|
|
2
|
+
// The table is the one list of which components owe which interaction states:
|
|
3
|
+
// `verify:check` reads the module, and `figma-executor` (which cannot import
|
|
4
|
+
// JavaScript) reads this rendering. Mirrors build-native-adapter-config.mjs:
|
|
5
|
+
// run bare to write, --check to gate CI. Zero dependencies.
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { ARCHETYPES, BASELINE_STATES, NAME_SYNONYMS, STATE_SYNONYMS } from './lib/component-states.mjs';
|
|
10
|
+
|
|
11
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
export const OUT = join(REPO_ROOT, 'references', 'state-baseline.md');
|
|
13
|
+
|
|
14
|
+
export function render(states = BASELINE_STATES, synonyms = NAME_SYNONYMS, archetypes = ARCHETYPES, stateSynonyms = STATE_SYNONYMS) {
|
|
15
|
+
const aliases = Object.keys(stateSynonyms).map((s) => `\`${s}\` counts as \`${stateSynonyms[s]}\``).join(', and ');
|
|
16
|
+
const owing = archetypes.filter((a) => (states[a] ?? []).length > 0);
|
|
17
|
+
const rows = owing.map((a) => {
|
|
18
|
+
const names = Object.keys(synonyms).filter((n) => synonyms[n] === a);
|
|
19
|
+
return `| ${a} | ${names.map((n) => `\`${n}\``).join(', ')} | ${states[a].map((s) => `\`${s}\``).join(', ')} |`;
|
|
20
|
+
});
|
|
21
|
+
const exempt = archetypes.filter((a) => !owing.includes(a) && a !== 'other');
|
|
22
|
+
const exemptNames = exempt.flatMap((a) => Object.keys(synonyms).filter((n) => synonyms[n] === a));
|
|
23
|
+
return `<!-- GENERATED by scripts/build-state-baseline.mjs from scripts/lib/component-states.mjs. Do not edit; run: node scripts/build-state-baseline.mjs -->
|
|
24
|
+
# State baseline
|
|
25
|
+
|
|
26
|
+
Which interaction states a component owes. This is the one list both checks read:
|
|
27
|
+
\`verify:check\` (\`state-incomplete\`) reads it from \`scripts/lib/component-states.mjs\`,
|
|
28
|
+
and the Figma checks script (\`references/figma-checks.md\`) inlines the same module,
|
|
29
|
+
so this table is the human-readable view of both. CI fails when it is stale.
|
|
30
|
+
|
|
31
|
+
A component name matches a row when it folds (lowercase, punctuation and spaces
|
|
32
|
+
stripped) to exactly one of that row's names. \`Text Field\` folds to \`textfield\`.
|
|
33
|
+
Matching is exact on purpose: \`IconButton\` matches nothing, and a component that
|
|
34
|
+
matches nothing owes no states.
|
|
35
|
+
|
|
36
|
+
| Archetype | Names | States owed |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
${rows.join('\n')}
|
|
39
|
+
|
|
40
|
+
Every state in the table must be a value on the component's \`state\` axis, with one
|
|
41
|
+
exception in Figma: a choice control may carry \`selected\` on its own axis named
|
|
42
|
+
\`selected\`, \`checked\` or \`on\`. On the
|
|
43
|
+
Figma side the matrix also carries \`default\` as the resting state; the CLI does not
|
|
44
|
+
count it. ${aliases}, since that is what shadcn and Radix call it. Conditional states (\`loading\`, \`error\`, \`success\`) are not in the table:
|
|
45
|
+
whether one applies is a design judgment.
|
|
46
|
+
|
|
47
|
+
Names that fold to ${exemptNames.map((n) => `\`${n}\``).join(', ')} owe none, so neither check asserts states on them.
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function main() {
|
|
52
|
+
const check = process.argv.includes('--check');
|
|
53
|
+
const rendered = render();
|
|
54
|
+
if (!check) {
|
|
55
|
+
writeFileSync(OUT, rendered);
|
|
56
|
+
console.log(`wrote ${OUT}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const current = readFileSync(OUT, 'utf8');
|
|
60
|
+
if (current === rendered) {
|
|
61
|
+
console.log('references/state-baseline.md is up to date');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
console.error(
|
|
65
|
+
'references/state-baseline.md is stale.\n' +
|
|
66
|
+
'Run: node scripts/build-state-baseline.mjs',
|
|
67
|
+
);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
package/scripts/install.mjs
CHANGED
|
@@ -99,18 +99,23 @@ function stagePayload(srcRoot, destRoot, skip) {
|
|
|
99
99
|
return written;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// Scripts that never run from a consuming repo: the installer itself, the
|
|
103
|
-
// reference-doc generators, and the
|
|
104
|
-
// renderer
|
|
102
|
+
// Scripts that never run from a consuming repo: the installer itself, the
|
|
103
|
+
// reference-doc generators, and the Figma sources they inline (the doc-card
|
|
104
|
+
// renderer and the Figma checks reach Figma pre-inlined inside
|
|
105
|
+
// references/doc-card-builder.md and references/figma-checks.md). Test fixtures
|
|
106
|
+
// under lib/fixtures/ are dropped too.
|
|
105
107
|
const PLUGIN_INTERNAL = new Set([
|
|
106
108
|
'install.mjs',
|
|
107
109
|
'build-doc-card-builder.mjs',
|
|
108
110
|
'build-native-adapter-config.mjs',
|
|
109
111
|
'lib/doc-card-render.figma.js',
|
|
112
|
+
'build-figma-checks.mjs',
|
|
113
|
+
'lib/figma-checks-read.figma.js',
|
|
114
|
+
'lib/figma-checks.mjs',
|
|
110
115
|
]);
|
|
111
116
|
|
|
112
117
|
export const skipScript = (relPosix) =>
|
|
113
|
-
relPosix.startsWith('adapters/') || relPosix.endsWith('.test.mjs') || PLUGIN_INTERNAL.has(relPosix);
|
|
118
|
+
relPosix.startsWith('adapters/') || relPosix.startsWith('lib/fixtures/') || relPosix.endsWith('.test.mjs') || PLUGIN_INTERNAL.has(relPosix);
|
|
114
119
|
|
|
115
120
|
export function install({ target, dir, pkgRoot = PKG_ROOT }) {
|
|
116
121
|
if (!TARGETS.includes(target)) {
|
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
// The archetype baseline for `state-incomplete` (`docs/specs/2026-09-12-verification-proof-bundle.md`,
|
|
2
|
-
// step 3).
|
|
2
|
+
// step 3). The prose in `references/figma-component-standards.md` (State handling) points at
|
|
3
|
+
// `references/state-baseline.md`, which is generated from this file.
|
|
3
4
|
//
|
|
4
5
|
// `default` is deliberately excluded from every baseline below: documenting the
|
|
5
6
|
// resting state is not documentation, and the Figma matrix requirement for it is
|
|
6
7
|
// the executor's, not this table's. Conditional states (`loading`, `error`,
|
|
7
|
-
// `success
|
|
8
|
-
//
|
|
8
|
+
// `success`) are deliberately excluded too — whether one applies to a given
|
|
9
|
+
// component is a design judgment, not something a baseline can assert. `selected`
|
|
10
|
+
// is not conditional for a choice control: a checkbox, radio, toggle, switch or
|
|
11
|
+
// chip that cannot be selected is not one.
|
|
9
12
|
//
|
|
10
|
-
//
|
|
13
|
+
// This table is the single source for both checks. `scripts/build-state-baseline.mjs`
|
|
14
|
+
// renders it into `references/state-baseline.md`, which the Figma executor reads;
|
|
15
|
+
// CI fails when that file is stale. MAINTENANCE: change this table, then run it.
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
// Defined here rather than imported from source-scan.mjs so this module has no
|
|
18
|
+
// imports and can be inlined into the Figma checks script. A test keeps the two
|
|
19
|
+
// copies in step.
|
|
20
|
+
const normalizeName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
13
21
|
|
|
14
22
|
export const ARCHETYPES = ['button', 'input', 'choice', 'card', 'modal', 'badge', 'other'];
|
|
15
23
|
|
|
16
24
|
export const BASELINE_STATES = {
|
|
17
25
|
button: ['hover', 'focus', 'active', 'disabled'],
|
|
18
26
|
input: ['hover', 'focus', 'disabled'],
|
|
19
|
-
choice: ['hover', 'focus', 'active', 'disabled'],
|
|
27
|
+
choice: ['hover', 'focus', 'active', 'selected', 'disabled'],
|
|
20
28
|
card: [],
|
|
21
29
|
modal: [],
|
|
22
30
|
badge: [],
|
|
@@ -40,6 +48,14 @@ export const NAME_SYNONYMS = {
|
|
|
40
48
|
tag: 'badge',
|
|
41
49
|
};
|
|
42
50
|
|
|
51
|
+
// Other names a recorded state goes by. shadcn and Radix call a checked checkbox
|
|
52
|
+
// `checked` and a pressed-in toggle `on`; both are `selected` here. `pressed` is
|
|
53
|
+
// not one: the standards use it for `active`.
|
|
54
|
+
export const STATE_SYNONYMS = {
|
|
55
|
+
checked: 'selected',
|
|
56
|
+
on: 'selected',
|
|
57
|
+
};
|
|
58
|
+
|
|
43
59
|
export function resolveArchetype(record) {
|
|
44
60
|
if (ARCHETYPES.includes(record.archetype)) return record.archetype;
|
|
45
61
|
const bySynonym = NAME_SYNONYMS[normalizeName(record.name)];
|
|
@@ -51,6 +67,11 @@ export function missingStates(record) {
|
|
|
51
67
|
if (archetype === null) return [];
|
|
52
68
|
const baseline = BASELINE_STATES[archetype];
|
|
53
69
|
if (baseline.length === 0) return [];
|
|
54
|
-
const recordedKeys = new Set(
|
|
70
|
+
const recordedKeys = new Set(
|
|
71
|
+
Object.keys(record.states ?? {}).map((key) => {
|
|
72
|
+
const name = normalizeName(key);
|
|
73
|
+
return STATE_SYNONYMS[name] ?? name;
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
55
76
|
return baseline.filter((state) => !recordedKeys.has(normalizeName(state)));
|
|
56
77
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Figma plugin-API reader for the stop-condition checks. This file is NOT a
|
|
2
|
+
// Node module — build-figma-checks.mjs concatenates it after the inlined
|
|
3
|
+
// component-states.mjs and figma-checks.mjs into references/figma-checks.md, and
|
|
4
|
+
// the result runs inside figma_execute (dynamic-page mode). Constraints honored
|
|
5
|
+
// here (see references/figma-scripting.md): async getters only, variable names
|
|
6
|
+
// resolved with getVariableByIdAsync and only for focus variants and their
|
|
7
|
+
// controlLayer. Untested in Node, like the doc-card renderer; the pure checker
|
|
8
|
+
// it feeds is unit-tested.
|
|
9
|
+
//
|
|
10
|
+
// In-scope globals when assembled: normalizeName (inlined
|
|
11
|
+
// component-states.mjs), checkSpec, checkBuilt (bound from withTable) and the
|
|
12
|
+
// caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
|
|
13
|
+
// shape is documented at the top of scripts/lib/figma-checks.mjs.
|
|
14
|
+
|
|
15
|
+
async function variableName(alias) {
|
|
16
|
+
if (!alias || !alias.id) return null;
|
|
17
|
+
const variable = await figma.variables.getVariableByIdAsync(alias.id);
|
|
18
|
+
return variable ? variable.name : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The fields the focus check reads from a variant, a control layer or a direct child.
|
|
22
|
+
async function readFocusNode(node, { withEffects }) {
|
|
23
|
+
const strokes = (node.boundVariables && node.boundVariables.strokes) || [];
|
|
24
|
+
const strokeVariables = [];
|
|
25
|
+
for (const alias of strokes) {
|
|
26
|
+
const name = await variableName(alias);
|
|
27
|
+
if (name !== null) strokeVariables.push(name);
|
|
28
|
+
}
|
|
29
|
+
const bound = node.boundVariables || {};
|
|
30
|
+
const out = {
|
|
31
|
+
strokeAlign: node.strokeAlign,
|
|
32
|
+
strokeVariables,
|
|
33
|
+
strokeWeightBound: Boolean(
|
|
34
|
+
bound.strokeWeight || bound.strokeTopWeight || bound.strokeRightWeight || bound.strokeBottomWeight || bound.strokeLeftWeight,
|
|
35
|
+
),
|
|
36
|
+
};
|
|
37
|
+
if (withEffects) {
|
|
38
|
+
out.effects = [];
|
|
39
|
+
for (const effect of node.effects || []) {
|
|
40
|
+
const colorAlias = effect.boundVariables && effect.boundVariables.color;
|
|
41
|
+
out.effects.push({
|
|
42
|
+
type: effect.type,
|
|
43
|
+
visible: effect.visible,
|
|
44
|
+
spread: effect.spread,
|
|
45
|
+
colorVariable: await variableName(colorAlias),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
out.layoutPositioning = node.layoutPositioning;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readFocusTree(node) {
|
|
55
|
+
const focus = await readFocusNode(node, { withEffects: true });
|
|
56
|
+
focus.children = [];
|
|
57
|
+
for (const child of node.children || []) {
|
|
58
|
+
focus.children.push(await readFocusNode(child, { withEffects: false }));
|
|
59
|
+
}
|
|
60
|
+
return focus;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The getter throws when Figma flags the set as conflicting (duplicate variant
|
|
64
|
+
// combinations). Fall back to the axes the children declare, so the structural
|
|
65
|
+
// check names the duplicates instead of the read throwing.
|
|
66
|
+
function groupProperties(set) {
|
|
67
|
+
try {
|
|
68
|
+
return set.variantGroupProperties;
|
|
69
|
+
} catch {
|
|
70
|
+
const axes = {};
|
|
71
|
+
for (const child of set.children) {
|
|
72
|
+
for (const axis of Object.keys(child.variantProperties || {})) axes[axis] = { values: [] };
|
|
73
|
+
}
|
|
74
|
+
return axes;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readComponentSet(expect) {
|
|
79
|
+
await figma.currentPage.loadAsync();
|
|
80
|
+
const frameName = `WIP: ${expect.name}`;
|
|
81
|
+
const frame = figma.currentPage.findOne((n) => n.name === frameName);
|
|
82
|
+
if (!frame) throw new Error(`no node named "${frameName}" on the current page`);
|
|
83
|
+
const set = frame.type === 'COMPONENT_SET' ? frame : (frame.children || []).find((c) => c.type === 'COMPONENT_SET');
|
|
84
|
+
// A frame holding plain FRAMEs where the set should be is a failed assertion,
|
|
85
|
+
// not a missing target: report what is there so the structural check names it.
|
|
86
|
+
if (!set) {
|
|
87
|
+
const first = (frame.children || [])[0];
|
|
88
|
+
return { type: first ? first.type : 'NONE', variantGroupProperties: {}, children: [] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const isFocusState = (props) =>
|
|
92
|
+
Object.entries(props || {}).some(([k, v]) => {
|
|
93
|
+
const state = normalizeName(v);
|
|
94
|
+
return normalizeName(k) === 'state' && state === 'focus';
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const children = [];
|
|
98
|
+
for (const child of set.children) {
|
|
99
|
+
const read = { type: child.type, variantProperties: child.variantProperties };
|
|
100
|
+
if (child.type === 'COMPONENT' && isFocusState(child.variantProperties)) {
|
|
101
|
+
Object.assign(read, await readFocusTree(child));
|
|
102
|
+
if (expect.controlLayer !== undefined && expect.controlLayer !== null) {
|
|
103
|
+
const layer = child.findOne((n) => n.name === expect.controlLayer);
|
|
104
|
+
read.controlLayer = layer ? await readFocusTree(layer) : null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
children.push(read);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
type: set.type,
|
|
111
|
+
variantGroupProperties: groupProperties(set),
|
|
112
|
+
children,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runFigmaChecks(EXPECT, MODE) {
|
|
117
|
+
if (MODE === 'spec') return checkSpec(EXPECT);
|
|
118
|
+
if (MODE === 'built') return checkBuilt(await readComponentSet(EXPECT), EXPECT);
|
|
119
|
+
throw new Error(`MODE must be 'spec' or 'built', got ${JSON.stringify(MODE)}`);
|
|
120
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// The Figma stop conditions as a pure checker: the COMPONENT_SET assertion against
|
|
2
|
+
// the full variant matrix, the state matrix, and the focus ring. ZERO imports,
|
|
3
|
+
// `export const`/`export function` only. This module is inlined, with
|
|
4
|
+
// component-states.mjs above it, into the generated Figma script
|
|
5
|
+
// (references/figma-checks.md) by build-figma-checks.mjs, so it must run in both
|
|
6
|
+
// Node and the Figma plugin sandbox.
|
|
7
|
+
//
|
|
8
|
+
// The state table is not imported. `withTable({ resolveArchetype, BASELINE_STATES,
|
|
9
|
+
// STATE_SYNONYMS, normalizeName })` takes it, and the fold, from the caller and
|
|
10
|
+
// returns { checkSpec, checkBuilt }. In the generated script the caller passes the
|
|
11
|
+
// inlined component-states.mjs names; in tests it passes the module's own exports.
|
|
12
|
+
//
|
|
13
|
+
// Spec: docs/specs/2026-10-07-figma-checks-as-scripts.md
|
|
14
|
+
// Rules: references/figma-component-standards.md (State handling; audit items 8, 9)
|
|
15
|
+
//
|
|
16
|
+
// EXPECT: { name, matrix: { axis: [values] }, uiFramework, controlLayer? }
|
|
17
|
+
//
|
|
18
|
+
// SNAPSHOT (what the reader returns for the COMPONENT_SET in `WIP: <name>`):
|
|
19
|
+
// {
|
|
20
|
+
// type, // the set's node type, 'COMPONENT_SET'
|
|
21
|
+
// variantGroupProperties: { Axis: { values: [...] }, ... }, // only the keys are read
|
|
22
|
+
// children: [ VARIANT, ... ]
|
|
23
|
+
// }
|
|
24
|
+
// VARIANT (every child): { type, variantProperties: { Axis: 'Value' } }
|
|
25
|
+
// A VARIANT whose state value folds to `focus` also carries FOCUS NODE fields
|
|
26
|
+
// (below), plus `controlLayer` when EXPECT names one.
|
|
27
|
+
// FOCUS NODE (the variant itself, and the controlLayer subtree):
|
|
28
|
+
// {
|
|
29
|
+
// effects: [{ type, visible, spread, colorVariable }], // colorVariable: bound colour variable name or null
|
|
30
|
+
// strokeAlign, // 'INSIDE' | 'OUTSIDE' | 'CENTER'
|
|
31
|
+
// strokeVariables: [name, ...], // names of variables bound to stroke colours
|
|
32
|
+
// strokeWeightBound, // boundVariables.strokeWeight is present
|
|
33
|
+
// children: [{ layoutPositioning, strokeAlign, strokeVariables, strokeWeightBound }] // direct children only
|
|
34
|
+
// }
|
|
35
|
+
// VARIANT.controlLayer: a FOCUS NODE, or null when EXPECT names a layer the variant does not have.
|
|
36
|
+
// Variable names are resolved by the reader and only for focus variants and their controlLayer.
|
|
37
|
+
//
|
|
38
|
+
// RESULT: { pass, checks: [{ name, result: 'pass' | 'fail' | 'skip', detail }] }
|
|
39
|
+
// `pass` ignores skips. A failed assertion is data; nothing here throws.
|
|
40
|
+
|
|
41
|
+
// The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
|
|
42
|
+
// null `uiFramework` uses the `ring` recipe. Any other value is out of scope.
|
|
43
|
+
// ring - DROP_SHADOW effect with a focus-bound colour and spread > 0, or an
|
|
44
|
+
// ABSOLUTE ring child with an OUTSIDE stroke bound to a focus variable
|
|
45
|
+
// mui - an input: the control's own stroke and its weight are bound; any other
|
|
46
|
+
// component uses the ring recipe
|
|
47
|
+
// outline - the control or a direct child carries an OUTSIDE stroke bound to a
|
|
48
|
+
// focus variable
|
|
49
|
+
export const FRAMEWORKS = {
|
|
50
|
+
shadcn: { recipe: 'ring' },
|
|
51
|
+
tailwind: { recipe: 'ring' },
|
|
52
|
+
mui: { recipe: 'mui' },
|
|
53
|
+
'vanilla-css': { recipe: 'outline' },
|
|
54
|
+
vanilla: { recipe: 'outline' },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function frameworkRecipe(uiFramework) {
|
|
58
|
+
if (uiFramework === undefined || uiFramework === null) return 'ring';
|
|
59
|
+
return Object.prototype.hasOwnProperty.call(FRAMEWORKS, uiFramework)
|
|
60
|
+
? FRAMEWORKS[uiFramework].recipe
|
|
61
|
+
: null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A focus variable is any variable whose name has a `focus` segment:
|
|
65
|
+
// `border/focus`, `color/border/focus` and `color/focus/ring` count; `focusRing` and
|
|
66
|
+
// a raw colour (null) do not.
|
|
67
|
+
function isFocusVariable(name) {
|
|
68
|
+
return typeof name === 'string' && name.split('/').some((seg) => seg.trim().toLowerCase() === 'focus');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A choice control may carry selection on its own axis instead of as a `selected`
|
|
72
|
+
// value on the state axis: the sample's Checkbox has `checked: false | true |
|
|
73
|
+
// indeterminate`. An axis with one of these names satisfies `selected`.
|
|
74
|
+
const SELECTION_AXES = ['selected', 'checked', 'on'];
|
|
75
|
+
|
|
76
|
+
const hasFocusStroke = (node) => (node.strokeVariables ?? []).some(isFocusVariable);
|
|
77
|
+
|
|
78
|
+
function lacksRing(target, recipe, isInput) {
|
|
79
|
+
const children = target.children ?? [];
|
|
80
|
+
if (recipe === 'mui' && isInput) {
|
|
81
|
+
const lacks = [];
|
|
82
|
+
if (!hasFocusStroke(target)) lacks.push('a stroke bound to a focus variable');
|
|
83
|
+
if (!target.strokeWeightBound) lacks.push('a stroke weight bound to a variable');
|
|
84
|
+
return lacks;
|
|
85
|
+
}
|
|
86
|
+
if (recipe === 'outline') {
|
|
87
|
+
const outside = (n) => n.strokeAlign === 'OUTSIDE' && hasFocusStroke(n);
|
|
88
|
+
return outside(target) || children.some(outside)
|
|
89
|
+
? []
|
|
90
|
+
: ['an OUTSIDE stroke bound to a focus variable on the control or a direct child'];
|
|
91
|
+
}
|
|
92
|
+
const shadow = (target.effects ?? []).some(
|
|
93
|
+
(e) => e.type === 'DROP_SHADOW' && e.visible !== false && e.spread > 0 && isFocusVariable(e.colorVariable),
|
|
94
|
+
);
|
|
95
|
+
const ringChild = children.some(
|
|
96
|
+
(c) => c.layoutPositioning === 'ABSOLUTE' && c.strokeAlign === 'OUTSIDE' && hasFocusStroke(c),
|
|
97
|
+
);
|
|
98
|
+
return shadow || ringChild
|
|
99
|
+
? []
|
|
100
|
+
: [
|
|
101
|
+
'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',
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName }) {
|
|
106
|
+
const fold = normalizeName;
|
|
107
|
+
const foldState = (value) => {
|
|
108
|
+
const name = fold(value);
|
|
109
|
+
return STATE_SYNONYMS[name] ?? name;
|
|
110
|
+
};
|
|
111
|
+
const result = (checks) => ({ pass: checks.every((c) => c.result !== 'fail'), checks });
|
|
112
|
+
const label = (props) =>
|
|
113
|
+
Object.entries(props ?? {})
|
|
114
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
115
|
+
.join(', ');
|
|
116
|
+
|
|
117
|
+
// Combinations as comparable strings, axes sorted by folded name.
|
|
118
|
+
const comboKey = (props) =>
|
|
119
|
+
Object.entries(props ?? {})
|
|
120
|
+
.map(([k, v]) => [fold(k), fold(v)])
|
|
121
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
122
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
123
|
+
.join(', ');
|
|
124
|
+
|
|
125
|
+
function crossProduct(matrix) {
|
|
126
|
+
let combos = [{}];
|
|
127
|
+
for (const [axis, values] of Object.entries(matrix ?? {})) {
|
|
128
|
+
combos = combos.flatMap((combo) => values.map((value) => ({ ...combo, [axis]: value })));
|
|
129
|
+
}
|
|
130
|
+
return combos;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function structural(snapshot, expect) {
|
|
134
|
+
const name = 'structural-read-back';
|
|
135
|
+
const problems = [];
|
|
136
|
+
if (snapshot.type !== 'COMPONENT_SET') problems.push(`the node is ${snapshot.type}, not a COMPONENT_SET`);
|
|
137
|
+
const children = snapshot.children ?? [];
|
|
138
|
+
const notComponents = children.filter((c) => c.type !== 'COMPONENT');
|
|
139
|
+
if (notComponents.length > 0) {
|
|
140
|
+
problems.push(`${notComponents.length} of ${children.length} children are not COMPONENTs (${[...new Set(notComponents.map((c) => c.type))].join(', ')})`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const builtAxes = Object.keys(snapshot.variantGroupProperties ?? {}).map(fold).sort();
|
|
144
|
+
const wantAxes = Object.keys(expect.matrix ?? {}).map(fold).sort();
|
|
145
|
+
const missingAxes = wantAxes.filter((a) => !builtAxes.includes(a));
|
|
146
|
+
const extraAxes = builtAxes.filter((a) => !wantAxes.includes(a));
|
|
147
|
+
if (missingAxes.length) problems.push(`missing axes: ${missingAxes.join(', ')}`);
|
|
148
|
+
if (extraAxes.length) problems.push(`extra axes: ${extraAxes.join(', ')}`);
|
|
149
|
+
|
|
150
|
+
const want = new Map(crossProduct(expect.matrix).map((combo) => [comboKey(combo), label(combo)]));
|
|
151
|
+
const seen = new Map();
|
|
152
|
+
const duplicates = [];
|
|
153
|
+
for (const child of children) {
|
|
154
|
+
const key = comboKey(child.variantProperties);
|
|
155
|
+
if (seen.has(key)) duplicates.push(label(child.variantProperties));
|
|
156
|
+
else seen.set(key, label(child.variantProperties));
|
|
157
|
+
}
|
|
158
|
+
const missing = [...want].filter(([key]) => !seen.has(key)).map(([, text]) => text);
|
|
159
|
+
const extra = [...seen].filter(([key]) => !want.has(key)).map(([, text]) => text);
|
|
160
|
+
if (missing.length) problems.push(`missing combinations: ${missing.join(' | ')}`);
|
|
161
|
+
if (extra.length) problems.push(`extra combinations: ${extra.join(' | ')}`);
|
|
162
|
+
if (duplicates.length) problems.push(`duplicate combinations: ${duplicates.join(' | ')}`);
|
|
163
|
+
|
|
164
|
+
return problems.length
|
|
165
|
+
? { name, result: 'fail', detail: problems.join('; ') }
|
|
166
|
+
: { name, result: 'pass', detail: `COMPONENT_SET with ${children.length} COMPONENT variants matching the matrix` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// `entries` are [axisName, value] pairs; only the `state` axis is read.
|
|
170
|
+
function stateBaseline(expect, entries) {
|
|
171
|
+
const name = 'state-baseline';
|
|
172
|
+
const archetype = resolveArchetype({ name: expect.name });
|
|
173
|
+
if (archetype === null) {
|
|
174
|
+
return { name, result: 'skip', detail: `"${expect.name}" is not a name in the state table` };
|
|
175
|
+
}
|
|
176
|
+
const baseline = BASELINE_STATES[archetype];
|
|
177
|
+
if (baseline.length === 0) {
|
|
178
|
+
return { name, result: 'skip', detail: `"${expect.name}" is a ${archetype}, which owes no states beyond default` };
|
|
179
|
+
}
|
|
180
|
+
const present = new Set(entries.filter(([axis]) => fold(axis) === 'state').map(([, value]) => foldState(value)));
|
|
181
|
+
if (entries.some(([axis]) => SELECTION_AXES.includes(fold(axis)))) present.add('selected');
|
|
182
|
+
const required = ['default', ...baseline];
|
|
183
|
+
const missing = required.filter((s) => !present.has(foldState(s)));
|
|
184
|
+
return missing.length
|
|
185
|
+
? { name, result: 'fail', detail: `the state axis is missing: ${missing.join(', ')}` }
|
|
186
|
+
: { name, result: 'pass', detail: `the state axis has default and every ${archetype} state` };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function focusIndicator(snapshot, expect) {
|
|
190
|
+
const name = 'focus-indicator';
|
|
191
|
+
const archetype = resolveArchetype({ name: expect.name });
|
|
192
|
+
if (archetype === null) {
|
|
193
|
+
return { name, result: 'skip', detail: `"${expect.name}" is not a name in the state table` };
|
|
194
|
+
}
|
|
195
|
+
if (BASELINE_STATES[archetype].length === 0) {
|
|
196
|
+
return { name, result: 'skip', detail: `"${expect.name}" is a ${archetype}, which owes no states, so no focus ring is checked` };
|
|
197
|
+
}
|
|
198
|
+
const recipe = frameworkRecipe(expect.uiFramework);
|
|
199
|
+
if (recipe === null) {
|
|
200
|
+
return { name, result: 'skip', detail: `uiFramework "${expect.uiFramework}" is out of scope for the focus check` };
|
|
201
|
+
}
|
|
202
|
+
const isFocusVariant = (child) =>
|
|
203
|
+
child.type === 'COMPONENT' &&
|
|
204
|
+
Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
|
|
205
|
+
const variants = (snapshot.children ?? []).filter(isFocusVariant);
|
|
206
|
+
if (variants.length === 0) {
|
|
207
|
+
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' };
|
|
208
|
+
}
|
|
209
|
+
const misses = [];
|
|
210
|
+
for (const variant of variants) {
|
|
211
|
+
let target = variant;
|
|
212
|
+
if (expect.controlLayer !== undefined && expect.controlLayer !== null) {
|
|
213
|
+
target = variant.controlLayer;
|
|
214
|
+
if (!target) {
|
|
215
|
+
misses.push(`${label(variant.variantProperties)}: no layer named "${expect.controlLayer}"`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const lacks = lacksRing(target, recipe, archetype === 'input');
|
|
220
|
+
if (lacks.length) misses.push(`${label(variant.variantProperties)}: lacks ${lacks.join(' and ')}`);
|
|
221
|
+
}
|
|
222
|
+
return misses.length
|
|
223
|
+
? { name, result: 'fail', detail: misses.join('; ') }
|
|
224
|
+
: { name, result: 'pass', detail: `all ${variants.length} focus variants carry a ${recipe}-recipe focus indicator` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Before anything is written: the matrix the spec asks for against the state table.
|
|
228
|
+
function checkSpec(expect) {
|
|
229
|
+
const entries = Object.entries(expect.matrix ?? {}).flatMap(([axis, values]) => values.map((v) => [axis, v]));
|
|
230
|
+
return result([stateBaseline(expect, entries)]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// After the build: the node as read.
|
|
234
|
+
function checkBuilt(snapshot, expect) {
|
|
235
|
+
const entries = (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
|
|
236
|
+
return result([structural(snapshot, expect), stateBaseline(expect, entries), focusIndicator(snapshot, expect)]);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { checkSpec, checkBuilt };
|
|
240
|
+
}
|