@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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Figma plugin-API reader for the screen adherence gate. This file is NOT a Node
|
|
2
|
+
// module — build-figma-checks.mjs concatenates it after the inlined
|
|
3
|
+
// figma-adherence.mjs and figma-variables-read.figma.js into
|
|
4
|
+
// references/figma-adherence.md, and the result runs inside figma_execute.
|
|
5
|
+
// Constraints honored here (see references/figma-scripting.md): async getters
|
|
6
|
+
// only; only the target page is loaded; overridden nodes are found inside their
|
|
7
|
+
// instance, never with getNodeByIdAsync in a loop (that stalled the bridge on a
|
|
8
|
+
// large file). Read-only: nothing here writes to the file. Untested in Node
|
|
9
|
+
// beyond the generator's snippet test; the pure checker it feeds is unit-tested.
|
|
10
|
+
//
|
|
11
|
+
// In-scope globals when assembled: checkScreens and dimensionBuckets (figma-adherence.mjs),
|
|
12
|
+
// colorHex and variableColors (figma-variables-read.figma.js), and the caller-filled slots
|
|
13
|
+
// TARGET = { page, screens?, fileKey } and SKIP = [rule, ...]. The snapshot shape is
|
|
14
|
+
// documented at the top of scripts/lib/figma-adherence.mjs.
|
|
15
|
+
|
|
16
|
+
const CORNERS = [
|
|
17
|
+
['topLeftRadius', 'top-left'],
|
|
18
|
+
['topRightRadius', 'top-right'],
|
|
19
|
+
['bottomRightRadius', 'bottom-right'],
|
|
20
|
+
['bottomLeftRadius', 'bottom-left'],
|
|
21
|
+
];
|
|
22
|
+
const SIDES = [
|
|
23
|
+
['paddingTop', 'top'],
|
|
24
|
+
['paddingRight', 'right'],
|
|
25
|
+
['paddingBottom', 'bottom'],
|
|
26
|
+
['paddingLeft', 'left'],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function solidPaints(node, field, kind) {
|
|
30
|
+
const list = node[field];
|
|
31
|
+
if (!Array.isArray(list)) return [];
|
|
32
|
+
const bound = (node.boundVariables && node.boundVariables[field]) || [];
|
|
33
|
+
const styleId = field === 'fills' ? node.fillStyleId : node.strokeStyleId;
|
|
34
|
+
const styled = typeof styleId === 'string' && styleId !== '';
|
|
35
|
+
const out = [];
|
|
36
|
+
list.forEach((paint, i) => {
|
|
37
|
+
if (paint.visible === false || paint.type !== 'SOLID') return;
|
|
38
|
+
// The paint's opacity goes into the hex the way colorHex writes a translucent
|
|
39
|
+
// variable, so a 50% black matches a 50% black token and not the opaque one.
|
|
40
|
+
const opacity = paint.opacity === undefined ? 1 : paint.opacity;
|
|
41
|
+
out.push({
|
|
42
|
+
kind,
|
|
43
|
+
hex: colorHex({ r: paint.color.r, g: paint.color.g, b: paint.color.b, a: opacity }),
|
|
44
|
+
bound: Boolean(bound[i]),
|
|
45
|
+
styled,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function gapOf(node) {
|
|
52
|
+
if (!node.layoutMode || node.layoutMode === 'NONE') return null;
|
|
53
|
+
const inFlow = (node.children || []).filter((c) => c.visible !== false && c.layoutPositioning !== 'ABSOLUTE');
|
|
54
|
+
return {
|
|
55
|
+
value: node.itemSpacing,
|
|
56
|
+
bound: Boolean(node.boundVariables && node.boundVariables.itemSpacing),
|
|
57
|
+
renders: inFlow.length >= 2 && node.primaryAxisAlignItems !== 'SPACE_BETWEEN',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function paddingOf(node) {
|
|
62
|
+
if (!node.layoutMode || node.layoutMode === 'NONE') return [];
|
|
63
|
+
const bound = node.boundVariables || {};
|
|
64
|
+
return SIDES.map(([field, side]) => ({ side, value: node[field], bound: Boolean(bound[field]) }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function radiusOf(node) {
|
|
68
|
+
if (!('cornerRadius' in node)) return [];
|
|
69
|
+
const bound = node.boundVariables || {};
|
|
70
|
+
if (node.cornerRadius !== figma.mixed) {
|
|
71
|
+
return [{ corner: 'all', value: node.cornerRadius, bound: Boolean(bound.topLeftRadius) }];
|
|
72
|
+
}
|
|
73
|
+
return CORNERS.map(([field, corner]) => ({ corner, value: node[field], bound: Boolean(bound[field]) }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function detachedOf(node, page) {
|
|
77
|
+
let info = null;
|
|
78
|
+
try {
|
|
79
|
+
info = node.detachedInfo;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (!info) return null;
|
|
84
|
+
if (info.type === 'library') return { kind: 'library', name: null, id: null, key: info.componentKey };
|
|
85
|
+
const main = page.findOne((n) => n.id === info.componentId);
|
|
86
|
+
return { kind: 'local', name: main ? main.name : null, id: info.componentId, key: null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function overriddenRecords(instance, path, underDetached) {
|
|
90
|
+
const records = [];
|
|
91
|
+
for (const entry of instance.overrides || []) {
|
|
92
|
+
const fields = entry.overriddenFields.filter((f) => f === 'fills' || f === 'strokes');
|
|
93
|
+
if (!fields.length) continue;
|
|
94
|
+
const node = entry.id === instance.id ? instance : instance.findOne((n) => n.id === entry.id);
|
|
95
|
+
if (!node) continue;
|
|
96
|
+
records.push({
|
|
97
|
+
id: node.id,
|
|
98
|
+
path: node === instance ? path : `${path} / ${node.name}`,
|
|
99
|
+
isRoot: false,
|
|
100
|
+
inInstance: true,
|
|
101
|
+
underDetached,
|
|
102
|
+
overridden: fields,
|
|
103
|
+
paints: [...solidPaints(node, 'fills', 'fill'), ...solidPaints(node, 'strokes', 'stroke')],
|
|
104
|
+
mixedPaints: false,
|
|
105
|
+
gap: null,
|
|
106
|
+
padding: [],
|
|
107
|
+
radius: [],
|
|
108
|
+
detached: null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return records;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The SLOT nodes on this instance's own surface: its slots, and the slots of
|
|
115
|
+
// instances nested in its component, reached only through component sublayers
|
|
116
|
+
// (ids 'I…'). A slot inside content added to a slot is left out here; the walk
|
|
117
|
+
// reaches it through that content.
|
|
118
|
+
function slotsOf(instance) {
|
|
119
|
+
return instance.findAll((n) => n.type === 'SLOT').filter((slot) => {
|
|
120
|
+
for (let node = slot.parent; node && node !== instance; node = node.parent) {
|
|
121
|
+
if (!String(node.id).startsWith('I')) return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function screensOn(page, names) {
|
|
128
|
+
const all = [];
|
|
129
|
+
for (const top of page.children) {
|
|
130
|
+
if (top.type === 'FRAME') all.push(top);
|
|
131
|
+
else if (top.type === 'SECTION') for (const c of top.children) if (c.type === 'FRAME') all.push(c);
|
|
132
|
+
}
|
|
133
|
+
if (!names || !names.length) return all;
|
|
134
|
+
return names.map((name) => {
|
|
135
|
+
const hit = all.find((s) => s.name === name);
|
|
136
|
+
if (!hit) throw new Error(`no screen named "${name}" on page "${page.name}"`);
|
|
137
|
+
return hit;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readTokens() {
|
|
142
|
+
const colours = {};
|
|
143
|
+
for (const variable of await figma.variables.getLocalVariablesAsync('COLOR')) {
|
|
144
|
+
const { modes } = await variableColors(variable);
|
|
145
|
+
for (const hex of Object.values(modes)) {
|
|
146
|
+
if (!hex) continue;
|
|
147
|
+
const names = colours[hex] || (colours[hex] = []);
|
|
148
|
+
if (!names.includes(variable.name)) names.push(variable.name);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const gap = {};
|
|
152
|
+
const radius = {};
|
|
153
|
+
const add = (table, value, name) => {
|
|
154
|
+
if (typeof value !== 'number') return;
|
|
155
|
+
const names = table[String(value)] || (table[String(value)] = []);
|
|
156
|
+
if (!names.includes(name)) names.push(name);
|
|
157
|
+
};
|
|
158
|
+
for (const variable of await figma.variables.getLocalVariablesAsync('FLOAT')) {
|
|
159
|
+
const buckets = dimensionBuckets(variable.name, variable.scopes || []);
|
|
160
|
+
for (const value of Object.values(variable.valuesByMode)) {
|
|
161
|
+
if (buckets.gap) add(gap, value, variable.name);
|
|
162
|
+
if (buckets.radius) add(radius, value, variable.name);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { colours, gap, radius };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Exact name first. Page names often carry decoration nobody types (01. Design
|
|
169
|
+
// System's are " ↳ Templates"), so a unique match on the trimmed name also counts.
|
|
170
|
+
function findPage(name) {
|
|
171
|
+
const exact = figma.root.children.find((p) => p.name === name);
|
|
172
|
+
if (exact) return exact;
|
|
173
|
+
const trimmed = figma.root.children.filter((p) => p.name.trim() === String(name).trim());
|
|
174
|
+
if (trimmed.length === 1) return trimmed[0];
|
|
175
|
+
throw new Error(`no page named "${name}"`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function readScreens(target) {
|
|
179
|
+
const page = findPage(target.page);
|
|
180
|
+
await page.loadAsync();
|
|
181
|
+
const screens = screensOn(page, target.screens);
|
|
182
|
+
const nodes = [];
|
|
183
|
+
const walk = (node, path, isRoot, insideDetached) => {
|
|
184
|
+
if (node.visible === false) return;
|
|
185
|
+
if (node.type === 'INSTANCE') {
|
|
186
|
+
nodes.push(...overriddenRecords(node, path, insideDetached));
|
|
187
|
+
// Content added to a slot is the screen's own, and isn't listed in
|
|
188
|
+
// instance.overrides. A slot's placeholder children keep instance-sublayer
|
|
189
|
+
// ids ('I…;…') and stay part of the component.
|
|
190
|
+
for (const slot of slotsOf(node)) {
|
|
191
|
+
for (const child of slot.children) {
|
|
192
|
+
if (!String(child.id).startsWith('I')) walk(child, `${path} / ${slot.name} / ${child.name}`, false, insideDetached);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const mixedPaints = node.fills === figma.mixed;
|
|
198
|
+
const detached = detachedOf(node, page);
|
|
199
|
+
const underDetached = insideDetached || detached !== null;
|
|
200
|
+
nodes.push({
|
|
201
|
+
id: node.id,
|
|
202
|
+
path,
|
|
203
|
+
isRoot,
|
|
204
|
+
inInstance: false,
|
|
205
|
+
underDetached,
|
|
206
|
+
overridden: [],
|
|
207
|
+
paints: [...solidPaints(node, 'fills', 'fill'), ...solidPaints(node, 'strokes', 'stroke')],
|
|
208
|
+
mixedPaints,
|
|
209
|
+
gap: gapOf(node),
|
|
210
|
+
padding: paddingOf(node),
|
|
211
|
+
radius: radiusOf(node),
|
|
212
|
+
detached,
|
|
213
|
+
});
|
|
214
|
+
for (const child of node.children || []) walk(child, `${path} / ${child.name}`, false, underDetached);
|
|
215
|
+
};
|
|
216
|
+
for (const screen of screens) walk(screen, `${page.name.trim()} / ${screen.name}`, true, false);
|
|
217
|
+
return {
|
|
218
|
+
// The caller passes the fileKey from design-system.json: figma.fileKey is only
|
|
219
|
+
// exposed to private plugins.
|
|
220
|
+
fileKey: target.fileKey || figma.fileKey,
|
|
221
|
+
nodes,
|
|
222
|
+
tokens: await readTokens(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function runScreenGate(TARGET, SKIP) {
|
|
227
|
+
return checkScreens(await readScreens(TARGET), { skip: SKIP || [] });
|
|
228
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The adherence gate for Figma screens as a pure checker: detached components,
|
|
2
|
+
// raw colours a token has, and raw gaps, padding and radii a token has. ZERO
|
|
3
|
+
// imports, `export const`/`export function` only. It is inlined, with the shared
|
|
4
|
+
// variable reader and the screen reader below it, into the generated Figma script
|
|
5
|
+
// (references/figma-adherence.md) by build-figma-checks.mjs, so it must run in
|
|
6
|
+
// both Node and the Figma plugin sandbox.
|
|
7
|
+
//
|
|
8
|
+
// Spec: docs/specs/2026-10-13-figma-adherence-gate.md
|
|
9
|
+
// The code-side counterpart is scripts/validate-adherence.mjs. Both follow its
|
|
10
|
+
// spec's Decision 3: a raw value fails only when a token has it; any other raw
|
|
11
|
+
// value is an advisory.
|
|
12
|
+
//
|
|
13
|
+
// SNAPSHOT (what the reader returns):
|
|
14
|
+
// {
|
|
15
|
+
// fileKey, // for links; the caller passes it (TARGET.fileKey)
|
|
16
|
+
// nodes: [{
|
|
17
|
+
// id, path, // path: 'Page / Screen / … / Node'
|
|
18
|
+
// isRoot, // the screen frame itself
|
|
19
|
+
// inInstance, // an overridden node inside an INSTANCE (or the instance itself)
|
|
20
|
+
// underDetached, // a detached frame, or anything inside one
|
|
21
|
+
// overridden: ['fills' | 'strokes'], // inInstance nodes only
|
|
22
|
+
// paints: [{ kind: 'fill' | 'stroke', hex, bound, styled }], // visible SOLID paints only;
|
|
23
|
+
// // hex is '#rrggbb', or '#rrggbbaa' when the paint's opacity < 1
|
|
24
|
+
// mixedPaints, // fills is figma.mixed (text with mixed runs)
|
|
25
|
+
// gap: { value, bound, renders } | null,
|
|
26
|
+
// padding: [{ side, value, bound }],
|
|
27
|
+
// radius: [{ corner, value, bound }],
|
|
28
|
+
// detached: { kind: 'local' | 'library', name, id, key } | null,
|
|
29
|
+
// }],
|
|
30
|
+
// tokens: {
|
|
31
|
+
// colours: { '#rrggbb': [variableName] }, // every mode's value
|
|
32
|
+
// gap: { '<number>': [variableName] }, // FLOAT scoped GAP or ALL_SCOPES
|
|
33
|
+
// radius: { '<number>': [variableName] }, // FLOAT scoped CORNER_RADIUS or ALL_SCOPES
|
|
34
|
+
// },
|
|
35
|
+
// }
|
|
36
|
+
//
|
|
37
|
+
// RESULT: { pass, failures, advisories, counts, skipped }
|
|
38
|
+
// failures/advisories: [{ rule, id, path, detail, link }], sorted by path then detail
|
|
39
|
+
// counts: { [rule]: { checked, failed } }
|
|
40
|
+
// `pass` is false when any failure exists. A failed assertion is data; nothing throws.
|
|
41
|
+
|
|
42
|
+
export const RULES = ['detached-component', 'raw-colour', 'raw-dimension'];
|
|
43
|
+
|
|
44
|
+
const PAINT_FIELD = { fill: 'fills', stroke: 'strokes' };
|
|
45
|
+
|
|
46
|
+
// Which dimension tables a number variable belongs in. An explicit scope decides.
|
|
47
|
+
// A variable left at ALL_SCOPES (Figma's default) counts only when its name says
|
|
48
|
+
// what it is: on Zygarden every number variable is ALL_SCOPES, and without the
|
|
49
|
+
// name test a 72 px gap failed with "bind text/7xl". Letter-spacing says "spacing"
|
|
50
|
+
// but is type, not layout.
|
|
51
|
+
const SPACING_WORDS = /spac|gap|inset|stack|inline|padding|gutter/i;
|
|
52
|
+
const NOT_SPACING = /letter/i;
|
|
53
|
+
const RADIUS_WORDS = /radius|corner|round/i;
|
|
54
|
+
export function dimensionBuckets(name, scopes = []) {
|
|
55
|
+
const all = scopes.includes('ALL_SCOPES');
|
|
56
|
+
return {
|
|
57
|
+
gap: scopes.includes('GAP') || (all && SPACING_WORDS.test(name) && !NOT_SPACING.test(name)),
|
|
58
|
+
radius: scopes.includes('CORNER_RADIUS') || (all && RADIUS_WORDS.test(name)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function checkScreens(snapshot, { skip = [] } = {}) {
|
|
63
|
+
const nodes = snapshot.nodes ?? [];
|
|
64
|
+
const tokens = snapshot.tokens ?? {};
|
|
65
|
+
const colours = tokens.colours ?? {};
|
|
66
|
+
const gapTokens = tokens.gap ?? {};
|
|
67
|
+
const radiusTokens = tokens.radius ?? {};
|
|
68
|
+
const on = (rule) => !skip.includes(rule);
|
|
69
|
+
const link = (id) => `https://www.figma.com/design/${snapshot.fileKey}?node-id=${String(id).replace(/:/g, '-')}`;
|
|
70
|
+
|
|
71
|
+
const failures = [];
|
|
72
|
+
const advisories = [];
|
|
73
|
+
const counts = Object.fromEntries(RULES.filter(on).map((rule) => [rule, { checked: 0, failed: 0 }]));
|
|
74
|
+
const entry = (rule, node, detail) => ({ rule, id: node.id, path: node.path, detail, link: link(node.id) });
|
|
75
|
+
const fail = (rule, node, detail) => {
|
|
76
|
+
counts[rule].failed += 1;
|
|
77
|
+
failures.push(entry(rule, node, detail));
|
|
78
|
+
};
|
|
79
|
+
const advise = (rule, node, detail) => advisories.push(entry(rule, node, detail));
|
|
80
|
+
|
|
81
|
+
// A bound value passes. A raw one fails when a token has it, naming the token;
|
|
82
|
+
// otherwise it is an advisory.
|
|
83
|
+
const raw = (rule, node, table, value, bound, what) => {
|
|
84
|
+
counts[rule].checked += 1;
|
|
85
|
+
if (bound) return;
|
|
86
|
+
const names = table[String(value)];
|
|
87
|
+
if (names && names.length) fail(rule, node, `${what}: bind ${names.join(', ')}`);
|
|
88
|
+
else advise(rule, node, rule === 'raw-colour' ? `${what}: no colour variable has this value` : `off-scale ${what}`);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for (const node of nodes) {
|
|
92
|
+
if (on('detached-component')) {
|
|
93
|
+
counts['detached-component'].checked += 1;
|
|
94
|
+
const d = node.detached;
|
|
95
|
+
if (d) {
|
|
96
|
+
let from;
|
|
97
|
+
if (d.kind === 'library') from = `a library component (key ${d.key})`;
|
|
98
|
+
else if (d.name) from = d.name;
|
|
99
|
+
else from = `a local component on another page (id ${d.id})`;
|
|
100
|
+
fail('detached-component', node, `detached from ${from}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Inside a detached copy, the one detached-component failure is the finding:
|
|
105
|
+
// re-instancing fixes every raw value under it. On 01. Design System's
|
|
106
|
+
// Archtypes page, checking them anyway turned 8 detached frames into ~200 failures.
|
|
107
|
+
if (node.underDetached) continue;
|
|
108
|
+
|
|
109
|
+
if (on('raw-colour')) {
|
|
110
|
+
if (node.mixedPaints) advise('raw-colour', node, 'mixed paints not read');
|
|
111
|
+
for (const paint of node.paints ?? []) {
|
|
112
|
+
if (node.inInstance && !(node.overridden ?? []).includes(PAINT_FIELD[paint.kind])) continue;
|
|
113
|
+
raw('raw-colour', node, colours, paint.hex, paint.bound || paint.styled, `${paint.kind} ${paint.hex}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (on('raw-dimension') && !node.inInstance) {
|
|
118
|
+
const gap = node.gap;
|
|
119
|
+
if (gap && gap.renders && gap.value > 0) raw('raw-dimension', node, gapTokens, gap.value, gap.bound, `gap ${gap.value}`);
|
|
120
|
+
for (const p of node.padding ?? []) {
|
|
121
|
+
if (p.value > 0) raw('raw-dimension', node, gapTokens, p.value, p.bound, `padding-${p.side} ${p.value}`);
|
|
122
|
+
}
|
|
123
|
+
if (!node.isRoot) {
|
|
124
|
+
for (const r of node.radius ?? []) {
|
|
125
|
+
if (r.value > 0) raw('raw-dimension', node, radiusTokens, r.value, r.bound, `radius-${r.corner} ${r.value}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Every enabled rule must have had something to check (code gate Decision 7).
|
|
132
|
+
const inert = (rule, detail) => failures.push({ rule, id: null, path: '', detail, link: null });
|
|
133
|
+
if (nodes.length === 0) inert('nothing-scanned', 'no node was read; check the page and screen names');
|
|
134
|
+
if (on('raw-colour') && Object.keys(colours).length === 0) {
|
|
135
|
+
inert('colour-rule-inert', 'the file has no local colour variables, so no raw colour could be matched to one');
|
|
136
|
+
}
|
|
137
|
+
if (on('raw-dimension') && Object.keys(gapTokens).length === 0 && Object.keys(radiusTokens).length === 0) {
|
|
138
|
+
inert('dimension-rule-inert', 'the file has no local number variable for spacing or radius: none is scoped GAP or CORNER_RADIUS, and none left at ALL_SCOPES is named for spacing or radius');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
142
|
+
const byPath = (a, b) => cmp(a.path, b.path) || cmp(a.detail, b.detail);
|
|
143
|
+
failures.sort(byPath);
|
|
144
|
+
advisories.sort(byPath);
|
|
145
|
+
return { pass: failures.length === 0, failures, advisories, counts, skipped: RULES.filter((rule) => !on(rule)) };
|
|
146
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Figma plugin-API reader for the stop-condition checks. This file is NOT a
|
|
2
2
|
// Node module — build-figma-checks.mjs concatenates it after the inlined
|
|
3
|
-
// component-states.mjs, contrast.mjs
|
|
4
|
-
// references/figma-checks.md, and
|
|
3
|
+
// component-states.mjs, contrast.mjs, figma-checks.mjs and
|
|
4
|
+
// figma-variables-read.figma.js into references/figma-checks.md, and
|
|
5
5
|
// the result runs inside figma_execute (dynamic-page mode). Constraints honored
|
|
6
6
|
// here (see references/figma-scripting.md): async getters only, variable names
|
|
7
7
|
// and per-mode colour values resolved with getVariableByIdAsync and only for
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// renderer; the pure checker it feeds is unit-tested.
|
|
12
12
|
//
|
|
13
13
|
// In-scope globals when assembled: normalizeName (inlined
|
|
14
|
-
// component-states.mjs),
|
|
14
|
+
// component-states.mjs), variableColors (figma-variables-read.figma.js),
|
|
15
|
+
// checkSpec, checkBuilt, checkExisting (bound from withTable) and the
|
|
15
16
|
// caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
|
|
16
17
|
// shape is documented at the top of scripts/lib/figma-checks.mjs.
|
|
17
18
|
|
|
@@ -26,37 +27,6 @@ async function variableName(alias) {
|
|
|
26
27
|
return variable.name;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function colorHex(value) {
|
|
30
|
-
if (!value || typeof value !== 'object' || !('r' in value)) return null;
|
|
31
|
-
const byte = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
32
|
-
const alpha = value.a === undefined ? 1 : value.a;
|
|
33
|
-
return `#${byte(value.r)}${byte(value.g)}${byte(value.b)}${alpha < 1 ? byte(alpha) : ''}`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// The value of `variable` in the mode named `modeName` (after the fold), else
|
|
37
|
-
// its collection's default mode, following aliases up to 10 hops.
|
|
38
|
-
async function resolveColor(variable, modeName, hops) {
|
|
39
|
-
if (!variable || hops > 10) return null;
|
|
40
|
-
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
41
|
-
if (!collection) return null;
|
|
42
|
-
const mode = collection.modes.find((m) => normalizeName(m.name) === normalizeName(modeName));
|
|
43
|
-
const value = variable.valuesByMode[mode ? mode.modeId : collection.defaultModeId];
|
|
44
|
-
if (value && value.type === 'VARIABLE_ALIAS') {
|
|
45
|
-
return resolveColor(await figma.variables.getVariableByIdAsync(value.id), modeName, hops + 1);
|
|
46
|
-
}
|
|
47
|
-
return colorHex(value);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// { modes: { [modeName]: hex | null }, defaultMode } over the variable's own collection.
|
|
51
|
-
async function variableColors(variable) {
|
|
52
|
-
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
53
|
-
if (!collection) return { modes: {}, defaultMode: undefined };
|
|
54
|
-
const modes = {};
|
|
55
|
-
for (const mode of collection.modes) modes[mode.name] = await resolveColor(variable, mode.name, 0);
|
|
56
|
-
const fallback = collection.modes.find((m) => m.modeId === collection.defaultModeId);
|
|
57
|
-
return { modes, defaultMode: fallback ? fallback.name : undefined };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
30
|
// The fields the focus check reads from a variant, a control layer or a direct child.
|
|
61
31
|
async function readFocusNode(node, { withEffects }) {
|
|
62
32
|
const strokes = (node.boundVariables && node.boundVariables.strokes) || [];
|
|
@@ -114,19 +84,45 @@ function groupProperties(set) {
|
|
|
114
84
|
}
|
|
115
85
|
}
|
|
116
86
|
|
|
117
|
-
|
|
118
|
-
|
|
87
|
+
// The set a build just made: the COMPONENT_SET in the `WIP: <name>` frame on the
|
|
88
|
+
// current page. A frame holding plain FRAMEs where the set should be is a failed
|
|
89
|
+
// assertion, not a missing target, so it comes back as `{ empty }` for the
|
|
90
|
+
// structural check to name.
|
|
91
|
+
async function findWipSet(expect) {
|
|
119
92
|
await figma.currentPage.loadAsync();
|
|
120
93
|
const frameName = `WIP: ${expect.name}`;
|
|
121
94
|
const frame = figma.currentPage.findOne((n) => n.name === frameName);
|
|
122
95
|
if (!frame) throw new Error(`no node named "${frameName}" on the current page`);
|
|
123
96
|
const set = frame.type === 'COMPONENT_SET' ? frame : (frame.children || []).find((c) => c.type === 'COMPONENT_SET');
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
97
|
+
if (set) return { set };
|
|
98
|
+
const first = (frame.children || [])[0];
|
|
99
|
+
return { empty: { type: first ? first.type : 'NONE', variantGroupProperties: {}, children: [] } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A set already in the library: the first COMPONENT_SET whose name folds to the
|
|
103
|
+
// same name. With `pageName`, only that page is loaded (exact name, or a unique
|
|
104
|
+
// match on the trimmed name). Without it every page is loaded in turn until one
|
|
105
|
+
// has the set, which on a miss is the whole file; a whole-file load stalled the
|
|
106
|
+
// bridge on a large file, so callers pass the page.
|
|
107
|
+
async function findExistingSet(name, pageName) {
|
|
108
|
+
const want = normalizeName(name);
|
|
109
|
+
let pages = figma.root.children;
|
|
110
|
+
if (pageName !== undefined && pageName !== null) {
|
|
111
|
+
const exact = pages.filter((p) => p.name === pageName);
|
|
112
|
+
const trimmed = pages.filter((p) => p.name.trim() === String(pageName).trim());
|
|
113
|
+
pages = exact.length ? exact : trimmed.length === 1 ? trimmed : [];
|
|
114
|
+
if (!pages.length) throw new Error(`no page named "${pageName}"`);
|
|
129
115
|
}
|
|
116
|
+
for (const page of pages) {
|
|
117
|
+
await page.loadAsync();
|
|
118
|
+
const set = page.findAllWithCriteria({ types: ['COMPONENT_SET'] }).find((s) => normalizeName(s.name) === want);
|
|
119
|
+
if (set) return set;
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`no component set named "${name}"${pageName ? ` on page "${pageName}"` : ' in this file'}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function readSet(set, expect) {
|
|
125
|
+
readVariables.clear();
|
|
130
126
|
|
|
131
127
|
const isFocusState = (props) =>
|
|
132
128
|
Object.entries(props || {}).some(([k, v]) => {
|
|
@@ -169,6 +165,10 @@ async function readComponentSet(expect) {
|
|
|
169
165
|
|
|
170
166
|
async function runFigmaChecks(EXPECT, MODE) {
|
|
171
167
|
if (MODE === 'spec') return checkSpec(EXPECT);
|
|
172
|
-
if (MODE === 'built')
|
|
173
|
-
|
|
168
|
+
if (MODE === 'built') {
|
|
169
|
+
const { set, empty } = await findWipSet(EXPECT);
|
|
170
|
+
return checkBuilt(set ? await readSet(set, EXPECT) : empty, EXPECT);
|
|
171
|
+
}
|
|
172
|
+
if (MODE === 'existing') return checkExisting(await readSet(await findExistingSet(EXPECT.name, EXPECT.page), EXPECT), EXPECT);
|
|
173
|
+
throw new Error(`MODE must be 'spec', 'built' or 'existing', got ${JSON.stringify(MODE)}`);
|
|
174
174
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// The state table is not imported. `withTable({ resolveArchetype, BASELINE_STATES,
|
|
10
10
|
// STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT })`
|
|
11
11
|
// takes it, the fold and the contrast maths from the caller and returns
|
|
12
|
-
// { checkSpec, checkBuilt }. In the generated script the caller passes the
|
|
12
|
+
// { checkSpec, checkBuilt, checkExisting }. In the generated script the caller passes the
|
|
13
13
|
// inlined component-states.mjs names; in tests it passes the module's own exports.
|
|
14
14
|
//
|
|
15
15
|
// Spec: docs/specs/2026-10-07-figma-checks-as-scripts.md
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
//
|
|
47
47
|
// RESULT: { pass, checks: [{ name, result: 'pass' | 'fail' | 'skip', detail }] }
|
|
48
48
|
// built mode returns four checks: structural-read-back, state-baseline,
|
|
49
|
-
// focus-indicator, focus-contrast.
|
|
49
|
+
// focus-indicator, focus-contrast. existing mode (a set already in the library,
|
|
50
|
+
// docs/specs/2026-10-13-figma-adherence-gate.md) returns the last three.
|
|
50
51
|
// `pass` ignores skips. A failed assertion is data; nothing here throws.
|
|
51
52
|
|
|
52
53
|
// The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
|
|
@@ -281,7 +282,7 @@ export function withTable({
|
|
|
281
282
|
return {
|
|
282
283
|
name,
|
|
283
284
|
result: 'skip',
|
|
284
|
-
detail: 'no surfaces named;
|
|
285
|
+
detail: 'no surfaces named; name the colour variables of the surfaces the component sits on (from the architect spec, or ask the user)',
|
|
285
286
|
};
|
|
286
287
|
}
|
|
287
288
|
const missing = snapshot.missingSurfaces ?? [];
|
|
@@ -369,9 +370,11 @@ export function withTable({
|
|
|
369
370
|
return result([stateBaseline(expect, entries)]);
|
|
370
371
|
}
|
|
371
372
|
|
|
373
|
+
const variantEntries = (snapshot) => (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
|
|
374
|
+
|
|
372
375
|
// After the build: the node as read.
|
|
373
376
|
function checkBuilt(snapshot, expect) {
|
|
374
|
-
const entries = (snapshot
|
|
377
|
+
const entries = variantEntries(snapshot);
|
|
375
378
|
const indicator = focusIndicator(snapshot, expect);
|
|
376
379
|
return result([
|
|
377
380
|
structural(snapshot, expect),
|
|
@@ -381,5 +384,13 @@ export function withTable({
|
|
|
381
384
|
]);
|
|
382
385
|
}
|
|
383
386
|
|
|
384
|
-
|
|
387
|
+
// An existing set has no spec matrix, so the structural check is not run; the
|
|
388
|
+
// other three read only the set itself.
|
|
389
|
+
function checkExisting(snapshot, expect) {
|
|
390
|
+
const entries = variantEntries(snapshot);
|
|
391
|
+
const indicator = focusIndicator(snapshot, expect);
|
|
392
|
+
return result([stateBaseline(expect, entries), indicator, focusContrast(snapshot, expect, indicator)]);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { checkSpec, checkBuilt, checkExisting };
|
|
385
396
|
}
|