@radicool/throughline 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/adapters/codex/AGENTS.md +1 -0
- package/adapters/codex/prompts/check-figma.md +41 -0
- package/adapters/codex/prompts/component-builder.md +8 -4
- package/adapters/cursor/.cursor/commands/check-figma.md +41 -0
- package/adapters/cursor/.cursor/rules/component-builder.mdc +8 -4
- package/adapters/generic/AGENTS.md +1 -0
- package/adapters/generic/commands/check-figma.md +41 -0
- package/adapters/generic/skills/component-builder/SKILL.md +8 -4
- package/package.json +1 -1
- package/references/figma-adherence.md +442 -0
- package/references/figma-checks.md +432 -40
- package/references/figma-component-standards.md +8 -0
- package/references/proof-bundle.md +1 -0
- package/scripts/README.md +7 -4
- package/scripts/build-figma-checks.mjs +139 -28
- package/scripts/install.mjs +5 -1
- package/scripts/lib/contrast.mjs +5 -2
- package/scripts/lib/figma-adherence-read.figma.js +207 -0
- package/scripts/lib/figma-adherence.mjs +146 -0
- package/scripts/lib/figma-checks-read.figma.js +68 -14
- package/scripts/lib/figma-checks.mjs +173 -17
- package/scripts/lib/figma-variables-read.figma.js +39 -0
- package/scripts/lib/fixtures/figma-adherence/clean.json +90 -0
- package/scripts/lib/fixtures/figma-adherence/detached-library.json +116 -0
- package/scripts/lib/fixtures/figma-adherence/ds01-gaps.json +84 -0
- package/scripts/lib/fixtures/figma-adherence/inert-all.json +45 -0
- package/scripts/lib/fixtures/figma-adherence/mixed-text.json +61 -0
- package/scripts/lib/fixtures/figma-adherence/nothing-scanned.json +49 -0
- package/scripts/lib/fixtures/figma-adherence/off-scale-gap.json +65 -0
- package/scripts/lib/fixtures/figma-adherence/override-raw.json +72 -0
- package/scripts/lib/fixtures/figma-adherence/padding-raw.json +74 -0
- package/scripts/lib/fixtures/figma-adherence/raw-colour-token.json +70 -0
- package/scripts/lib/fixtures/figma-adherence/root-radius.json +88 -0
- package/scripts/lib/fixtures/figma-adherence/skip-colour.json +86 -0
- package/scripts/lib/fixtures/figma-adherence/zygarden-override-bound.json +85 -0
- package/scripts/lib/fixtures/figma-adherence/zygarden-text-raw.json +208 -0
- package/scripts/lib/fixtures/figma-checks/avatar-not-in-table.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ios-swift-skipped.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-missing-default.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-mui-ring.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-no-focus-variant.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-plain-frames.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ring-child-not-absolute.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ring-hidden.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ring-unbound-colour.json +4 -3
- package/scripts/lib/fixtures/figma-checks/button-shadcn-filled.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-sparse.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-spread-zero.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-transparent-ring-child.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-vanilla-inside.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-vanilla-outside.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-wrong-combination.json +2 -1
- package/scripts/lib/fixtures/figma-checks/checkbox-checked-axis.real.json +2 -1
- package/scripts/lib/fixtures/figma-checks/checkbox-checked-synonym.json +2 -1
- package/scripts/lib/fixtures/figma-checks/checkbox-missing-selected.json +2 -1
- package/scripts/lib/fixtures/figma-checks/indicator-failed.json +106 -0
- package/scripts/lib/fixtures/figma-checks/input-control-layer-missing.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-control-layer-ring-on-wrapper.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-control-layer.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-mui-weight-unbound.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-mui.json +2 -1
- package/scripts/lib/fixtures/figma-checks/ring-single-mode.json +112 -0
- package/scripts/lib/fixtures/figma-checks/ring-translucent.json +111 -0
- package/scripts/lib/fixtures/figma-checks/sample-button.real.json +2 -1
- package/scripts/lib/fixtures/figma-checks/sample-ring-on-muted-raw.json +113 -0
- package/scripts/lib/fixtures/figma-checks/sample-ring-passes.json +169 -0
- package/scripts/lib/fixtures/figma-checks/surface-missing.json +108 -0
- package/scripts/lib/fixtures/figma-checks/surface-single-mode.json +112 -0
- package/scripts/lib/fixtures/figma-checks/surface-translucent.json +112 -0
- package/scripts/lib/fixtures/figma-checks/zygarden-ring-light-fails.json +113 -0
package/scripts/README.md
CHANGED
|
@@ -23,15 +23,18 @@ 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) |
|
|
26
|
+
| `lib/figma-checks.mjs` | Pure checker for the Figma stop conditions: the COMPONENT_SET assertion against the full matrix, the state matrix, the focus ring, and the focus ring's 3:1 contrast against the surfaces it's named to sit on, plus the focus-framework rules. Inlined into `references/figma-checks.md`; unit-tested against `lib/fixtures/figma-checks/`. | plugin-internal (not installed) |
|
|
27
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) |
|
|
28
|
+
| `lib/figma-variables-read.figma.js` | Figma helpers that resolve a colour variable's value in each mode, through aliases. Inlined into both `references/figma-checks.md` and `references/figma-adherence.md`; never executed as a module. | plugin-internal (not installed) |
|
|
29
|
+
| `lib/figma-adherence.mjs` | Pure checker for the Figma screen adherence gate: detached components, and raw colours, gaps, padding and radii that a variable has. A raw value no variable has is an advisory. Inlined into `references/figma-adherence.md`; unit-tested against `lib/fixtures/figma-adherence/`. | plugin-internal (not installed) |
|
|
30
|
+
| `lib/figma-adherence-read.figma.js` | Figma reader that turns the screens on one page into the adherence checker's snapshot JSON, read-only. Inlined into `references/figma-adherence.md`; never executed as a module. | plugin-internal (not installed) |
|
|
28
31
|
| `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 |
|
|
29
32
|
| `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` |
|
|
30
33
|
| `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` |
|
|
31
34
|
| `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` |
|
|
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
|
|
35
|
+
| `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. Also inlined into `references/figma-checks.md` for the focus-ring contrast check. | copied alongside `verify-check.mjs`; also inlined into the generated Figma checks |
|
|
33
36
|
| `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`
|
|
37
|
+
| `build-figma-checks.mjs` | Generate `references/figma-checks.md`, the `figma_execute` snippet for the Figma stop conditions, by inlining `lib/component-states.mjs`, `lib/contrast.mjs`, `lib/figma-checks.mjs` and `lib/figma-variables-read.figma.js` above the reader (`lib/figma-checks-read.figma.js`); and `references/figma-adherence.md`, the screen adherence gate, from `lib/figma-adherence.mjs`, `lib/figma-variables-read.figma.js` and `lib/figma-adherence-read.figma.js`. `--check` gates CI on both. | plugin-internal (not installed) |
|
|
35
38
|
| `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) |
|
|
36
39
|
| `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) |
|
|
37
40
|
|
|
@@ -249,7 +252,7 @@ the committed `adapters/<target>/` tree plus the runtime payload
|
|
|
249
252
|
(`references/` + `scripts/`, minus `scripts/adapters/`) into `.throughline/`,
|
|
250
253
|
rewriting `${CLAUDE_PLUGIN_ROOT}` → `.throughline`:
|
|
251
254
|
|
|
252
|
-
npx @radicool/throughline@0.
|
|
255
|
+
npx @radicool/throughline@0.25.0 init --target=cursor|codex|generic
|
|
253
256
|
|
|
254
257
|
See `scripts/install.mjs` (pure core + CLI + `install.test.mjs`).
|
|
255
258
|
|
|
@@ -1,53 +1,85 @@
|
|
|
1
|
-
// Generates
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Generates the two canonical figma_execute snippets by inlining tested Node
|
|
2
|
+
// modules above their Figma readers:
|
|
3
|
+
// references/figma-checks.md — the Figma stop conditions (COMPONENT_SET
|
|
4
|
+
// assertion, state matrix, focus ring, focus-ring contrast): the state table
|
|
5
|
+
// (lib/component-states.mjs), the contrast maths (lib/contrast.mjs) and the pure
|
|
6
|
+
// checker (lib/figma-checks.mjs), then lib/figma-variables-read.figma.js and the
|
|
7
|
+
// reader (lib/figma-checks-read.figma.js).
|
|
8
|
+
// references/figma-adherence.md — the screen adherence gate: the pure checker
|
|
9
|
+
// (lib/figma-adherence.mjs), then lib/figma-variables-read.figma.js and the
|
|
10
|
+
// screen reader (lib/figma-adherence-read.figma.js).
|
|
11
|
+
// Mirrors build-doc-card-builder.mjs: run bare to write both, run with --check to
|
|
12
|
+
// gate CI on both. Zero dependencies.
|
|
7
13
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
8
14
|
import { join, dirname } from 'node:path';
|
|
9
15
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
16
|
|
|
11
17
|
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
18
|
const STATES = join(REPO_ROOT, 'scripts', 'lib', 'component-states.mjs');
|
|
19
|
+
const CONTRAST = join(REPO_ROOT, 'scripts', 'lib', 'contrast.mjs');
|
|
13
20
|
const CHECKER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks.mjs');
|
|
14
21
|
const READER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks-read.figma.js');
|
|
22
|
+
const VARIABLES = join(REPO_ROOT, 'scripts', 'lib', 'figma-variables-read.figma.js');
|
|
23
|
+
const ADHERENCE = join(REPO_ROOT, 'scripts', 'lib', 'figma-adherence.mjs');
|
|
24
|
+
const ADHERENCE_READER = join(REPO_ROOT, 'scripts', 'lib', 'figma-adherence-read.figma.js');
|
|
15
25
|
const OUT = join(REPO_ROOT, 'references', 'figma-checks.md');
|
|
26
|
+
const ADHERENCE_OUT = join(REPO_ROOT, 'references', 'figma-adherence.md');
|
|
16
27
|
|
|
17
|
-
const BIND = 'const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName });';
|
|
28
|
+
const BIND = 'const { checkSpec, checkBuilt, checkExisting } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });';
|
|
18
29
|
|
|
19
30
|
const HEADER = [
|
|
20
31
|
'# Figma stop-condition checks (GENERATED)',
|
|
21
32
|
'',
|
|
22
33
|
'> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/component-states.mjs`',
|
|
23
|
-
'> (the state table), `scripts/lib/
|
|
24
|
-
'>
|
|
34
|
+
'> (the state table), `scripts/lib/contrast.mjs` (the WCAG contrast maths),',
|
|
35
|
+
'> `scripts/lib/figma-checks.mjs` (the pure checker, unit-tested in Node) +',
|
|
36
|
+
'> `scripts/lib/figma-checks-read.figma.js` (the Figma reader). Regenerate with',
|
|
25
37
|
'> `node scripts/build-figma-checks.mjs`; CI gates freshness with `--check`.',
|
|
26
38
|
'',
|
|
27
39
|
'The canonical `figma_execute` snippet for the checks that can stop a component',
|
|
28
40
|
'build: the COMPONENT_SET assertion against the full variant matrix, the state',
|
|
29
|
-
'matrix,
|
|
30
|
-
'
|
|
41
|
+
'matrix, the focus ring, and the focus ring\'s contrast against the surfaces it',
|
|
42
|
+
'sits on (WCAG 1.4.11, 3:1, in every mode). The script is the rule; do not re-write',
|
|
43
|
+
'these checks by hand.',
|
|
31
44
|
'',
|
|
32
45
|
'## How to call it',
|
|
33
46
|
'',
|
|
34
47
|
'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',
|
|
48
|
+
' - `const EXPECT = { name, matrix, uiFramework, controlLayer, surfaces }` — `name` is the',
|
|
36
49
|
' component\'s real name; `matrix` is `{ axis: [values] }` for every axis of the',
|
|
37
50
|
' set; `uiFramework` is `project.uiFramework` from `design-system.json`;',
|
|
38
51
|
' `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
|
-
'
|
|
52
|
+
' copied from the architect\'s spec when it gives one (omit it otherwise);',
|
|
53
|
+
' `surfaces` is the list of colour-variable names of the page surfaces the',
|
|
54
|
+
' component sits on, copied from the architect\'s spec. Without it the',
|
|
55
|
+
' focus-contrast check is skipped. Surfaces are looked up among the file\'s',
|
|
56
|
+
' local variables only.',
|
|
57
|
+
' - `const MODE = \'spec\'`, `\'built\'` or `\'existing\'`.',
|
|
41
58
|
'2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass',
|
|
42
59
|
' `timeout: 30000`.',
|
|
43
60
|
'3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is',
|
|
44
61
|
' `pass`, `fail` or `skip`; `pass` ignores skips. On `fail`, fix what each failing',
|
|
45
62
|
' check\'s `detail` names and run it again, within the ~3-pass budget; after that,',
|
|
46
|
-
' `BLOCKED` naming the failing checks\' `detail`.',
|
|
63
|
+
' `BLOCKED` naming the failing checks\' `detail`. A `focus-contrast` fail is',
|
|
64
|
+
' the exception: return `BLOCKED` at once, naming its detail. A ring below 3:1 is',
|
|
65
|
+
' fixed by a token value, which no rebuild changes, and a missing surface is a',
|
|
66
|
+
' wrong name in the spec. Never edit a variable to make it pass.',
|
|
47
67
|
'4. `\'spec\'` reads no node and runs before the first write. `\'built\'` reads the',
|
|
48
68
|
' COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs',
|
|
49
69
|
' in the self-verify loop. It throws only if that frame is missing; a frame with',
|
|
50
70
|
' no set, or a set Figma reports as conflicting, fails the structural check.',
|
|
71
|
+
'5. `\'existing\'` checks a set already in the library, read-only: the first',
|
|
72
|
+
' COMPONENT_SET whose name matches `name` ignoring case and punctuation. Add',
|
|
73
|
+
' `page` to `EXPECT` (the page holding the components) so only that page is',
|
|
74
|
+
' loaded; without it every page loads in turn, which on a large file can stall',
|
|
75
|
+
' the bridge. `matrix` is not needed. There is no spec to copy `controlLayer` and',
|
|
76
|
+
' `surfaces` from, so the caller names them: `surfaces` as for a build, and',
|
|
77
|
+
' `controlLayer` for a wrapped control whose ring sits on an inner layer (without',
|
|
78
|
+
' it, a correct ring on an inner layer reads as missing). It runs the state,',
|
|
79
|
+
' focus-ring and focus-contrast checks against the set\'s own variants, not the',
|
|
80
|
+
' structural check. A set whose focus state is drawn some other way, such as an',
|
|
81
|
+
' INSIDE stroke, fails the focus-ring check and skips contrast. It throws when no',
|
|
82
|
+
' set has that name.',
|
|
51
83
|
'',
|
|
52
84
|
'```js',
|
|
53
85
|
'const EXPECT = { name: \'Chip\', matrix: { state: [\'default\', \'hover\'] }, uiFramework: \'shadcn\' };',
|
|
@@ -61,38 +93,117 @@ const HEADER = [
|
|
|
61
93
|
'```js',
|
|
62
94
|
].join('\n');
|
|
63
95
|
|
|
96
|
+
const ADHERENCE_HEADER = [
|
|
97
|
+
'# Figma screen adherence gate (GENERATED)',
|
|
98
|
+
'',
|
|
99
|
+
'> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/figma-adherence.mjs`',
|
|
100
|
+
'> (the pure checker, unit-tested in Node), `scripts/lib/figma-variables-read.figma.js`',
|
|
101
|
+
'> (colour-variable resolution) + `scripts/lib/figma-adherence-read.figma.js` (the',
|
|
102
|
+
'> screen reader). Regenerate with `node scripts/build-figma-checks.mjs`; CI gates',
|
|
103
|
+
'> freshness with `--check`.',
|
|
104
|
+
'',
|
|
105
|
+
'The canonical `figma_execute` snippet that checks Figma screens against the design',
|
|
106
|
+
'system, read-only. It fails a frame detached from a component, a raw colour that a',
|
|
107
|
+
'colour variable has (opacity included), and a raw gap, padding or corner radius',
|
|
108
|
+
'that a spacing or radius variable has, naming the variable to bind. A number',
|
|
109
|
+
'variable counts by its scope (`GAP`, `CORNER_RADIUS`), or, left at `ALL_SCOPES`,',
|
|
110
|
+
'by a name that says spacing or radius. A raw value no variable has is an',
|
|
111
|
+
'advisory, not a failure. A rule that had nothing to compare against fails by name',
|
|
112
|
+
'(`nothing-scanned`, `colour-rule-inert`, `dimension-rule-inert`). The script is the',
|
|
113
|
+
'rule; do not re-write it by hand.',
|
|
114
|
+
'',
|
|
115
|
+
'## How to call it',
|
|
116
|
+
'',
|
|
117
|
+
'1. Paste the snippet below into `figma_execute`, with the two slots filled above it:',
|
|
118
|
+
' - `const TARGET = { page, screens, fileKey }` — `page` is the page name (an exact',
|
|
119
|
+
' match, or else the one page whose name matches once leading and trailing',
|
|
120
|
+
' spaces are trimmed); `screens` is an optional list of screen names (top-level',
|
|
121
|
+
' frames, or frames directly inside a top-level section), and without it every',
|
|
122
|
+
' screen on the page is read; `fileKey` is `figma.fileKey` from',
|
|
123
|
+
' `design-system.json`, used for the links.',
|
|
124
|
+
' - `const SKIP = []` — rule names to switch off (`detached-component`,',
|
|
125
|
+
' `raw-colour`, `raw-dimension`); a skipped rule\'s inert check is skipped too.',
|
|
126
|
+
'2. End the script with `return await runScreenGate(TARGET, SKIP);` and pass',
|
|
127
|
+
' `timeout: 30000`.',
|
|
128
|
+
'3. Read the result, `{ pass, failures, advisories, counts, skipped }`. Each failure',
|
|
129
|
+
' and advisory has `rule`, `path`, `detail` and a `link` to the node. Report every',
|
|
130
|
+
' failure with its link. Never edit the file to make it pass.',
|
|
131
|
+
'4. Only the named page is loaded, and tokens are the file\'s local variables. A file',
|
|
132
|
+
' whose variables all come from a published library fails as inert.',
|
|
133
|
+
'',
|
|
134
|
+
'```js',
|
|
135
|
+
'const TARGET = { page: \'Templates\', fileKey: \'<figma.fileKey>\' };',
|
|
136
|
+
'const SKIP = [];',
|
|
137
|
+
'// … the generated snippet …',
|
|
138
|
+
'return await runScreenGate(TARGET, SKIP);',
|
|
139
|
+
'```',
|
|
140
|
+
'',
|
|
141
|
+
'## The snippet',
|
|
142
|
+
'',
|
|
143
|
+
'```js',
|
|
144
|
+
].join('\n');
|
|
145
|
+
|
|
64
146
|
const FOOTER = ['```', ''].join('\n');
|
|
65
147
|
|
|
66
148
|
const stripExports = (src) =>
|
|
67
149
|
src.replace(/^export const /gm, 'const ').replace(/^export function /gm, 'function ');
|
|
68
150
|
|
|
69
|
-
|
|
70
|
-
const
|
|
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]]) {
|
|
151
|
+
function assertInlinable(sources) {
|
|
152
|
+
for (const [name, src] of sources) {
|
|
73
153
|
if (/^\s*(import|export)\b/m.test(src)) {
|
|
74
154
|
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
155
|
}
|
|
76
156
|
}
|
|
77
|
-
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function buildFigmaChecks({ statesSource, contrastSource, checkerSource, variablesSource, readerSource }) {
|
|
160
|
+
const states = stripExports(statesSource);
|
|
161
|
+
const contrast = stripExports(contrastSource);
|
|
162
|
+
const checker = stripExports(checkerSource);
|
|
163
|
+
assertInlinable([['component-states.mjs', states], ['contrast.mjs', contrast], ['figma-checks.mjs', checker], ['figma-variables-read.figma.js', variablesSource], ['figma-checks-read.figma.js', readerSource]]);
|
|
164
|
+
return `${HEADER}\n${states}\n${contrast}\n${checker}\n${BIND}\n\n${variablesSource}\n${readerSource}${FOOTER}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function buildFigmaAdherence({ checkerSource, variablesSource, readerSource }) {
|
|
168
|
+
const checker = stripExports(checkerSource);
|
|
169
|
+
assertInlinable([['figma-adherence.mjs', checker], ['figma-variables-read.figma.js', variablesSource], ['figma-adherence-read.figma.js', readerSource]]);
|
|
170
|
+
return `${ADHERENCE_HEADER}\n${checker}\n${variablesSource}\n${readerSource}${FOOTER}`;
|
|
78
171
|
}
|
|
79
172
|
|
|
80
173
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
81
174
|
const result = buildFigmaChecks({
|
|
82
175
|
statesSource: readFileSync(STATES, 'utf8'),
|
|
176
|
+
contrastSource: readFileSync(CONTRAST, 'utf8'),
|
|
83
177
|
checkerSource: readFileSync(CHECKER, 'utf8'),
|
|
178
|
+
variablesSource: readFileSync(VARIABLES, 'utf8'),
|
|
84
179
|
readerSource: readFileSync(READER, 'utf8'),
|
|
85
180
|
});
|
|
181
|
+
const adherence = buildFigmaAdherence({
|
|
182
|
+
checkerSource: readFileSync(ADHERENCE, 'utf8'),
|
|
183
|
+
variablesSource: readFileSync(VARIABLES, 'utf8'),
|
|
184
|
+
readerSource: readFileSync(ADHERENCE_READER, 'utf8'),
|
|
185
|
+
});
|
|
186
|
+
const outputs = [
|
|
187
|
+
[OUT, result, 'references/figma-checks.md', 'figma checks'],
|
|
188
|
+
[ADHERENCE_OUT, adherence, 'references/figma-adherence.md', 'figma adherence'],
|
|
189
|
+
];
|
|
86
190
|
if (process.argv.includes('--check')) {
|
|
87
|
-
let
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
191
|
+
let stale = false;
|
|
192
|
+
for (const [path, content, label, what] of outputs) {
|
|
193
|
+
let onDisk = null;
|
|
194
|
+
try { onDisk = readFileSync(path, 'utf8'); } catch { /* missing counts as drift */ }
|
|
195
|
+
if (onDisk !== content) {
|
|
196
|
+
console.error(`✗ ${label} out of date; run: node scripts/build-figma-checks.mjs`);
|
|
197
|
+
stale = true;
|
|
198
|
+
} else {
|
|
199
|
+
console.log(`✓ ${what} in sync`);
|
|
200
|
+
}
|
|
92
201
|
}
|
|
93
|
-
|
|
202
|
+
if (stale) process.exit(1);
|
|
94
203
|
} else {
|
|
95
|
-
|
|
96
|
-
|
|
204
|
+
for (const [path, content, label] of outputs) {
|
|
205
|
+
writeFileSync(path, content);
|
|
206
|
+
console.log(`✓ wrote ${label}`);
|
|
207
|
+
}
|
|
97
208
|
}
|
|
98
209
|
}
|
package/scripts/install.mjs
CHANGED
|
@@ -102,7 +102,8 @@ function stagePayload(srcRoot, destRoot, skip) {
|
|
|
102
102
|
// Scripts that never run from a consuming repo: the installer itself, the
|
|
103
103
|
// reference-doc generators, and the Figma sources they inline (the doc-card
|
|
104
104
|
// renderer and the Figma checks reach Figma pre-inlined inside
|
|
105
|
-
// references/doc-card-builder.md
|
|
105
|
+
// references/doc-card-builder.md, references/figma-checks.md and
|
|
106
|
+
// references/figma-adherence.md). Test fixtures
|
|
106
107
|
// under lib/fixtures/ are dropped too.
|
|
107
108
|
const PLUGIN_INTERNAL = new Set([
|
|
108
109
|
'install.mjs',
|
|
@@ -112,6 +113,9 @@ const PLUGIN_INTERNAL = new Set([
|
|
|
112
113
|
'build-figma-checks.mjs',
|
|
113
114
|
'lib/figma-checks-read.figma.js',
|
|
114
115
|
'lib/figma-checks.mjs',
|
|
116
|
+
'lib/figma-variables-read.figma.js',
|
|
117
|
+
'lib/figma-adherence.mjs',
|
|
118
|
+
'lib/figma-adherence-read.figma.js',
|
|
115
119
|
]);
|
|
116
120
|
|
|
117
121
|
export const skipScript = (relPosix) =>
|
package/scripts/lib/contrast.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// MAINTENANCE: a change to the semantic role set must change this table.
|
|
7
7
|
|
|
8
8
|
export const AA_NORMAL_TEXT = 4.5;
|
|
9
|
+
// WCAG 1.4.11 non-text contrast, used by the Figma `focus-contrast` check.
|
|
10
|
+
export const AA_NON_TEXT = 3;
|
|
9
11
|
|
|
10
12
|
// `text/disabled` is excluded: WCAG 1.4.3 exempts inactive user-interface
|
|
11
13
|
// components from the contrast minimum, and a disabled role is deliberately
|
|
@@ -15,8 +17,9 @@ export const AA_NORMAL_TEXT = 4.5;
|
|
|
15
17
|
// questions) — a system is free to use those roles for non-text surfaces, so
|
|
16
18
|
// pairing them risks the first false failures in the table.
|
|
17
19
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
+
// This table is text-on-surface only. Focus-ring contrast, the 3:1 non-text case
|
|
21
|
+
// (WCAG 1.4.11), is checked in the Figma checks (`figma-checks.mjs`,
|
|
22
|
+
// `focus-contrast`), where the build knows which ring sits on which surface.
|
|
20
23
|
//
|
|
21
24
|
// One threshold is applied to every pair because a colour role does not say
|
|
22
25
|
// what text size it is used at — `text/primary` is body copy as well as
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Figma plugin-API reader for the screen adherence gate. This file is NOT a Node
|
|
2
|
+
// module — build-figma-checks.mjs concatenates it after the inlined
|
|
3
|
+
// figma-adherence.mjs and figma-variables-read.figma.js into
|
|
4
|
+
// references/figma-adherence.md, and the result runs inside figma_execute.
|
|
5
|
+
// Constraints honored here (see references/figma-scripting.md): async getters
|
|
6
|
+
// only; only the target page is loaded; overridden nodes are found inside their
|
|
7
|
+
// instance, never with getNodeByIdAsync in a loop (that stalled the bridge on a
|
|
8
|
+
// large file). Read-only: nothing here writes to the file. Untested in Node
|
|
9
|
+
// beyond the generator's snippet test; the pure checker it feeds is unit-tested.
|
|
10
|
+
//
|
|
11
|
+
// In-scope globals when assembled: checkScreens and dimensionBuckets (figma-adherence.mjs),
|
|
12
|
+
// colorHex and variableColors (figma-variables-read.figma.js), and the caller-filled slots
|
|
13
|
+
// TARGET = { page, screens?, fileKey } and SKIP = [rule, ...]. The snapshot shape is
|
|
14
|
+
// documented at the top of scripts/lib/figma-adherence.mjs.
|
|
15
|
+
|
|
16
|
+
const CORNERS = [
|
|
17
|
+
['topLeftRadius', 'top-left'],
|
|
18
|
+
['topRightRadius', 'top-right'],
|
|
19
|
+
['bottomRightRadius', 'bottom-right'],
|
|
20
|
+
['bottomLeftRadius', 'bottom-left'],
|
|
21
|
+
];
|
|
22
|
+
const SIDES = [
|
|
23
|
+
['paddingTop', 'top'],
|
|
24
|
+
['paddingRight', 'right'],
|
|
25
|
+
['paddingBottom', 'bottom'],
|
|
26
|
+
['paddingLeft', 'left'],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function solidPaints(node, field, kind) {
|
|
30
|
+
const list = node[field];
|
|
31
|
+
if (!Array.isArray(list)) return [];
|
|
32
|
+
const bound = (node.boundVariables && node.boundVariables[field]) || [];
|
|
33
|
+
const styleId = field === 'fills' ? node.fillStyleId : node.strokeStyleId;
|
|
34
|
+
const styled = typeof styleId === 'string' && styleId !== '';
|
|
35
|
+
const out = [];
|
|
36
|
+
list.forEach((paint, i) => {
|
|
37
|
+
if (paint.visible === false || paint.type !== 'SOLID') return;
|
|
38
|
+
// The paint's opacity goes into the hex the way colorHex writes a translucent
|
|
39
|
+
// variable, so a 50% black matches a 50% black token and not the opaque one.
|
|
40
|
+
const opacity = paint.opacity === undefined ? 1 : paint.opacity;
|
|
41
|
+
out.push({
|
|
42
|
+
kind,
|
|
43
|
+
hex: colorHex({ r: paint.color.r, g: paint.color.g, b: paint.color.b, a: opacity }),
|
|
44
|
+
bound: Boolean(bound[i]),
|
|
45
|
+
styled,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function gapOf(node) {
|
|
52
|
+
if (!node.layoutMode || node.layoutMode === 'NONE') return null;
|
|
53
|
+
const inFlow = (node.children || []).filter((c) => c.visible !== false && c.layoutPositioning !== 'ABSOLUTE');
|
|
54
|
+
return {
|
|
55
|
+
value: node.itemSpacing,
|
|
56
|
+
bound: Boolean(node.boundVariables && node.boundVariables.itemSpacing),
|
|
57
|
+
renders: inFlow.length >= 2 && node.primaryAxisAlignItems !== 'SPACE_BETWEEN',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function paddingOf(node) {
|
|
62
|
+
if (!node.layoutMode || node.layoutMode === 'NONE') return [];
|
|
63
|
+
const bound = node.boundVariables || {};
|
|
64
|
+
return SIDES.map(([field, side]) => ({ side, value: node[field], bound: Boolean(bound[field]) }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function radiusOf(node) {
|
|
68
|
+
if (!('cornerRadius' in node)) return [];
|
|
69
|
+
const bound = node.boundVariables || {};
|
|
70
|
+
if (node.cornerRadius !== figma.mixed) {
|
|
71
|
+
return [{ corner: 'all', value: node.cornerRadius, bound: Boolean(bound.topLeftRadius) }];
|
|
72
|
+
}
|
|
73
|
+
return CORNERS.map(([field, corner]) => ({ corner, value: node[field], bound: Boolean(bound[field]) }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function detachedOf(node, page) {
|
|
77
|
+
let info = null;
|
|
78
|
+
try {
|
|
79
|
+
info = node.detachedInfo;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (!info) return null;
|
|
84
|
+
if (info.type === 'library') return { kind: 'library', name: null, id: null, key: info.componentKey };
|
|
85
|
+
const main = page.findOne((n) => n.id === info.componentId);
|
|
86
|
+
return { kind: 'local', name: main ? main.name : null, id: info.componentId, key: null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function overriddenRecords(instance, path, underDetached) {
|
|
90
|
+
const records = [];
|
|
91
|
+
for (const entry of instance.overrides || []) {
|
|
92
|
+
const fields = entry.overriddenFields.filter((f) => f === 'fills' || f === 'strokes');
|
|
93
|
+
if (!fields.length) continue;
|
|
94
|
+
const node = entry.id === instance.id ? instance : instance.findOne((n) => n.id === entry.id);
|
|
95
|
+
if (!node) continue;
|
|
96
|
+
records.push({
|
|
97
|
+
id: node.id,
|
|
98
|
+
path: node === instance ? path : `${path} / ${node.name}`,
|
|
99
|
+
isRoot: false,
|
|
100
|
+
inInstance: true,
|
|
101
|
+
underDetached,
|
|
102
|
+
overridden: fields,
|
|
103
|
+
paints: [...solidPaints(node, 'fills', 'fill'), ...solidPaints(node, 'strokes', 'stroke')],
|
|
104
|
+
mixedPaints: false,
|
|
105
|
+
gap: null,
|
|
106
|
+
padding: [],
|
|
107
|
+
radius: [],
|
|
108
|
+
detached: null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return records;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function screensOn(page, names) {
|
|
115
|
+
const all = [];
|
|
116
|
+
for (const top of page.children) {
|
|
117
|
+
if (top.type === 'FRAME') all.push(top);
|
|
118
|
+
else if (top.type === 'SECTION') for (const c of top.children) if (c.type === 'FRAME') all.push(c);
|
|
119
|
+
}
|
|
120
|
+
if (!names || !names.length) return all;
|
|
121
|
+
return names.map((name) => {
|
|
122
|
+
const hit = all.find((s) => s.name === name);
|
|
123
|
+
if (!hit) throw new Error(`no screen named "${name}" on page "${page.name}"`);
|
|
124
|
+
return hit;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readTokens() {
|
|
129
|
+
const colours = {};
|
|
130
|
+
for (const variable of await figma.variables.getLocalVariablesAsync('COLOR')) {
|
|
131
|
+
const { modes } = await variableColors(variable);
|
|
132
|
+
for (const hex of Object.values(modes)) {
|
|
133
|
+
if (!hex) continue;
|
|
134
|
+
const names = colours[hex] || (colours[hex] = []);
|
|
135
|
+
if (!names.includes(variable.name)) names.push(variable.name);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const gap = {};
|
|
139
|
+
const radius = {};
|
|
140
|
+
const add = (table, value, name) => {
|
|
141
|
+
if (typeof value !== 'number') return;
|
|
142
|
+
const names = table[String(value)] || (table[String(value)] = []);
|
|
143
|
+
if (!names.includes(name)) names.push(name);
|
|
144
|
+
};
|
|
145
|
+
for (const variable of await figma.variables.getLocalVariablesAsync('FLOAT')) {
|
|
146
|
+
const buckets = dimensionBuckets(variable.name, variable.scopes || []);
|
|
147
|
+
for (const value of Object.values(variable.valuesByMode)) {
|
|
148
|
+
if (buckets.gap) add(gap, value, variable.name);
|
|
149
|
+
if (buckets.radius) add(radius, value, variable.name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { colours, gap, radius };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Exact name first. Page names often carry decoration nobody types (01. Design
|
|
156
|
+
// System's are " ↳ Templates"), so a unique match on the trimmed name also counts.
|
|
157
|
+
function findPage(name) {
|
|
158
|
+
const exact = figma.root.children.find((p) => p.name === name);
|
|
159
|
+
if (exact) return exact;
|
|
160
|
+
const trimmed = figma.root.children.filter((p) => p.name.trim() === String(name).trim());
|
|
161
|
+
if (trimmed.length === 1) return trimmed[0];
|
|
162
|
+
throw new Error(`no page named "${name}"`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function readScreens(target) {
|
|
166
|
+
const page = findPage(target.page);
|
|
167
|
+
await page.loadAsync();
|
|
168
|
+
const screens = screensOn(page, target.screens);
|
|
169
|
+
const nodes = [];
|
|
170
|
+
const walk = (node, path, isRoot, insideDetached) => {
|
|
171
|
+
if (node.visible === false) return;
|
|
172
|
+
if (node.type === 'INSTANCE') {
|
|
173
|
+
nodes.push(...overriddenRecords(node, path, insideDetached));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const mixedPaints = node.fills === figma.mixed;
|
|
177
|
+
const detached = detachedOf(node, page);
|
|
178
|
+
const underDetached = insideDetached || detached !== null;
|
|
179
|
+
nodes.push({
|
|
180
|
+
id: node.id,
|
|
181
|
+
path,
|
|
182
|
+
isRoot,
|
|
183
|
+
inInstance: false,
|
|
184
|
+
underDetached,
|
|
185
|
+
overridden: [],
|
|
186
|
+
paints: [...solidPaints(node, 'fills', 'fill'), ...solidPaints(node, 'strokes', 'stroke')],
|
|
187
|
+
mixedPaints,
|
|
188
|
+
gap: gapOf(node),
|
|
189
|
+
padding: paddingOf(node),
|
|
190
|
+
radius: radiusOf(node),
|
|
191
|
+
detached,
|
|
192
|
+
});
|
|
193
|
+
for (const child of node.children || []) walk(child, `${path} / ${child.name}`, false, underDetached);
|
|
194
|
+
};
|
|
195
|
+
for (const screen of screens) walk(screen, `${page.name.trim()} / ${screen.name}`, true, false);
|
|
196
|
+
return {
|
|
197
|
+
// The caller passes the fileKey from design-system.json: figma.fileKey is only
|
|
198
|
+
// exposed to private plugins.
|
|
199
|
+
fileKey: target.fileKey || figma.fileKey,
|
|
200
|
+
nodes,
|
|
201
|
+
tokens: await readTokens(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function runScreenGate(TARGET, SKIP) {
|
|
206
|
+
return checkScreens(await readScreens(TARGET), { skip: SKIP || [] });
|
|
207
|
+
}
|