@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.
- package/README.md +8 -4
- package/adapters/codex/AGENTS.md +2 -0
- package/adapters/codex/prompts/check-figma.md +41 -0
- package/adapters/codex/prompts/component-builder.md +5 -3
- package/adapters/codex/prompts/compose-figma.md +59 -0
- package/adapters/codex/prompts/document-component.md +3 -2
- package/adapters/cursor/.cursor/commands/check-figma.md +41 -0
- package/adapters/cursor/.cursor/commands/compose-figma.md +59 -0
- package/adapters/cursor/.cursor/commands/document-component.md +3 -2
- package/adapters/cursor/.cursor/rules/component-builder.mdc +5 -3
- package/adapters/generic/AGENTS.md +2 -0
- package/adapters/generic/commands/check-figma.md +41 -0
- package/adapters/generic/commands/compose-figma.md +59 -0
- package/adapters/generic/commands/document-component.md +3 -2
- package/adapters/generic/skills/component-builder/SKILL.md +5 -3
- package/package.json +1 -1
- package/references/doc-card-builder.md +62 -15
- package/references/figma-adherence.md +463 -0
- package/references/figma-checks.md +106 -43
- package/references/figma-compose.md +896 -0
- package/scripts/README.md +8 -2
- package/scripts/build-doc-card-builder.mjs +16 -0
- package/scripts/build-figma-checks.mjs +208 -23
- package/scripts/doc-card-payload.mjs +115 -0
- package/scripts/install.mjs +7 -1
- package/scripts/lib/doc-card-plan.mjs +37 -0
- package/scripts/lib/doc-card-render.figma.js +9 -15
- package/scripts/lib/figma-adherence-read.figma.js +228 -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-compose.figma.js +326 -0
- package/scripts/lib/figma-compose.mjs +317 -0
- 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-compose/casing-and-suffix.json +8 -0
- package/scripts/lib/fixtures/figma-compose/fill-in-unlaid-slot.json +7 -0
- package/scripts/lib/fixtures/figma-compose/hug-on-fixed-component.json +34 -0
- package/scripts/lib/fixtures/figma-compose/inventory.json +295 -0
- package/scripts/lib/fixtures/figma-compose/many-errors.json +8 -0
- package/scripts/lib/fixtures/figma-compose/missing-component.json +7 -0
- package/scripts/lib/fixtures/figma-compose/name-taken.json +6 -0
- package/scripts/lib/fixtures/figma-compose/padding-sides.json +23 -0
- package/scripts/lib/fixtures/figma-compose/radius-kind.json +27 -0
- package/scripts/lib/fixtures/figma-compose/raw-number.json +5 -0
- package/scripts/lib/fixtures/figma-compose/sign-in-ok.json +16 -0
- package/scripts/lib/fixtures/figma-compose/slot-ok.json +9 -0
- package/scripts/lib/fixtures/figma-compose/unknown-prop-and-option.json +7 -0
- 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 `'
|
|
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
|
```
|