@radicool/throughline 0.24.0 → 0.26.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 (62) hide show
  1. package/README.md +8 -4
  2. package/adapters/codex/AGENTS.md +2 -0
  3. package/adapters/codex/prompts/check-figma.md +41 -0
  4. package/adapters/codex/prompts/component-builder.md +5 -3
  5. package/adapters/codex/prompts/compose-figma.md +59 -0
  6. package/adapters/codex/prompts/document-component.md +3 -2
  7. package/adapters/cursor/.cursor/commands/check-figma.md +41 -0
  8. package/adapters/cursor/.cursor/commands/compose-figma.md +59 -0
  9. package/adapters/cursor/.cursor/commands/document-component.md +3 -2
  10. package/adapters/cursor/.cursor/rules/component-builder.mdc +5 -3
  11. package/adapters/generic/AGENTS.md +2 -0
  12. package/adapters/generic/commands/check-figma.md +41 -0
  13. package/adapters/generic/commands/compose-figma.md +59 -0
  14. package/adapters/generic/commands/document-component.md +3 -2
  15. package/adapters/generic/skills/component-builder/SKILL.md +5 -3
  16. package/package.json +1 -1
  17. package/references/doc-card-builder.md +62 -15
  18. package/references/figma-adherence.md +463 -0
  19. package/references/figma-checks.md +106 -43
  20. package/references/figma-compose.md +896 -0
  21. package/scripts/README.md +8 -2
  22. package/scripts/build-doc-card-builder.mjs +16 -0
  23. package/scripts/build-figma-checks.mjs +208 -23
  24. package/scripts/doc-card-payload.mjs +115 -0
  25. package/scripts/install.mjs +7 -1
  26. package/scripts/lib/doc-card-plan.mjs +37 -0
  27. package/scripts/lib/doc-card-render.figma.js +9 -15
  28. package/scripts/lib/figma-adherence-read.figma.js +228 -0
  29. package/scripts/lib/figma-adherence.mjs +146 -0
  30. package/scripts/lib/figma-checks-read.figma.js +43 -43
  31. package/scripts/lib/figma-checks.mjs +16 -5
  32. package/scripts/lib/figma-compose.figma.js +326 -0
  33. package/scripts/lib/figma-compose.mjs +317 -0
  34. package/scripts/lib/figma-variables-read.figma.js +39 -0
  35. package/scripts/lib/fixtures/figma-adherence/clean.json +90 -0
  36. package/scripts/lib/fixtures/figma-adherence/detached-library.json +116 -0
  37. package/scripts/lib/fixtures/figma-adherence/ds01-gaps.json +84 -0
  38. package/scripts/lib/fixtures/figma-adherence/inert-all.json +45 -0
  39. package/scripts/lib/fixtures/figma-adherence/mixed-text.json +61 -0
  40. package/scripts/lib/fixtures/figma-adherence/nothing-scanned.json +49 -0
  41. package/scripts/lib/fixtures/figma-adherence/off-scale-gap.json +65 -0
  42. package/scripts/lib/fixtures/figma-adherence/override-raw.json +72 -0
  43. package/scripts/lib/fixtures/figma-adherence/padding-raw.json +74 -0
  44. package/scripts/lib/fixtures/figma-adherence/raw-colour-token.json +70 -0
  45. package/scripts/lib/fixtures/figma-adherence/root-radius.json +88 -0
  46. package/scripts/lib/fixtures/figma-adherence/skip-colour.json +86 -0
  47. package/scripts/lib/fixtures/figma-adherence/zygarden-override-bound.json +85 -0
  48. package/scripts/lib/fixtures/figma-adherence/zygarden-text-raw.json +208 -0
  49. package/scripts/lib/fixtures/figma-compose/casing-and-suffix.json +8 -0
  50. package/scripts/lib/fixtures/figma-compose/fill-in-unlaid-slot.json +7 -0
  51. package/scripts/lib/fixtures/figma-compose/hug-on-fixed-component.json +34 -0
  52. package/scripts/lib/fixtures/figma-compose/inventory.json +295 -0
  53. package/scripts/lib/fixtures/figma-compose/many-errors.json +8 -0
  54. package/scripts/lib/fixtures/figma-compose/missing-component.json +7 -0
  55. package/scripts/lib/fixtures/figma-compose/name-taken.json +6 -0
  56. package/scripts/lib/fixtures/figma-compose/padding-sides.json +23 -0
  57. package/scripts/lib/fixtures/figma-compose/radius-kind.json +27 -0
  58. package/scripts/lib/fixtures/figma-compose/raw-number.json +5 -0
  59. package/scripts/lib/fixtures/figma-compose/sign-in-ok.json +16 -0
  60. package/scripts/lib/fixtures/figma-compose/slot-ok.json +9 -0
  61. package/scripts/lib/fixtures/figma-compose/unknown-prop-and-option.json +7 -0
  62. package/scripts/lib/fixtures/figma-compose/wrong-kind.json +5 -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
  ```