@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.
Files changed (31) hide show
  1. package/README.md +6 -4
  2. package/adapters/codex/AGENTS.md +1 -0
  3. package/adapters/codex/prompts/check-figma.md +41 -0
  4. package/adapters/cursor/.cursor/commands/check-figma.md +41 -0
  5. package/adapters/generic/AGENTS.md +1 -0
  6. package/adapters/generic/commands/check-figma.md +41 -0
  7. package/package.json +1 -1
  8. package/references/figma-adherence.md +442 -0
  9. package/references/figma-checks.md +106 -43
  10. package/scripts/README.md +5 -2
  11. package/scripts/build-figma-checks.mjs +121 -23
  12. package/scripts/install.mjs +5 -1
  13. package/scripts/lib/figma-adherence-read.figma.js +207 -0
  14. package/scripts/lib/figma-adherence.mjs +146 -0
  15. package/scripts/lib/figma-checks-read.figma.js +43 -43
  16. package/scripts/lib/figma-checks.mjs +16 -5
  17. package/scripts/lib/figma-variables-read.figma.js +39 -0
  18. package/scripts/lib/fixtures/figma-adherence/clean.json +90 -0
  19. package/scripts/lib/fixtures/figma-adherence/detached-library.json +116 -0
  20. package/scripts/lib/fixtures/figma-adherence/ds01-gaps.json +84 -0
  21. package/scripts/lib/fixtures/figma-adherence/inert-all.json +45 -0
  22. package/scripts/lib/fixtures/figma-adherence/mixed-text.json +61 -0
  23. package/scripts/lib/fixtures/figma-adherence/nothing-scanned.json +49 -0
  24. package/scripts/lib/fixtures/figma-adherence/off-scale-gap.json +65 -0
  25. package/scripts/lib/fixtures/figma-adherence/override-raw.json +72 -0
  26. package/scripts/lib/fixtures/figma-adherence/padding-raw.json +74 -0
  27. package/scripts/lib/fixtures/figma-adherence/raw-colour-token.json +70 -0
  28. package/scripts/lib/fixtures/figma-adherence/root-radius.json +88 -0
  29. package/scripts/lib/fixtures/figma-adherence/skip-colour.json +86 -0
  30. package/scripts/lib/fixtures/figma-adherence/zygarden-override-bound.json +85 -0
  31. 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 `'built'`.
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; the architect names the colour variables of the surfaces the component sits on',
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.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
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
- return { checkSpec, checkBuilt };
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
- const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });
657
+ return { checkSpec, checkBuilt, checkExisting };
658
+ }
638
659
 
639
- // Figma plugin-API reader for the stop-condition checks. This file is NOT a
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
- // Colour variables read on focus nodes, by name, for the contrast check.
657
- const readVariables = new Map();
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
- async function variableName(alias) {
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) => normalizeName(m.name) === normalizeName(modeName));
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
- async function readComponentSet(expect) {
756
- readVariables.clear();
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
- // A frame holding plain FRAMEs where the set should be is a failed assertion,
763
- // not a missing target: report what is there so the structural check names it.
764
- if (!set) {
765
- const first = (frame.children || [])[0];
766
- return { type: first ? first.type : 'NONE', variantGroupProperties: {}, children: [] };
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') return checkBuilt(await readComponentSet(EXPECT), EXPECT);
811
- throw new Error(`MODE must be 'spec' or 'built', got ${JSON.stringify(MODE)}`);
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-checks.mjs` above the reader (`lib/figma-checks-read.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
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.24.0 init --target=cursor|codex|generic
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 references/figma-checks.md — the canonical figma_execute snippet
2
- // that runs the Figma stop conditions (COMPONENT_SET assertion, state matrix,
3
- // focus ring, focus-ring contrast) — by inlining the state table
4
- // (lib/component-states.mjs), the contrast maths (lib/contrast.mjs) and the
5
- // pure checker (lib/figma-checks.mjs) above the Figma reader
6
- // (lib/figma-checks-read.figma.js). Mirrors build-doc-card-builder.mjs: run
7
- // bare to write, run with --check to gate CI. Zero dependencies.
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 `\'built\'`.',
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
- export function buildFigmaChecks({ statesSource, contrastSource, checkerSource, readerSource }) {
81
- const states = stripExports(statesSource);
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
- return `${HEADER}\n${states}\n${contrast}\n${checker}\n${BIND}\n\n${readerSource}${FOOTER}`;
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 onDisk = null;
101
- try { onDisk = readFileSync(OUT, 'utf8'); } catch { /* missing counts as drift */ }
102
- if (onDisk !== result) {
103
- console.error('✗ references/figma-checks.md out of date; run: node scripts/build-figma-checks.mjs');
104
- process.exit(1);
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
- console.log('✓ figma checks in sync');
202
+ if (stale) process.exit(1);
107
203
  } else {
108
- writeFileSync(OUT, result);
109
- console.log('✓ wrote references/figma-checks.md');
204
+ for (const [path, content, label] of outputs) {
205
+ writeFileSync(path, content);
206
+ console.log(`✓ wrote ${label}`);
207
+ }
110
208
  }
111
209
  }
@@ -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 and references/figma-checks.md). Test fixtures
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) =>