@radicool/throughline 0.22.0 → 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 +4 -4
- package/adapters/codex/prompts/component-builder.md +6 -2
- package/adapters/cursor/.cursor/rules/component-builder.mdc +6 -2
- package/adapters/generic/skills/component-builder/SKILL.md +6 -2
- package/package.json +1 -1
- package/references/figma-checks.md +484 -0
- package/references/figma-component-standards.md +8 -38
- package/references/figma-scripting.md +6 -6
- package/references/state-baseline.md +5 -3
- package/scripts/README.md +4 -1
- package/scripts/build-figma-checks.mjs +98 -0
- package/scripts/build-state-baseline.mjs +5 -3
- package/scripts/install.mjs +9 -4
- package/scripts/lib/component-states.mjs +4 -1
- package/scripts/lib/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/README.md
CHANGED
|
@@ -73,7 +73,7 @@ Everything above was created during a single working session and synced directly
|
|
|
73
73
|
|
|
74
74
|
| | |
|
|
75
75
|
|---|---|
|
|
76
|
-
| **A supported agent** | Required — [Claude Code](https://docs.claude.com/en/docs/claude-code) (native plugin), or **Cursor**, **Codex**, or any **AGENTS.md**-aware agent via `npx @radicool/throughline@0.
|
|
76
|
+
| **A supported agent** | Required — [Claude Code](https://docs.claude.com/en/docs/claude-code) (native plugin), or **Cursor**, **Codex**, or any **AGENTS.md**-aware agent via `npx @radicool/throughline@0.23.0 init`. |
|
|
77
77
|
| **Figma** | Required, **desktop app** (the browser version causes connection errors). **Professional plan or higher recommended** — multi-mode variables (Light/Dark, brand themes) need it. |
|
|
78
78
|
| **Figma access token** | Optional — only for the few Figma calls the Desktop Bridge can't make, like reading a published library. Without it, you work through the bridge alone. The setup skill walks you through it; your token stays yours and is never shared in chat. |
|
|
79
79
|
| **GitHub** (or similar) | Optional — only when you're ready to graduate to a real remote repo with PRs and CI. |
|
|
@@ -114,9 +114,9 @@ Update anytime with `/plugin marketplace update throughline-marketplace`.
|
|
|
114
114
|
Run the installer in your project — it stamps in the skills, the reference docs, the scripts, and the Figma MCP config for your tool:
|
|
115
115
|
|
|
116
116
|
```
|
|
117
|
-
npx @radicool/throughline@0.
|
|
118
|
-
npx @radicool/throughline@0.
|
|
119
|
-
npx @radicool/throughline@0.
|
|
117
|
+
npx @radicool/throughline@0.23.0 init --target=cursor # → .cursor/rules + .cursor/mcp.json
|
|
118
|
+
npx @radicool/throughline@0.23.0 init --target=codex # → prompts/ + AGENTS.md index + codex-mcp.toml
|
|
119
|
+
npx @radicool/throughline@0.23.0 init --target=generic # → skills/ + AGENTS.md index
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
It's safe to re-run (it merges `AGENTS.md` and `.cursor/mcp.json` non-destructively) and stages everything the skills read into `.throughline/`. Then open the `figma-environment-setup` skill/rule/prompt for your tool to begin. For Codex, add the printed `codex-mcp.toml` block to your Codex config to enable Figma access.
|
|
@@ -173,8 +173,12 @@ deterministic naming):
|
|
|
173
173
|
re-screenshot) **and its "Post-build audit (REQUIRED before handoff)"
|
|
174
174
|
read-back checklist** (container type, auto layout, bound variables,
|
|
175
175
|
deterministic names) before the checkpoint. When you build inline, with no
|
|
176
|
-
`figma-executor`,
|
|
177
|
-
the
|
|
176
|
+
`figma-executor`, run `.throughline/references/figma-checks.md` the
|
|
177
|
+
way the executor does: `'spec'` mode before the first write and `'built'` mode
|
|
178
|
+
in the self-verify loop, with `EXPECT` built from your own matrix. You are the
|
|
179
|
+
architect here, so you name `controlLayer` yourself for a wrapped control (an
|
|
180
|
+
Input with a label or helper text). A `fail` on the `focus-indicator` check
|
|
181
|
+
stops the build, as it would for the executor.
|
|
178
182
|
|
|
179
183
|
Checkpoint after each component: show all variants, confirm before the next.
|
|
180
184
|
|
|
@@ -177,8 +177,12 @@ deterministic naming):
|
|
|
177
177
|
re-screenshot) **and its "Post-build audit (REQUIRED before handoff)"
|
|
178
178
|
read-back checklist** (container type, auto layout, bound variables,
|
|
179
179
|
deterministic names) before the checkpoint. When you build inline, with no
|
|
180
|
-
`figma-executor`,
|
|
181
|
-
the
|
|
180
|
+
`figma-executor`, run `.throughline/references/figma-checks.md` the
|
|
181
|
+
way the executor does: `'spec'` mode before the first write and `'built'` mode
|
|
182
|
+
in the self-verify loop, with `EXPECT` built from your own matrix. You are the
|
|
183
|
+
architect here, so you name `controlLayer` yourself for a wrapped control (an
|
|
184
|
+
Input with a label or helper text). A `fail` on the `focus-indicator` check
|
|
185
|
+
stops the build, as it would for the executor.
|
|
182
186
|
|
|
183
187
|
Checkpoint after each component: show all variants, confirm before the next.
|
|
184
188
|
|
|
@@ -173,8 +173,12 @@ deterministic naming):
|
|
|
173
173
|
re-screenshot) **and its "Post-build audit (REQUIRED before handoff)"
|
|
174
174
|
read-back checklist** (container type, auto layout, bound variables,
|
|
175
175
|
deterministic names) before the checkpoint. When you build inline, with no
|
|
176
|
-
`figma-executor`,
|
|
177
|
-
the
|
|
176
|
+
`figma-executor`, run `.throughline/references/figma-checks.md` the
|
|
177
|
+
way the executor does: `'spec'` mode before the first write and `'built'` mode
|
|
178
|
+
in the self-verify loop, with `EXPECT` built from your own matrix. You are the
|
|
179
|
+
architect here, so you name `controlLayer` yourself for a wrapped control (an
|
|
180
|
+
Input with a label or helper text). A `fail` on the `focus-indicator` check
|
|
181
|
+
stops the build, as it would for the executor.
|
|
178
182
|
|
|
179
183
|
Checkpoint after each component: show all variants, confirm before the next.
|
|
180
184
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radicool/throughline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Build a complete design system end to end — author in Figma, sync tokens to code, generate Storybook. Usable from Claude Code, Cursor, Codex, or any AGENTS.md agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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
|
+
```
|
|
@@ -557,7 +557,9 @@ For each generated artboard / doc card / icon grid, read the nodes back (via
|
|
|
557
557
|
8. **States complete** — each component set's `state` axis includes every relevant
|
|
558
558
|
state for that component (every state the table in `references/state-baseline.md`
|
|
559
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.
|
|
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.
|
|
561
563
|
9. **Focus state matches the library idiom** — the focus state is built as
|
|
562
564
|
`project.uiFramework`'s real pattern (see "State handling"), not a house-style
|
|
563
565
|
stroke, and with no padded wrapper inflating the component. Shadow-based recipes: a
|
|
@@ -570,49 +572,17 @@ For each generated artboard / doc card / icon grid, read the nodes back (via
|
|
|
570
572
|
wrapper, an inside-aligned stroke, or a transparent variant with no ring at all must
|
|
571
573
|
be retrofitted to the current recipe (see "State handling").
|
|
572
574
|
|
|
573
|
-
**The `focus-indicator` check** is the programmatic half of this item
|
|
574
|
-
|
|
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:
|
|
575
580
|
- **`figma-executor` returns `BLOCKED`** on a miss, naming the variant. It
|
|
576
581
|
builds from an architect spec, and adding a ring the spec didn't give is a
|
|
577
582
|
design decision it doesn't make.
|
|
578
583
|
- **Building inline**, you designed the ring yourself, so a miss is a build
|
|
579
584
|
error: fix and re-audit like any other item. If the ring is still missing
|
|
580
585
|
when the pass budget below runs out, **stop the build** rather than hand off.
|
|
581
|
-
- **Scope.** It runs only for a component whose name folds (lowercase,
|
|
582
|
-
punctuation stripped) to a name in the table in `references/state-baseline.md`
|
|
583
|
-
(not its exempt list), the same
|
|
584
|
-
exact-name match as the state check and the same list the CLI's
|
|
585
|
-
`state-incomplete` reads. It also
|
|
586
|
-
needs `project.uiFramework` to be exactly `shadcn`, `tailwind`, `mui`,
|
|
587
|
-
`vanilla-css` or `vanilla`, or absent or `null`. Anything else (`ios-swift`,
|
|
588
|
-
a tier-2 library, any other value) is skipped, and the skip is said.
|
|
589
|
-
- **What it reads.** Every child `COMPONENT` of the set whose
|
|
590
|
-
`variantProperties` has a key folding to `state` with a value folding to
|
|
591
|
-
exactly `focus`. `Focused` or `focus-visible` isn't read. The ring is looked
|
|
592
|
-
for on that variant, or, where the variant wraps the control in other content
|
|
593
|
-
(an Input with a label or helper text), on the child frame that is the
|
|
594
|
-
control, the node the recipe above puts the ring on. Reading **zero** focus
|
|
595
|
-
variants on an in-scope component is not a pass: it means the `state` axis
|
|
596
|
-
has no `focus` value, which the state check stops on.
|
|
597
|
-
- **A focus variable** is any variable whose name has a `focus` segment:
|
|
598
|
-
`border/focus`, `color/border/focus`, `color/focus/ring` and
|
|
599
|
-
`color/focus/ringOnFill` all count. Systems name their ring role differently,
|
|
600
|
-
and a collection prefix is common. A raw colour, or a variable with no `focus`
|
|
601
|
-
segment, doesn't count.
|
|
602
|
-
- **Ring recipes** (`shadcn`, `tailwind`, absent or `null`; `mui` on anything
|
|
603
|
-
but an input). The control has either a `DROP_SHADOW` effect with
|
|
604
|
-
`visible !== false`, `spread > 0` and `boundVariables.color` on a focus
|
|
605
|
-
variable, or a direct child with `layoutPositioning = "ABSOLUTE"` and
|
|
606
|
-
`strokeAlign = "OUTSIDE"` whose stroke is bound to a focus variable.
|
|
607
|
-
`spread > 0` matters: binding an effect's colour can silently zero its spread
|
|
608
|
-
(see `figma-scripting.md`), which leaves a ring that is bound and invisible.
|
|
609
|
-
Only the ring is asserted. The recipe's border recolour is styling, and a
|
|
610
|
-
control whose border keeps its normal colour beside a correct ring passes.
|
|
611
|
-
- **`mui` inputs.** The control's own stroke is bound to a focus variable, and its
|
|
612
|
-
weight to a variable (the recipe's `width/focus`).
|
|
613
|
-
- **`vanilla-css` / `vanilla`.** The control or a direct child carries a stroke
|
|
614
|
-
bound to a focus variable with `strokeAlign = "OUTSIDE"`. The `offset/focus` gap
|
|
615
|
-
isn't asserted.
|
|
616
586
|
- **The result.** On a miss, stop and name the variant and what it lacks. On a
|
|
617
587
|
pass, `component-builder` records `focus-indicator` as an attested check. A
|
|
618
588
|
skip records nothing.
|
|
@@ -330,12 +330,12 @@ uses **build-verify-then-replace**:
|
|
|
330
330
|
2. **Verify the working frame green before touching anything real** — a
|
|
331
331
|
screenshot alone is **insufficient**: 10 tone-colored frames render
|
|
332
332
|
identically to 10 real variants, so a fast model can build plain frames and
|
|
333
|
-
self-report success. The gate is a **programmatic read-back** (`figma_execute`)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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**
|
|
339
339
|
check (create → read-back → screenshot → iterate, max ~3), never the sole one.
|
|
340
340
|
3. **Only then finalize:** remove/replace any existing same-named component,
|
|
341
341
|
rename the working frame's component set to the real `<ComponentName>`, **and
|