@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
|
@@ -24,7 +24,7 @@ these checks by hand.
|
|
|
24
24
|
component sits on, copied from the architect's spec. Without it the
|
|
25
25
|
focus-contrast check is skipped. Surfaces are looked up among the file's
|
|
26
26
|
local variables only.
|
|
27
|
-
- `const MODE = 'spec'` or `'
|
|
27
|
+
- `const MODE = 'spec'`, `'built'` or `'existing'`.
|
|
28
28
|
2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass
|
|
29
29
|
`timeout: 30000`.
|
|
30
30
|
3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is
|
|
@@ -38,6 +38,18 @@ these checks by hand.
|
|
|
38
38
|
COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs
|
|
39
39
|
in the self-verify loop. It throws only if that frame is missing; a frame with
|
|
40
40
|
no set, or a set Figma reports as conflicting, fails the structural check.
|
|
41
|
+
5. `'existing'` checks a set already in the library, read-only: the first
|
|
42
|
+
COMPONENT_SET whose name matches `name` ignoring case and punctuation. Add
|
|
43
|
+
`page` to `EXPECT` (the page holding the components) so only that page is
|
|
44
|
+
loaded; without it every page loads in turn, which on a large file can stall
|
|
45
|
+
the bridge. `matrix` is not needed. There is no spec to copy `controlLayer` and
|
|
46
|
+
`surfaces` from, so the caller names them: `surfaces` as for a build, and
|
|
47
|
+
`controlLayer` for a wrapped control whose ring sits on an inner layer (without
|
|
48
|
+
it, a correct ring on an inner layer reads as missing). It runs the state,
|
|
49
|
+
focus-ring and focus-contrast checks against the set's own variants, not the
|
|
50
|
+
structural check. A set whose focus state is drawn some other way, such as an
|
|
51
|
+
INSIDE stroke, fails the focus-ring check and skips contrast. It throws when no
|
|
52
|
+
set has that name.
|
|
41
53
|
|
|
42
54
|
```js
|
|
43
55
|
const EXPECT = { name: 'Chip', matrix: { state: ['default', 'hover'] }, uiFramework: 'shadcn' };
|
|
@@ -259,7 +271,7 @@ function composite(fgHex, bgHex) {
|
|
|
259
271
|
// The state table is not imported. `withTable({ resolveArchetype, BASELINE_STATES,
|
|
260
272
|
// STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT })`
|
|
261
273
|
// takes it, the fold and the contrast maths from the caller and returns
|
|
262
|
-
// { checkSpec, checkBuilt }. In the generated script the caller passes the
|
|
274
|
+
// { checkSpec, checkBuilt, checkExisting }. In the generated script the caller passes the
|
|
263
275
|
// inlined component-states.mjs names; in tests it passes the module's own exports.
|
|
264
276
|
//
|
|
265
277
|
// Spec: docs/specs/2026-10-07-figma-checks-as-scripts.md
|
|
@@ -296,7 +308,8 @@ function composite(fgHex, bgHex) {
|
|
|
296
308
|
//
|
|
297
309
|
// RESULT: { pass, checks: [{ name, result: 'pass' | 'fail' | 'skip', detail }] }
|
|
298
310
|
// built mode returns four checks: structural-read-back, state-baseline,
|
|
299
|
-
// focus-indicator, focus-contrast.
|
|
311
|
+
// focus-indicator, focus-contrast. existing mode (a set already in the library,
|
|
312
|
+
// docs/specs/2026-10-13-figma-adherence-gate.md) returns the last three.
|
|
300
313
|
// `pass` ignores skips. A failed assertion is data; nothing here throws.
|
|
301
314
|
|
|
302
315
|
// The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
|
|
@@ -531,7 +544,7 @@ function withTable({
|
|
|
531
544
|
return {
|
|
532
545
|
name,
|
|
533
546
|
result: 'skip',
|
|
534
|
-
detail: 'no surfaces named;
|
|
547
|
+
detail: 'no surfaces named; name the colour variables of the surfaces the component sits on (from the architect spec, or ask the user)',
|
|
535
548
|
};
|
|
536
549
|
}
|
|
537
550
|
const missing = snapshot.missingSurfaces ?? [];
|
|
@@ -619,9 +632,11 @@ function withTable({
|
|
|
619
632
|
return result([stateBaseline(expect, entries)]);
|
|
620
633
|
}
|
|
621
634
|
|
|
635
|
+
const variantEntries = (snapshot) => (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
|
|
636
|
+
|
|
622
637
|
// After the build: the node as read.
|
|
623
638
|
function checkBuilt(snapshot, expect) {
|
|
624
|
-
const entries = (snapshot
|
|
639
|
+
const entries = variantEntries(snapshot);
|
|
625
640
|
const indicator = focusIndicator(snapshot, expect);
|
|
626
641
|
return result([
|
|
627
642
|
structural(snapshot, expect),
|
|
@@ -631,38 +646,27 @@ function withTable({
|
|
|
631
646
|
]);
|
|
632
647
|
}
|
|
633
648
|
|
|
634
|
-
|
|
635
|
-
|
|
649
|
+
// An existing set has no spec matrix, so the structural check is not run; the
|
|
650
|
+
// other three read only the set itself.
|
|
651
|
+
function checkExisting(snapshot, expect) {
|
|
652
|
+
const entries = variantEntries(snapshot);
|
|
653
|
+
const indicator = focusIndicator(snapshot, expect);
|
|
654
|
+
return result([stateBaseline(expect, entries), indicator, focusContrast(snapshot, expect, indicator)]);
|
|
655
|
+
}
|
|
636
656
|
|
|
637
|
-
|
|
657
|
+
return { checkSpec, checkBuilt, checkExisting };
|
|
658
|
+
}
|
|
638
659
|
|
|
639
|
-
|
|
640
|
-
// Node module — build-figma-checks.mjs concatenates it after the inlined
|
|
641
|
-
// component-states.mjs, contrast.mjs and figma-checks.mjs into
|
|
642
|
-
// references/figma-checks.md, and
|
|
643
|
-
// the result runs inside figma_execute (dynamic-page mode). Constraints honored
|
|
644
|
-
// here (see references/figma-scripting.md): async getters only, variable names
|
|
645
|
-
// and per-mode colour values resolved with getVariableByIdAsync and only for
|
|
646
|
-
// focus variants and their controlLayer, plus the EXPECT.surfaces names, which
|
|
647
|
-
// are looked up among the file's LOCAL colour variables only (a surface from a
|
|
648
|
-
// published library reads as missing). Untested in Node, like the doc-card
|
|
649
|
-
// renderer; the pure checker it feeds is unit-tested.
|
|
650
|
-
//
|
|
651
|
-
// In-scope globals when assembled: normalizeName (inlined
|
|
652
|
-
// component-states.mjs), checkSpec, checkBuilt (bound from withTable) and the
|
|
653
|
-
// caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
|
|
654
|
-
// shape is documented at the top of scripts/lib/figma-checks.mjs.
|
|
660
|
+
const { checkSpec, checkBuilt, checkExisting } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });
|
|
655
661
|
|
|
656
|
-
//
|
|
657
|
-
|
|
662
|
+
// Figma plugin-API helpers for reading colour variables, shared by the Figma
|
|
663
|
+
// checks reader and the screen reader. This file is NOT a Node module:
|
|
664
|
+
// build-figma-checks.mjs inlines it into both generated scripts
|
|
665
|
+
// (references/figma-checks.md, references/figma-adherence.md). It depends on
|
|
666
|
+
// nothing else, so it carries its own mode-name fold. Async getters only
|
|
667
|
+
// (references/figma-scripting.md). Untested in Node, like the readers.
|
|
658
668
|
|
|
659
|
-
|
|
660
|
-
if (!alias || !alias.id) return null;
|
|
661
|
-
const variable = await figma.variables.getVariableByIdAsync(alias.id);
|
|
662
|
-
if (!variable) return null;
|
|
663
|
-
if (variable.resolvedType === 'COLOR') readVariables.set(variable.name, variable);
|
|
664
|
-
return variable.name;
|
|
665
|
-
}
|
|
669
|
+
const foldModeName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
666
670
|
|
|
667
671
|
function colorHex(value) {
|
|
668
672
|
if (!value || typeof value !== 'object' || !('r' in value)) return null;
|
|
@@ -677,7 +681,7 @@ async function resolveColor(variable, modeName, hops) {
|
|
|
677
681
|
if (!variable || hops > 10) return null;
|
|
678
682
|
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
679
683
|
if (!collection) return null;
|
|
680
|
-
const mode = collection.modes.find((m) =>
|
|
684
|
+
const mode = collection.modes.find((m) => foldModeName(m.name) === foldModeName(modeName));
|
|
681
685
|
const value = variable.valuesByMode[mode ? mode.modeId : collection.defaultModeId];
|
|
682
686
|
if (value && value.type === 'VARIABLE_ALIAS') {
|
|
683
687
|
return resolveColor(await figma.variables.getVariableByIdAsync(value.id), modeName, hops + 1);
|
|
@@ -695,6 +699,35 @@ async function variableColors(variable) {
|
|
|
695
699
|
return { modes, defaultMode: fallback ? fallback.name : undefined };
|
|
696
700
|
}
|
|
697
701
|
|
|
702
|
+
// Figma plugin-API reader for the stop-condition checks. This file is NOT a
|
|
703
|
+
// Node module — build-figma-checks.mjs concatenates it after the inlined
|
|
704
|
+
// component-states.mjs, contrast.mjs, figma-checks.mjs and
|
|
705
|
+
// figma-variables-read.figma.js into references/figma-checks.md, and
|
|
706
|
+
// the result runs inside figma_execute (dynamic-page mode). Constraints honored
|
|
707
|
+
// here (see references/figma-scripting.md): async getters only, variable names
|
|
708
|
+
// and per-mode colour values resolved with getVariableByIdAsync and only for
|
|
709
|
+
// focus variants and their controlLayer, plus the EXPECT.surfaces names, which
|
|
710
|
+
// are looked up among the file's LOCAL colour variables only (a surface from a
|
|
711
|
+
// published library reads as missing). Untested in Node, like the doc-card
|
|
712
|
+
// renderer; the pure checker it feeds is unit-tested.
|
|
713
|
+
//
|
|
714
|
+
// In-scope globals when assembled: normalizeName (inlined
|
|
715
|
+
// component-states.mjs), variableColors (figma-variables-read.figma.js),
|
|
716
|
+
// checkSpec, checkBuilt, checkExisting (bound from withTable) and the
|
|
717
|
+
// caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
|
|
718
|
+
// shape is documented at the top of scripts/lib/figma-checks.mjs.
|
|
719
|
+
|
|
720
|
+
// Colour variables read on focus nodes, by name, for the contrast check.
|
|
721
|
+
const readVariables = new Map();
|
|
722
|
+
|
|
723
|
+
async function variableName(alias) {
|
|
724
|
+
if (!alias || !alias.id) return null;
|
|
725
|
+
const variable = await figma.variables.getVariableByIdAsync(alias.id);
|
|
726
|
+
if (!variable) return null;
|
|
727
|
+
if (variable.resolvedType === 'COLOR') readVariables.set(variable.name, variable);
|
|
728
|
+
return variable.name;
|
|
729
|
+
}
|
|
730
|
+
|
|
698
731
|
// The fields the focus check reads from a variant, a control layer or a direct child.
|
|
699
732
|
async function readFocusNode(node, { withEffects }) {
|
|
700
733
|
const strokes = (node.boundVariables && node.boundVariables.strokes) || [];
|
|
@@ -752,19 +785,45 @@ function groupProperties(set) {
|
|
|
752
785
|
}
|
|
753
786
|
}
|
|
754
787
|
|
|
755
|
-
|
|
756
|
-
|
|
788
|
+
// The set a build just made: the COMPONENT_SET in the `WIP: <name>` frame on the
|
|
789
|
+
// current page. A frame holding plain FRAMEs where the set should be is a failed
|
|
790
|
+
// assertion, not a missing target, so it comes back as `{ empty }` for the
|
|
791
|
+
// structural check to name.
|
|
792
|
+
async function findWipSet(expect) {
|
|
757
793
|
await figma.currentPage.loadAsync();
|
|
758
794
|
const frameName = `WIP: ${expect.name}`;
|
|
759
795
|
const frame = figma.currentPage.findOne((n) => n.name === frameName);
|
|
760
796
|
if (!frame) throw new Error(`no node named "${frameName}" on the current page`);
|
|
761
797
|
const set = frame.type === 'COMPONENT_SET' ? frame : (frame.children || []).find((c) => c.type === 'COMPONENT_SET');
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
798
|
+
if (set) return { set };
|
|
799
|
+
const first = (frame.children || [])[0];
|
|
800
|
+
return { empty: { type: first ? first.type : 'NONE', variantGroupProperties: {}, children: [] } };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// A set already in the library: the first COMPONENT_SET whose name folds to the
|
|
804
|
+
// same name. With `pageName`, only that page is loaded (exact name, or a unique
|
|
805
|
+
// match on the trimmed name). Without it every page is loaded in turn until one
|
|
806
|
+
// has the set, which on a miss is the whole file; a whole-file load stalled the
|
|
807
|
+
// bridge on a large file, so callers pass the page.
|
|
808
|
+
async function findExistingSet(name, pageName) {
|
|
809
|
+
const want = normalizeName(name);
|
|
810
|
+
let pages = figma.root.children;
|
|
811
|
+
if (pageName !== undefined && pageName !== null) {
|
|
812
|
+
const exact = pages.filter((p) => p.name === pageName);
|
|
813
|
+
const trimmed = pages.filter((p) => p.name.trim() === String(pageName).trim());
|
|
814
|
+
pages = exact.length ? exact : trimmed.length === 1 ? trimmed : [];
|
|
815
|
+
if (!pages.length) throw new Error(`no page named "${pageName}"`);
|
|
767
816
|
}
|
|
817
|
+
for (const page of pages) {
|
|
818
|
+
await page.loadAsync();
|
|
819
|
+
const set = page.findAllWithCriteria({ types: ['COMPONENT_SET'] }).find((s) => normalizeName(s.name) === want);
|
|
820
|
+
if (set) return set;
|
|
821
|
+
}
|
|
822
|
+
throw new Error(`no component set named "${name}"${pageName ? ` on page "${pageName}"` : ' in this file'}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function readSet(set, expect) {
|
|
826
|
+
readVariables.clear();
|
|
768
827
|
|
|
769
828
|
const isFocusState = (props) =>
|
|
770
829
|
Object.entries(props || {}).some(([k, v]) => {
|
|
@@ -807,7 +866,11 @@ async function readComponentSet(expect) {
|
|
|
807
866
|
|
|
808
867
|
async function runFigmaChecks(EXPECT, MODE) {
|
|
809
868
|
if (MODE === 'spec') return checkSpec(EXPECT);
|
|
810
|
-
if (MODE === 'built')
|
|
811
|
-
|
|
869
|
+
if (MODE === 'built') {
|
|
870
|
+
const { set, empty } = await findWipSet(EXPECT);
|
|
871
|
+
return checkBuilt(set ? await readSet(set, EXPECT) : empty, EXPECT);
|
|
872
|
+
}
|
|
873
|
+
if (MODE === 'existing') return checkExisting(await readSet(await findExistingSet(EXPECT.name, EXPECT.page), EXPECT), EXPECT);
|
|
874
|
+
throw new Error(`MODE must be 'spec', 'built' or 'existing', got ${JSON.stringify(MODE)}`);
|
|
812
875
|
}
|
|
813
876
|
```
|
package/scripts/README.md
CHANGED
|
@@ -25,13 +25,16 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
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
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
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`, `lib/contrast.mjs` and `lib/figma-
|
|
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,10 +1,15 @@
|
|
|
1
|
-
// Generates
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// (lib/figma-checks-read.figma.js
|
|
7
|
-
//
|
|
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.
|
|
8
13
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
9
14
|
import { join, dirname } from 'node:path';
|
|
10
15
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -14,9 +19,13 @@ const STATES = join(REPO_ROOT, 'scripts', 'lib', 'component-states.mjs');
|
|
|
14
19
|
const CONTRAST = join(REPO_ROOT, 'scripts', 'lib', 'contrast.mjs');
|
|
15
20
|
const CHECKER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks.mjs');
|
|
16
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');
|
|
17
25
|
const OUT = join(REPO_ROOT, 'references', 'figma-checks.md');
|
|
26
|
+
const ADHERENCE_OUT = join(REPO_ROOT, 'references', 'figma-adherence.md');
|
|
18
27
|
|
|
19
|
-
const BIND = 'const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });';
|
|
28
|
+
const BIND = 'const { checkSpec, checkBuilt, checkExisting } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });';
|
|
20
29
|
|
|
21
30
|
const HEADER = [
|
|
22
31
|
'# Figma stop-condition checks (GENERATED)',
|
|
@@ -45,7 +54,7 @@ const HEADER = [
|
|
|
45
54
|
' component sits on, copied from the architect\'s spec. Without it the',
|
|
46
55
|
' focus-contrast check is skipped. Surfaces are looked up among the file\'s',
|
|
47
56
|
' local variables only.',
|
|
48
|
-
' - `const MODE = \'spec\'` or `\'
|
|
57
|
+
' - `const MODE = \'spec\'`, `\'built\'` or `\'existing\'`.',
|
|
49
58
|
'2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass',
|
|
50
59
|
' `timeout: 30000`.',
|
|
51
60
|
'3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is',
|
|
@@ -59,6 +68,18 @@ const HEADER = [
|
|
|
59
68
|
' COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs',
|
|
60
69
|
' in the self-verify loop. It throws only if that frame is missing; a frame with',
|
|
61
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.',
|
|
62
83
|
'',
|
|
63
84
|
'```js',
|
|
64
85
|
'const EXPECT = { name: \'Chip\', matrix: { state: [\'default\', \'hover\'] }, uiFramework: \'shadcn\' };',
|
|
@@ -72,21 +93,81 @@ const HEADER = [
|
|
|
72
93
|
'```js',
|
|
73
94
|
].join('\n');
|
|
74
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
|
+
|
|
75
146
|
const FOOTER = ['```', ''].join('\n');
|
|
76
147
|
|
|
77
148
|
const stripExports = (src) =>
|
|
78
149
|
src.replace(/^export const /gm, 'const ').replace(/^export function /gm, 'function ');
|
|
79
150
|
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const contrast = stripExports(contrastSource);
|
|
83
|
-
const checker = stripExports(checkerSource);
|
|
84
|
-
for (const [name, src] of [['component-states.mjs', states], ['contrast.mjs', contrast], ['figma-checks.mjs', checker], ['figma-checks-read.figma.js', readerSource]]) {
|
|
151
|
+
function assertInlinable(sources) {
|
|
152
|
+
for (const [name, src] of sources) {
|
|
85
153
|
if (/^\s*(import|export)\b/m.test(src)) {
|
|
86
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`);
|
|
87
155
|
}
|
|
88
156
|
}
|
|
89
|
-
|
|
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}`;
|
|
90
171
|
}
|
|
91
172
|
|
|
92
173
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
@@ -94,18 +175,35 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
94
175
|
statesSource: readFileSync(STATES, 'utf8'),
|
|
95
176
|
contrastSource: readFileSync(CONTRAST, 'utf8'),
|
|
96
177
|
checkerSource: readFileSync(CHECKER, 'utf8'),
|
|
178
|
+
variablesSource: readFileSync(VARIABLES, 'utf8'),
|
|
97
179
|
readerSource: readFileSync(READER, 'utf8'),
|
|
98
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
|
+
];
|
|
99
190
|
if (process.argv.includes('--check')) {
|
|
100
|
-
let
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
}
|
|
105
201
|
}
|
|
106
|
-
|
|
202
|
+
if (stale) process.exit(1);
|
|
107
203
|
} else {
|
|
108
|
-
|
|
109
|
-
|
|
204
|
+
for (const [path, content, label] of outputs) {
|
|
205
|
+
writeFileSync(path, content);
|
|
206
|
+
console.log(`✓ wrote ${label}`);
|
|
207
|
+
}
|
|
110
208
|
}
|
|
111
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) =>
|