@radicool/throughline 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/adapters/codex/AGENTS.md +1 -0
- package/adapters/codex/prompts/check-figma.md +41 -0
- package/adapters/cursor/.cursor/commands/check-figma.md +41 -0
- package/adapters/generic/AGENTS.md +1 -0
- package/adapters/generic/commands/check-figma.md +41 -0
- package/package.json +1 -1
- package/references/figma-adherence.md +442 -0
- package/references/figma-checks.md +106 -43
- package/scripts/README.md +5 -2
- package/scripts/build-figma-checks.mjs +121 -23
- package/scripts/install.mjs +5 -1
- package/scripts/lib/figma-adherence-read.figma.js +207 -0
- package/scripts/lib/figma-adherence.mjs +146 -0
- package/scripts/lib/figma-checks-read.figma.js +43 -43
- package/scripts/lib/figma-checks.mjs +16 -5
- package/scripts/lib/figma-variables-read.figma.js +39 -0
- package/scripts/lib/fixtures/figma-adherence/clean.json +90 -0
- package/scripts/lib/fixtures/figma-adherence/detached-library.json +116 -0
- package/scripts/lib/fixtures/figma-adherence/ds01-gaps.json +84 -0
- package/scripts/lib/fixtures/figma-adherence/inert-all.json +45 -0
- package/scripts/lib/fixtures/figma-adherence/mixed-text.json +61 -0
- package/scripts/lib/fixtures/figma-adherence/nothing-scanned.json +49 -0
- package/scripts/lib/fixtures/figma-adherence/off-scale-gap.json +65 -0
- package/scripts/lib/fixtures/figma-adherence/override-raw.json +72 -0
- package/scripts/lib/fixtures/figma-adherence/padding-raw.json +74 -0
- package/scripts/lib/fixtures/figma-adherence/raw-colour-token.json +70 -0
- package/scripts/lib/fixtures/figma-adherence/root-radius.json +88 -0
- package/scripts/lib/fixtures/figma-adherence/skip-colour.json +86 -0
- package/scripts/lib/fixtures/figma-adherence/zygarden-override-bound.json +85 -0
- package/scripts/lib/fixtures/figma-adherence/zygarden-text-raw.json +208 -0
package/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.25.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. |
|
|
@@ -107,6 +107,8 @@ Then start with:
|
|
|
107
107
|
|
|
108
108
|
This is the reliable entry point — it runs environment setup first, ahead of anything else. (You can also just say *"let's set up my design system"*, but if you have other plugins installed that grab "let's build…" style phrases, the slash command guarantees ThroughLine takes the wheel.)
|
|
109
109
|
|
|
110
|
+
Already have screens or components in Figma? `/throughline:check-figma` checks them against the system without touching the file: it flags a component someone detached, and a colour, gap, padding or radius that's hardcoded when a token already has that value. It also runs the state, focus-ring and focus-contrast checks over the components already in your library.
|
|
111
|
+
|
|
110
112
|
Update anytime with `/plugin marketplace update throughline-marketplace`.
|
|
111
113
|
|
|
112
114
|
#### Cursor, Codex, or a generic AGENTS.md agent
|
|
@@ -114,9 +116,9 @@ Update anytime with `/plugin marketplace update throughline-marketplace`.
|
|
|
114
116
|
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
117
|
|
|
116
118
|
```
|
|
117
|
-
npx @radicool/throughline@0.
|
|
118
|
-
npx @radicool/throughline@0.
|
|
119
|
-
npx @radicool/throughline@0.
|
|
119
|
+
npx @radicool/throughline@0.25.0 init --target=cursor # → .cursor/rules + .cursor/mcp.json
|
|
120
|
+
npx @radicool/throughline@0.25.0 init --target=codex # → prompts/ + AGENTS.md index + codex-mcp.toml
|
|
121
|
+
npx @radicool/throughline@0.25.0 init --target=generic # → skills/ + AGENTS.md index
|
|
120
122
|
```
|
|
121
123
|
|
|
122
124
|
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.
|
package/adapters/codex/AGENTS.md
CHANGED
|
@@ -19,6 +19,7 @@ ThroughLine builds a design system end to end. Load the matching prompt for the
|
|
|
19
19
|
|
|
20
20
|
## ThroughLine commands
|
|
21
21
|
|
|
22
|
+
- `check-figma` — Check what's already in Figma against the design system, read-only — screens for detached components and raw values a token has, and existing component sets for their states, focus ring and focus contrast. → load `prompts/check-figma.md`.
|
|
22
23
|
- `design-system-status` — Show a plain-language summary of the current design system state — what's set up, what's not, and sensible next steps — read from design-system.json. → load `prompts/design-system-status.md`.
|
|
23
24
|
- `document-component` — Author, refresh, or reconcile the usage documentation for one existing component — draft its canonical doc record from four sources, project it to Figma, the doc card, and code, and resolve any drift via a reviewable per-item choice. → load `prompts/document-component.md`.
|
|
24
25
|
- `new-component` — Build a single new component end to end — in Figma, then sync any new tokens, then build its code component and stories — with a confirmation between each stage. → load `prompts/new-component.md`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Check the Figma file against the design system without changing it. Use the
|
|
2
|
+
settings already in `design-system.json` (`figma.fileKey`, `project.uiFramework`,
|
|
3
|
+
`components.built`) rather than re-asking them. Scale explanation to
|
|
4
|
+
`user.codingLevel`.
|
|
5
|
+
|
|
6
|
+
1. **Know the file.** Read `figma.fileKey` from `design-system.json`. If it's
|
|
7
|
+
absent, stop and ask which file to check.
|
|
8
|
+
2. **Confirm the active file.** Run the preflight in
|
|
9
|
+
`.throughline/references/figma-scripting.md` ("confirm the *active*
|
|
10
|
+
file is your target") against that fileKey, and pin it with `figma_navigate`
|
|
11
|
+
(`lock: true`) for the run. Unpin when done.
|
|
12
|
+
3. **Screens.** Unless the user named them, ask which page holds the screens, and
|
|
13
|
+
optionally which screens on it. Run the snippet in
|
|
14
|
+
`.throughline/references/figma-adherence.md` verbatim, with
|
|
15
|
+
`const TARGET = { page, screens, fileKey }` (the fileKey from step 1, for the
|
|
16
|
+
links) and `const SKIP = []` filled. The script is the rule; don't re-derive its
|
|
17
|
+
checks.
|
|
18
|
+
4. **Existing component sets.** Ask which page holds the component sets, so only
|
|
19
|
+
that page is loaded. Ask which colour variables are the page surfaces the
|
|
20
|
+
components sit on, offering the file's likeliest default page surface (read the
|
|
21
|
+
names with `figma_get_variables`), or let the user skip focus contrast. For each
|
|
22
|
+
name in `components.built`, run
|
|
23
|
+
`.throughline/references/figma-checks.md` with `MODE = 'existing'` and
|
|
24
|
+
`EXPECT = { name, page, uiFramework, surfaces }` (`uiFramework` from
|
|
25
|
+
`project.uiFramework`). A name with no set on that page is reported as missing,
|
|
26
|
+
not as a failure of the others. When `focus-indicator` fails on a wrapped control
|
|
27
|
+
(an Input with a label, a Checkbox whose box is an inner frame), look at one focus
|
|
28
|
+
variant's layers before reporting it: if the ring is on an inner layer, run that
|
|
29
|
+
set again with `controlLayer` naming that layer, and say in the report that you
|
|
30
|
+
did.
|
|
31
|
+
5. **Report.** The screen gate gives one pass or fail for the page: list its
|
|
32
|
+
failures grouped by screen (the path's second segment), each with its detail and
|
|
33
|
+
link, then the advisories, the counts and any skipped rules. Then pass or fail
|
|
34
|
+
per component set, with whether focus contrast ran and against which surfaces. Say plainly what the
|
|
35
|
+
gate can't see: a frame drawn from scratch to look like a component, variables
|
|
36
|
+
that live only in a published library, and focus states drawn as an INSIDE
|
|
37
|
+
stroke.
|
|
38
|
+
|
|
39
|
+
Never edit the file to make a check pass, and never edit a token. A failure is
|
|
40
|
+
a finding for the user; fixing it is a separate, deliberate step.
|
|
41
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Check the Figma file against the design system without changing it. Use the
|
|
2
|
+
settings already in `design-system.json` (`figma.fileKey`, `project.uiFramework`,
|
|
3
|
+
`components.built`) rather than re-asking them. Scale explanation to
|
|
4
|
+
`user.codingLevel`.
|
|
5
|
+
|
|
6
|
+
1. **Know the file.** Read `figma.fileKey` from `design-system.json`. If it's
|
|
7
|
+
absent, stop and ask which file to check.
|
|
8
|
+
2. **Confirm the active file.** Run the preflight in
|
|
9
|
+
`.throughline/references/figma-scripting.md` ("confirm the *active*
|
|
10
|
+
file is your target") against that fileKey, and pin it with `figma_navigate`
|
|
11
|
+
(`lock: true`) for the run. Unpin when done.
|
|
12
|
+
3. **Screens.** Unless the user named them, ask which page holds the screens, and
|
|
13
|
+
optionally which screens on it. Run the snippet in
|
|
14
|
+
`.throughline/references/figma-adherence.md` verbatim, with
|
|
15
|
+
`const TARGET = { page, screens, fileKey }` (the fileKey from step 1, for the
|
|
16
|
+
links) and `const SKIP = []` filled. The script is the rule; don't re-derive its
|
|
17
|
+
checks.
|
|
18
|
+
4. **Existing component sets.** Ask which page holds the component sets, so only
|
|
19
|
+
that page is loaded. Ask which colour variables are the page surfaces the
|
|
20
|
+
components sit on, offering the file's likeliest default page surface (read the
|
|
21
|
+
names with `figma_get_variables`), or let the user skip focus contrast. For each
|
|
22
|
+
name in `components.built`, run
|
|
23
|
+
`.throughline/references/figma-checks.md` with `MODE = 'existing'` and
|
|
24
|
+
`EXPECT = { name, page, uiFramework, surfaces }` (`uiFramework` from
|
|
25
|
+
`project.uiFramework`). A name with no set on that page is reported as missing,
|
|
26
|
+
not as a failure of the others. When `focus-indicator` fails on a wrapped control
|
|
27
|
+
(an Input with a label, a Checkbox whose box is an inner frame), look at one focus
|
|
28
|
+
variant's layers before reporting it: if the ring is on an inner layer, run that
|
|
29
|
+
set again with `controlLayer` naming that layer, and say in the report that you
|
|
30
|
+
did.
|
|
31
|
+
5. **Report.** The screen gate gives one pass or fail for the page: list its
|
|
32
|
+
failures grouped by screen (the path's second segment), each with its detail and
|
|
33
|
+
link, then the advisories, the counts and any skipped rules. Then pass or fail
|
|
34
|
+
per component set, with whether focus contrast ran and against which surfaces. Say plainly what the
|
|
35
|
+
gate can't see: a frame drawn from scratch to look like a component, variables
|
|
36
|
+
that live only in a published library, and focus states drawn as an INSIDE
|
|
37
|
+
stroke.
|
|
38
|
+
|
|
39
|
+
Never edit the file to make a check pass, and never edit a token. A failure is
|
|
40
|
+
a finding for the user; fixing it is a separate, deliberate step.
|
|
41
|
+
|
|
@@ -19,6 +19,7 @@ ThroughLine builds a design system end to end. Read the matching skill file for
|
|
|
19
19
|
|
|
20
20
|
## ThroughLine commands
|
|
21
21
|
|
|
22
|
+
- `check-figma` — Check what's already in Figma against the design system, read-only — screens for detached components and raw values a token has, and existing component sets for their states, focus ring and focus contrast. → read `commands/check-figma.md`.
|
|
22
23
|
- `design-system-status` — Show a plain-language summary of the current design system state — what's set up, what's not, and sensible next steps — read from design-system.json. → read `commands/design-system-status.md`.
|
|
23
24
|
- `document-component` — Author, refresh, or reconcile the usage documentation for one existing component — draft its canonical doc record from four sources, project it to Figma, the doc card, and code, and resolve any drift via a reviewable per-item choice. → read `commands/document-component.md`.
|
|
24
25
|
- `new-component` — Build a single new component end to end — in Figma, then sync any new tokens, then build its code component and stories — with a confirmation between each stage. → read `commands/new-component.md`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Check the Figma file against the design system without changing it. Use the
|
|
2
|
+
settings already in `design-system.json` (`figma.fileKey`, `project.uiFramework`,
|
|
3
|
+
`components.built`) rather than re-asking them. Scale explanation to
|
|
4
|
+
`user.codingLevel`.
|
|
5
|
+
|
|
6
|
+
1. **Know the file.** Read `figma.fileKey` from `design-system.json`. If it's
|
|
7
|
+
absent, stop and ask which file to check.
|
|
8
|
+
2. **Confirm the active file.** Run the preflight in
|
|
9
|
+
`.throughline/references/figma-scripting.md` ("confirm the *active*
|
|
10
|
+
file is your target") against that fileKey, and pin it with `figma_navigate`
|
|
11
|
+
(`lock: true`) for the run. Unpin when done.
|
|
12
|
+
3. **Screens.** Unless the user named them, ask which page holds the screens, and
|
|
13
|
+
optionally which screens on it. Run the snippet in
|
|
14
|
+
`.throughline/references/figma-adherence.md` verbatim, with
|
|
15
|
+
`const TARGET = { page, screens, fileKey }` (the fileKey from step 1, for the
|
|
16
|
+
links) and `const SKIP = []` filled. The script is the rule; don't re-derive its
|
|
17
|
+
checks.
|
|
18
|
+
4. **Existing component sets.** Ask which page holds the component sets, so only
|
|
19
|
+
that page is loaded. Ask which colour variables are the page surfaces the
|
|
20
|
+
components sit on, offering the file's likeliest default page surface (read the
|
|
21
|
+
names with `figma_get_variables`), or let the user skip focus contrast. For each
|
|
22
|
+
name in `components.built`, run
|
|
23
|
+
`.throughline/references/figma-checks.md` with `MODE = 'existing'` and
|
|
24
|
+
`EXPECT = { name, page, uiFramework, surfaces }` (`uiFramework` from
|
|
25
|
+
`project.uiFramework`). A name with no set on that page is reported as missing,
|
|
26
|
+
not as a failure of the others. When `focus-indicator` fails on a wrapped control
|
|
27
|
+
(an Input with a label, a Checkbox whose box is an inner frame), look at one focus
|
|
28
|
+
variant's layers before reporting it: if the ring is on an inner layer, run that
|
|
29
|
+
set again with `controlLayer` naming that layer, and say in the report that you
|
|
30
|
+
did.
|
|
31
|
+
5. **Report.** The screen gate gives one pass or fail for the page: list its
|
|
32
|
+
failures grouped by screen (the path's second segment), each with its detail and
|
|
33
|
+
link, then the advisories, the counts and any skipped rules. Then pass or fail
|
|
34
|
+
per component set, with whether focus contrast ran and against which surfaces. Say plainly what the
|
|
35
|
+
gate can't see: a frame drawn from scratch to look like a component, variables
|
|
36
|
+
that live only in a published library, and focus states drawn as an INSIDE
|
|
37
|
+
stroke.
|
|
38
|
+
|
|
39
|
+
Never edit the file to make a check pass, and never edit a token. A failure is
|
|
40
|
+
a finding for the user; fixing it is a separate, deliberate step.
|
|
41
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radicool/throughline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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,442 @@
|
|
|
1
|
+
# Figma screen adherence gate (GENERATED)
|
|
2
|
+
|
|
3
|
+
> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/figma-adherence.mjs`
|
|
4
|
+
> (the pure checker, unit-tested in Node), `scripts/lib/figma-variables-read.figma.js`
|
|
5
|
+
> (colour-variable resolution) + `scripts/lib/figma-adherence-read.figma.js` (the
|
|
6
|
+
> screen reader). Regenerate with `node scripts/build-figma-checks.mjs`; CI gates
|
|
7
|
+
> freshness with `--check`.
|
|
8
|
+
|
|
9
|
+
The canonical `figma_execute` snippet that checks Figma screens against the design
|
|
10
|
+
system, read-only. It fails a frame detached from a component, a raw colour that a
|
|
11
|
+
colour variable has (opacity included), and a raw gap, padding or corner radius
|
|
12
|
+
that a spacing or radius variable has, naming the variable to bind. A number
|
|
13
|
+
variable counts by its scope (`GAP`, `CORNER_RADIUS`), or, left at `ALL_SCOPES`,
|
|
14
|
+
by a name that says spacing or radius. A raw value no variable has is an
|
|
15
|
+
advisory, not a failure. A rule that had nothing to compare against fails by name
|
|
16
|
+
(`nothing-scanned`, `colour-rule-inert`, `dimension-rule-inert`). The script is the
|
|
17
|
+
rule; do not re-write it by hand.
|
|
18
|
+
|
|
19
|
+
## How to call it
|
|
20
|
+
|
|
21
|
+
1. Paste the snippet below into `figma_execute`, with the two slots filled above it:
|
|
22
|
+
- `const TARGET = { page, screens, fileKey }` — `page` is the page name (an exact
|
|
23
|
+
match, or else the one page whose name matches once leading and trailing
|
|
24
|
+
spaces are trimmed); `screens` is an optional list of screen names (top-level
|
|
25
|
+
frames, or frames directly inside a top-level section), and without it every
|
|
26
|
+
screen on the page is read; `fileKey` is `figma.fileKey` from
|
|
27
|
+
`design-system.json`, used for the links.
|
|
28
|
+
- `const SKIP = []` — rule names to switch off (`detached-component`,
|
|
29
|
+
`raw-colour`, `raw-dimension`); a skipped rule's inert check is skipped too.
|
|
30
|
+
2. End the script with `return await runScreenGate(TARGET, SKIP);` and pass
|
|
31
|
+
`timeout: 30000`.
|
|
32
|
+
3. Read the result, `{ pass, failures, advisories, counts, skipped }`. Each failure
|
|
33
|
+
and advisory has `rule`, `path`, `detail` and a `link` to the node. Report every
|
|
34
|
+
failure with its link. Never edit the file to make it pass.
|
|
35
|
+
4. Only the named page is loaded, and tokens are the file's local variables. A file
|
|
36
|
+
whose variables all come from a published library fails as inert.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
const TARGET = { page: 'Templates', fileKey: '<figma.fileKey>' };
|
|
40
|
+
const SKIP = [];
|
|
41
|
+
// … the generated snippet …
|
|
42
|
+
return await runScreenGate(TARGET, SKIP);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## The snippet
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
// The adherence gate for Figma screens as a pure checker: detached components,
|
|
49
|
+
// raw colours a token has, and raw gaps, padding and radii a token has. ZERO
|
|
50
|
+
// imports, `export const`/`export function` only. It is inlined, with the shared
|
|
51
|
+
// variable reader and the screen reader below it, into the generated Figma script
|
|
52
|
+
// (references/figma-adherence.md) by build-figma-checks.mjs, so it must run in
|
|
53
|
+
// both Node and the Figma plugin sandbox.
|
|
54
|
+
//
|
|
55
|
+
// Spec: docs/specs/2026-10-13-figma-adherence-gate.md
|
|
56
|
+
// The code-side counterpart is scripts/validate-adherence.mjs. Both follow its
|
|
57
|
+
// spec's Decision 3: a raw value fails only when a token has it; any other raw
|
|
58
|
+
// value is an advisory.
|
|
59
|
+
//
|
|
60
|
+
// SNAPSHOT (what the reader returns):
|
|
61
|
+
// {
|
|
62
|
+
// fileKey, // for links; the caller passes it (TARGET.fileKey)
|
|
63
|
+
// nodes: [{
|
|
64
|
+
// id, path, // path: 'Page / Screen / … / Node'
|
|
65
|
+
// isRoot, // the screen frame itself
|
|
66
|
+
// inInstance, // an overridden node inside an INSTANCE (or the instance itself)
|
|
67
|
+
// underDetached, // a detached frame, or anything inside one
|
|
68
|
+
// overridden: ['fills' | 'strokes'], // inInstance nodes only
|
|
69
|
+
// paints: [{ kind: 'fill' | 'stroke', hex, bound, styled }], // visible SOLID paints only;
|
|
70
|
+
// // hex is '#rrggbb', or '#rrggbbaa' when the paint's opacity < 1
|
|
71
|
+
// mixedPaints, // fills is figma.mixed (text with mixed runs)
|
|
72
|
+
// gap: { value, bound, renders } | null,
|
|
73
|
+
// padding: [{ side, value, bound }],
|
|
74
|
+
// radius: [{ corner, value, bound }],
|
|
75
|
+
// detached: { kind: 'local' | 'library', name, id, key } | null,
|
|
76
|
+
// }],
|
|
77
|
+
// tokens: {
|
|
78
|
+
// colours: { '#rrggbb': [variableName] }, // every mode's value
|
|
79
|
+
// gap: { '<number>': [variableName] }, // FLOAT scoped GAP or ALL_SCOPES
|
|
80
|
+
// radius: { '<number>': [variableName] }, // FLOAT scoped CORNER_RADIUS or ALL_SCOPES
|
|
81
|
+
// },
|
|
82
|
+
// }
|
|
83
|
+
//
|
|
84
|
+
// RESULT: { pass, failures, advisories, counts, skipped }
|
|
85
|
+
// failures/advisories: [{ rule, id, path, detail, link }], sorted by path then detail
|
|
86
|
+
// counts: { [rule]: { checked, failed } }
|
|
87
|
+
// `pass` is false when any failure exists. A failed assertion is data; nothing throws.
|
|
88
|
+
|
|
89
|
+
const RULES = ['detached-component', 'raw-colour', 'raw-dimension'];
|
|
90
|
+
|
|
91
|
+
const PAINT_FIELD = { fill: 'fills', stroke: 'strokes' };
|
|
92
|
+
|
|
93
|
+
// Which dimension tables a number variable belongs in. An explicit scope decides.
|
|
94
|
+
// A variable left at ALL_SCOPES (Figma's default) counts only when its name says
|
|
95
|
+
// what it is: on Zygarden every number variable is ALL_SCOPES, and without the
|
|
96
|
+
// name test a 72 px gap failed with "bind text/7xl". Letter-spacing says "spacing"
|
|
97
|
+
// but is type, not layout.
|
|
98
|
+
const SPACING_WORDS = /spac|gap|inset|stack|inline|padding|gutter/i;
|
|
99
|
+
const NOT_SPACING = /letter/i;
|
|
100
|
+
const RADIUS_WORDS = /radius|corner|round/i;
|
|
101
|
+
function dimensionBuckets(name, scopes = []) {
|
|
102
|
+
const all = scopes.includes('ALL_SCOPES');
|
|
103
|
+
return {
|
|
104
|
+
gap: scopes.includes('GAP') || (all && SPACING_WORDS.test(name) && !NOT_SPACING.test(name)),
|
|
105
|
+
radius: scopes.includes('CORNER_RADIUS') || (all && RADIUS_WORDS.test(name)),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkScreens(snapshot, { skip = [] } = {}) {
|
|
110
|
+
const nodes = snapshot.nodes ?? [];
|
|
111
|
+
const tokens = snapshot.tokens ?? {};
|
|
112
|
+
const colours = tokens.colours ?? {};
|
|
113
|
+
const gapTokens = tokens.gap ?? {};
|
|
114
|
+
const radiusTokens = tokens.radius ?? {};
|
|
115
|
+
const on = (rule) => !skip.includes(rule);
|
|
116
|
+
const link = (id) => `https://www.figma.com/design/${snapshot.fileKey}?node-id=${String(id).replace(/:/g, '-')}`;
|
|
117
|
+
|
|
118
|
+
const failures = [];
|
|
119
|
+
const advisories = [];
|
|
120
|
+
const counts = Object.fromEntries(RULES.filter(on).map((rule) => [rule, { checked: 0, failed: 0 }]));
|
|
121
|
+
const entry = (rule, node, detail) => ({ rule, id: node.id, path: node.path, detail, link: link(node.id) });
|
|
122
|
+
const fail = (rule, node, detail) => {
|
|
123
|
+
counts[rule].failed += 1;
|
|
124
|
+
failures.push(entry(rule, node, detail));
|
|
125
|
+
};
|
|
126
|
+
const advise = (rule, node, detail) => advisories.push(entry(rule, node, detail));
|
|
127
|
+
|
|
128
|
+
// A bound value passes. A raw one fails when a token has it, naming the token;
|
|
129
|
+
// otherwise it is an advisory.
|
|
130
|
+
const raw = (rule, node, table, value, bound, what) => {
|
|
131
|
+
counts[rule].checked += 1;
|
|
132
|
+
if (bound) return;
|
|
133
|
+
const names = table[String(value)];
|
|
134
|
+
if (names && names.length) fail(rule, node, `${what}: bind ${names.join(', ')}`);
|
|
135
|
+
else advise(rule, node, rule === 'raw-colour' ? `${what}: no colour variable has this value` : `off-scale ${what}`);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
for (const node of nodes) {
|
|
139
|
+
if (on('detached-component')) {
|
|
140
|
+
counts['detached-component'].checked += 1;
|
|
141
|
+
const d = node.detached;
|
|
142
|
+
if (d) {
|
|
143
|
+
let from;
|
|
144
|
+
if (d.kind === 'library') from = `a library component (key ${d.key})`;
|
|
145
|
+
else if (d.name) from = d.name;
|
|
146
|
+
else from = `a local component on another page (id ${d.id})`;
|
|
147
|
+
fail('detached-component', node, `detached from ${from}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Inside a detached copy, the one detached-component failure is the finding:
|
|
152
|
+
// re-instancing fixes every raw value under it. On 01. Design System's
|
|
153
|
+
// Archtypes page, checking them anyway turned 8 detached frames into ~200 failures.
|
|
154
|
+
if (node.underDetached) continue;
|
|
155
|
+
|
|
156
|
+
if (on('raw-colour')) {
|
|
157
|
+
if (node.mixedPaints) advise('raw-colour', node, 'mixed paints not read');
|
|
158
|
+
for (const paint of node.paints ?? []) {
|
|
159
|
+
if (node.inInstance && !(node.overridden ?? []).includes(PAINT_FIELD[paint.kind])) continue;
|
|
160
|
+
raw('raw-colour', node, colours, paint.hex, paint.bound || paint.styled, `${paint.kind} ${paint.hex}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (on('raw-dimension') && !node.inInstance) {
|
|
165
|
+
const gap = node.gap;
|
|
166
|
+
if (gap && gap.renders && gap.value > 0) raw('raw-dimension', node, gapTokens, gap.value, gap.bound, `gap ${gap.value}`);
|
|
167
|
+
for (const p of node.padding ?? []) {
|
|
168
|
+
if (p.value > 0) raw('raw-dimension', node, gapTokens, p.value, p.bound, `padding-${p.side} ${p.value}`);
|
|
169
|
+
}
|
|
170
|
+
if (!node.isRoot) {
|
|
171
|
+
for (const r of node.radius ?? []) {
|
|
172
|
+
if (r.value > 0) raw('raw-dimension', node, radiusTokens, r.value, r.bound, `radius-${r.corner} ${r.value}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Every enabled rule must have had something to check (code gate Decision 7).
|
|
179
|
+
const inert = (rule, detail) => failures.push({ rule, id: null, path: '', detail, link: null });
|
|
180
|
+
if (nodes.length === 0) inert('nothing-scanned', 'no node was read; check the page and screen names');
|
|
181
|
+
if (on('raw-colour') && Object.keys(colours).length === 0) {
|
|
182
|
+
inert('colour-rule-inert', 'the file has no local colour variables, so no raw colour could be matched to one');
|
|
183
|
+
}
|
|
184
|
+
if (on('raw-dimension') && Object.keys(gapTokens).length === 0 && Object.keys(radiusTokens).length === 0) {
|
|
185
|
+
inert('dimension-rule-inert', 'the file has no local number variable for spacing or radius: none is scoped GAP or CORNER_RADIUS, and none left at ALL_SCOPES is named for spacing or radius');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
189
|
+
const byPath = (a, b) => cmp(a.path, b.path) || cmp(a.detail, b.detail);
|
|
190
|
+
failures.sort(byPath);
|
|
191
|
+
advisories.sort(byPath);
|
|
192
|
+
return { pass: failures.length === 0, failures, advisories, counts, skipped: RULES.filter((rule) => !on(rule)) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Figma plugin-API helpers for reading colour variables, shared by the Figma
|
|
196
|
+
// checks reader and the screen reader. This file is NOT a Node module:
|
|
197
|
+
// build-figma-checks.mjs inlines it into both generated scripts
|
|
198
|
+
// (references/figma-checks.md, references/figma-adherence.md). It depends on
|
|
199
|
+
// nothing else, so it carries its own mode-name fold. Async getters only
|
|
200
|
+
// (references/figma-scripting.md). Untested in Node, like the readers.
|
|
201
|
+
|
|
202
|
+
const foldModeName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
203
|
+
|
|
204
|
+
function colorHex(value) {
|
|
205
|
+
if (!value || typeof value !== 'object' || !('r' in value)) return null;
|
|
206
|
+
const byte = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
207
|
+
const alpha = value.a === undefined ? 1 : value.a;
|
|
208
|
+
return `#${byte(value.r)}${byte(value.g)}${byte(value.b)}${alpha < 1 ? byte(alpha) : ''}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The value of `variable` in the mode named `modeName` (after the fold), else
|
|
212
|
+
// its collection's default mode, following aliases up to 10 hops.
|
|
213
|
+
async function resolveColor(variable, modeName, hops) {
|
|
214
|
+
if (!variable || hops > 10) return null;
|
|
215
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
216
|
+
if (!collection) return null;
|
|
217
|
+
const mode = collection.modes.find((m) => foldModeName(m.name) === foldModeName(modeName));
|
|
218
|
+
const value = variable.valuesByMode[mode ? mode.modeId : collection.defaultModeId];
|
|
219
|
+
if (value && value.type === 'VARIABLE_ALIAS') {
|
|
220
|
+
return resolveColor(await figma.variables.getVariableByIdAsync(value.id), modeName, hops + 1);
|
|
221
|
+
}
|
|
222
|
+
return colorHex(value);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// { modes: { [modeName]: hex | null }, defaultMode } over the variable's own collection.
|
|
226
|
+
async function variableColors(variable) {
|
|
227
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
228
|
+
if (!collection) return { modes: {}, defaultMode: undefined };
|
|
229
|
+
const modes = {};
|
|
230
|
+
for (const mode of collection.modes) modes[mode.name] = await resolveColor(variable, mode.name, 0);
|
|
231
|
+
const fallback = collection.modes.find((m) => m.modeId === collection.defaultModeId);
|
|
232
|
+
return { modes, defaultMode: fallback ? fallback.name : undefined };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Figma plugin-API reader for the screen adherence gate. This file is NOT a Node
|
|
236
|
+
// module — build-figma-checks.mjs concatenates it after the inlined
|
|
237
|
+
// figma-adherence.mjs and figma-variables-read.figma.js into
|
|
238
|
+
// references/figma-adherence.md, and the result runs inside figma_execute.
|
|
239
|
+
// Constraints honored here (see references/figma-scripting.md): async getters
|
|
240
|
+
// only; only the target page is loaded; overridden nodes are found inside their
|
|
241
|
+
// instance, never with getNodeByIdAsync in a loop (that stalled the bridge on a
|
|
242
|
+
// large file). Read-only: nothing here writes to the file. Untested in Node
|
|
243
|
+
// beyond the generator's snippet test; the pure checker it feeds is unit-tested.
|
|
244
|
+
//
|
|
245
|
+
// In-scope globals when assembled: checkScreens and dimensionBuckets (figma-adherence.mjs),
|
|
246
|
+
// colorHex and variableColors (figma-variables-read.figma.js), and the caller-filled slots
|
|
247
|
+
// TARGET = { page, screens?, fileKey } and SKIP = [rule, ...]. The snapshot shape is
|
|
248
|
+
// documented at the top of scripts/lib/figma-adherence.mjs.
|
|
249
|
+
|
|
250
|
+
const CORNERS = [
|
|
251
|
+
['topLeftRadius', 'top-left'],
|
|
252
|
+
['topRightRadius', 'top-right'],
|
|
253
|
+
['bottomRightRadius', 'bottom-right'],
|
|
254
|
+
['bottomLeftRadius', 'bottom-left'],
|
|
255
|
+
];
|
|
256
|
+
const SIDES = [
|
|
257
|
+
['paddingTop', 'top'],
|
|
258
|
+
['paddingRight', 'right'],
|
|
259
|
+
['paddingBottom', 'bottom'],
|
|
260
|
+
['paddingLeft', 'left'],
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
function solidPaints(node, field, kind) {
|
|
264
|
+
const list = node[field];
|
|
265
|
+
if (!Array.isArray(list)) return [];
|
|
266
|
+
const bound = (node.boundVariables && node.boundVariables[field]) || [];
|
|
267
|
+
const styleId = field === 'fills' ? node.fillStyleId : node.strokeStyleId;
|
|
268
|
+
const styled = typeof styleId === 'string' && styleId !== '';
|
|
269
|
+
const out = [];
|
|
270
|
+
list.forEach((paint, i) => {
|
|
271
|
+
if (paint.visible === false || paint.type !== 'SOLID') return;
|
|
272
|
+
// The paint's opacity goes into the hex the way colorHex writes a translucent
|
|
273
|
+
// variable, so a 50% black matches a 50% black token and not the opaque one.
|
|
274
|
+
const opacity = paint.opacity === undefined ? 1 : paint.opacity;
|
|
275
|
+
out.push({
|
|
276
|
+
kind,
|
|
277
|
+
hex: colorHex({ r: paint.color.r, g: paint.color.g, b: paint.color.b, a: opacity }),
|
|
278
|
+
bound: Boolean(bound[i]),
|
|
279
|
+
styled,
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function gapOf(node) {
|
|
286
|
+
if (!node.layoutMode || node.layoutMode === 'NONE') return null;
|
|
287
|
+
const inFlow = (node.children || []).filter((c) => c.visible !== false && c.layoutPositioning !== 'ABSOLUTE');
|
|
288
|
+
return {
|
|
289
|
+
value: node.itemSpacing,
|
|
290
|
+
bound: Boolean(node.boundVariables && node.boundVariables.itemSpacing),
|
|
291
|
+
renders: inFlow.length >= 2 && node.primaryAxisAlignItems !== 'SPACE_BETWEEN',
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function paddingOf(node) {
|
|
296
|
+
if (!node.layoutMode || node.layoutMode === 'NONE') return [];
|
|
297
|
+
const bound = node.boundVariables || {};
|
|
298
|
+
return SIDES.map(([field, side]) => ({ side, value: node[field], bound: Boolean(bound[field]) }));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function radiusOf(node) {
|
|
302
|
+
if (!('cornerRadius' in node)) return [];
|
|
303
|
+
const bound = node.boundVariables || {};
|
|
304
|
+
if (node.cornerRadius !== figma.mixed) {
|
|
305
|
+
return [{ corner: 'all', value: node.cornerRadius, bound: Boolean(bound.topLeftRadius) }];
|
|
306
|
+
}
|
|
307
|
+
return CORNERS.map(([field, corner]) => ({ corner, value: node[field], bound: Boolean(bound[field]) }));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function detachedOf(node, page) {
|
|
311
|
+
let info = null;
|
|
312
|
+
try {
|
|
313
|
+
info = node.detachedInfo;
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
if (!info) return null;
|
|
318
|
+
if (info.type === 'library') return { kind: 'library', name: null, id: null, key: info.componentKey };
|
|
319
|
+
const main = page.findOne((n) => n.id === info.componentId);
|
|
320
|
+
return { kind: 'local', name: main ? main.name : null, id: info.componentId, key: null };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function overriddenRecords(instance, path, underDetached) {
|
|
324
|
+
const records = [];
|
|
325
|
+
for (const entry of instance.overrides || []) {
|
|
326
|
+
const fields = entry.overriddenFields.filter((f) => f === 'fills' || f === 'strokes');
|
|
327
|
+
if (!fields.length) continue;
|
|
328
|
+
const node = entry.id === instance.id ? instance : instance.findOne((n) => n.id === entry.id);
|
|
329
|
+
if (!node) continue;
|
|
330
|
+
records.push({
|
|
331
|
+
id: node.id,
|
|
332
|
+
path: node === instance ? path : `${path} / ${node.name}`,
|
|
333
|
+
isRoot: false,
|
|
334
|
+
inInstance: true,
|
|
335
|
+
underDetached,
|
|
336
|
+
overridden: fields,
|
|
337
|
+
paints: [...solidPaints(node, 'fills', 'fill'), ...solidPaints(node, 'strokes', 'stroke')],
|
|
338
|
+
mixedPaints: false,
|
|
339
|
+
gap: null,
|
|
340
|
+
padding: [],
|
|
341
|
+
radius: [],
|
|
342
|
+
detached: null,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return records;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function screensOn(page, names) {
|
|
349
|
+
const all = [];
|
|
350
|
+
for (const top of page.children) {
|
|
351
|
+
if (top.type === 'FRAME') all.push(top);
|
|
352
|
+
else if (top.type === 'SECTION') for (const c of top.children) if (c.type === 'FRAME') all.push(c);
|
|
353
|
+
}
|
|
354
|
+
if (!names || !names.length) return all;
|
|
355
|
+
return names.map((name) => {
|
|
356
|
+
const hit = all.find((s) => s.name === name);
|
|
357
|
+
if (!hit) throw new Error(`no screen named "${name}" on page "${page.name}"`);
|
|
358
|
+
return hit;
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function readTokens() {
|
|
363
|
+
const colours = {};
|
|
364
|
+
for (const variable of await figma.variables.getLocalVariablesAsync('COLOR')) {
|
|
365
|
+
const { modes } = await variableColors(variable);
|
|
366
|
+
for (const hex of Object.values(modes)) {
|
|
367
|
+
if (!hex) continue;
|
|
368
|
+
const names = colours[hex] || (colours[hex] = []);
|
|
369
|
+
if (!names.includes(variable.name)) names.push(variable.name);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const gap = {};
|
|
373
|
+
const radius = {};
|
|
374
|
+
const add = (table, value, name) => {
|
|
375
|
+
if (typeof value !== 'number') return;
|
|
376
|
+
const names = table[String(value)] || (table[String(value)] = []);
|
|
377
|
+
if (!names.includes(name)) names.push(name);
|
|
378
|
+
};
|
|
379
|
+
for (const variable of await figma.variables.getLocalVariablesAsync('FLOAT')) {
|
|
380
|
+
const buckets = dimensionBuckets(variable.name, variable.scopes || []);
|
|
381
|
+
for (const value of Object.values(variable.valuesByMode)) {
|
|
382
|
+
if (buckets.gap) add(gap, value, variable.name);
|
|
383
|
+
if (buckets.radius) add(radius, value, variable.name);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { colours, gap, radius };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Exact name first. Page names often carry decoration nobody types (01. Design
|
|
390
|
+
// System's are " ↳ Templates"), so a unique match on the trimmed name also counts.
|
|
391
|
+
function findPage(name) {
|
|
392
|
+
const exact = figma.root.children.find((p) => p.name === name);
|
|
393
|
+
if (exact) return exact;
|
|
394
|
+
const trimmed = figma.root.children.filter((p) => p.name.trim() === String(name).trim());
|
|
395
|
+
if (trimmed.length === 1) return trimmed[0];
|
|
396
|
+
throw new Error(`no page named "${name}"`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function readScreens(target) {
|
|
400
|
+
const page = findPage(target.page);
|
|
401
|
+
await page.loadAsync();
|
|
402
|
+
const screens = screensOn(page, target.screens);
|
|
403
|
+
const nodes = [];
|
|
404
|
+
const walk = (node, path, isRoot, insideDetached) => {
|
|
405
|
+
if (node.visible === false) return;
|
|
406
|
+
if (node.type === 'INSTANCE') {
|
|
407
|
+
nodes.push(...overriddenRecords(node, path, insideDetached));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const mixedPaints = node.fills === figma.mixed;
|
|
411
|
+
const detached = detachedOf(node, page);
|
|
412
|
+
const underDetached = insideDetached || detached !== null;
|
|
413
|
+
nodes.push({
|
|
414
|
+
id: node.id,
|
|
415
|
+
path,
|
|
416
|
+
isRoot,
|
|
417
|
+
inInstance: false,
|
|
418
|
+
underDetached,
|
|
419
|
+
overridden: [],
|
|
420
|
+
paints: [...solidPaints(node, 'fills', 'fill'), ...solidPaints(node, 'strokes', 'stroke')],
|
|
421
|
+
mixedPaints,
|
|
422
|
+
gap: gapOf(node),
|
|
423
|
+
padding: paddingOf(node),
|
|
424
|
+
radius: radiusOf(node),
|
|
425
|
+
detached,
|
|
426
|
+
});
|
|
427
|
+
for (const child of node.children || []) walk(child, `${path} / ${child.name}`, false, underDetached);
|
|
428
|
+
};
|
|
429
|
+
for (const screen of screens) walk(screen, `${page.name.trim()} / ${screen.name}`, true, false);
|
|
430
|
+
return {
|
|
431
|
+
// The caller passes the fileKey from design-system.json: figma.fileKey is only
|
|
432
|
+
// exposed to private plugins.
|
|
433
|
+
fileKey: target.fileKey || figma.fileKey,
|
|
434
|
+
nodes,
|
|
435
|
+
tokens: await readTokens(),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function runScreenGate(TARGET, SKIP) {
|
|
440
|
+
return checkScreens(await readScreens(TARGET), { skip: SKIP || [] });
|
|
441
|
+
}
|
|
442
|
+
```
|