@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,896 @@
|
|
|
1
|
+
# Figma screen composition (GENERATED)
|
|
2
|
+
|
|
3
|
+
> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/figma-adherence.mjs`
|
|
4
|
+
> (for `dimensionBuckets`), `scripts/lib/figma-compose.mjs` (the pure resolver,
|
|
5
|
+
> unit-tested in Node), `scripts/lib/figma-variables-read.figma.js` +
|
|
6
|
+
> `scripts/lib/figma-compose.figma.js` (the reader and writer). Regenerate with
|
|
7
|
+
> `node scripts/build-figma-checks.mjs`; CI gates freshness with `--check`.
|
|
8
|
+
|
|
9
|
+
The canonical `figma_execute` snippet that composes a screen from the file's own
|
|
10
|
+
component sets, variables and text styles. A screen is a short JSON description
|
|
11
|
+
written only in those names (its shape is the `DESCRIPTION` comment in the
|
|
12
|
+
snippet, at the top of the resolver).
|
|
13
|
+
The whole description is checked before anything is written: an unknown component,
|
|
14
|
+
property, option, variable or text style, a variable of the wrong kind, or a name
|
|
15
|
+
already held by a screen a person made, comes back as a list of errors and the file
|
|
16
|
+
is untouched. A component the file lacks is a `missing-component` error with the
|
|
17
|
+
nearest names: report it as a gap, never draw a lookalike. Every value the script
|
|
18
|
+
writes is bound to a variable. The script is the rule; do not re-write it by hand.
|
|
19
|
+
|
|
20
|
+
## How to call it
|
|
21
|
+
|
|
22
|
+
1. Paste the snippet below into `figma_execute` with `timeout: 30000`, and end it
|
|
23
|
+
with `return await runCompose(MODE, SPEC);`, one mode per call:
|
|
24
|
+
- `'inventory'`, `SPEC = { componentsPage, iconsPage }`: read-only. Returns
|
|
25
|
+
`{ summary }`: each set's properties and options and whether it has auto
|
|
26
|
+
layout, swap targets, variables by kind (colour, gap, radius), text styles and
|
|
27
|
+
modes. Plan only from these names.
|
|
28
|
+
`iconsPage` is optional; pass it when a swap property needs its targets.
|
|
29
|
+
- `'build'`, `SPEC = { componentsPage, iconsPage, description, fileKey }`:
|
|
30
|
+
returns `{ ok: false, errors }` with nothing written, or builds the screen into
|
|
31
|
+
a top-level frame `WIP: <screen>` on the description's page (created if
|
|
32
|
+
missing) and returns `{ ok: true, wipId, wipName, link, ms }`. A throw during
|
|
33
|
+
the write removes the `WIP:` frame (and a page the build created) before it
|
|
34
|
+
rethrows, so nothing is left half-built: report it as a composer bug.
|
|
35
|
+
- `'finalize'`, `SPEC = { page, screen, fileKey }`: renames `WIP: <screen>` to
|
|
36
|
+
`<screen>`, replacing a screen of that name only if throughline composed it.
|
|
37
|
+
Returns `{ id, link, replaced }`.
|
|
38
|
+
- `'discard'`, `SPEC = { page, screen }`: removes `WIP: <screen>`. Only when
|
|
39
|
+
the user asks.
|
|
40
|
+
2. Between `build` and `finalize`, run the screen gate
|
|
41
|
+
(`references/figma-adherence.md`) with `screens: ['WIP: <screen>']`. Finalize
|
|
42
|
+
only a passing screen. A failure is a composer bug: report it and leave the
|
|
43
|
+
`WIP:` frame.
|
|
44
|
+
3. Pages are found by exact name, or else the one page whose trimmed name matches.
|
|
45
|
+
Only the named pages are loaded. Components and variables are the file's local
|
|
46
|
+
ones.
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
const MODE = 'build';
|
|
50
|
+
const SPEC = { componentsPage: 'Components', iconsPage: 'Icons', fileKey: '<figma.fileKey>', description: {
|
|
51
|
+
screen: 'Sign in', page: 'Screens', width: 390, modes: { 'Color / Semantic': 'Light' },
|
|
52
|
+
root: { layout: 'vertical', gap: 'space/gap/md', padding: 'space/inset/lg', fill: 'color/bg/canvas', children: [
|
|
53
|
+
{ text: 'Sign in', style: 'Heading/L', color: 'color/text/primary' },
|
|
54
|
+
{ component: 'Input', props: { placeholder: 'Email' }, sizing: { horizontal: 'fill' } },
|
|
55
|
+
{ component: 'Button', props: { label: 'Sign in', hasTrailing: true, trailingIcon: 'icon/arrow-right' } },
|
|
56
|
+
] },
|
|
57
|
+
} };
|
|
58
|
+
// … the generated snippet …
|
|
59
|
+
return await runCompose(MODE, SPEC);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## The snippet
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
// The adherence gate for Figma screens as a pure checker: detached components,
|
|
66
|
+
// raw colours a token has, and raw gaps, padding and radii a token has. ZERO
|
|
67
|
+
// imports, `export const`/`export function` only. It is inlined, with the shared
|
|
68
|
+
// variable reader and the screen reader below it, into the generated Figma script
|
|
69
|
+
// (references/figma-adherence.md) by build-figma-checks.mjs, so it must run in
|
|
70
|
+
// both Node and the Figma plugin sandbox.
|
|
71
|
+
//
|
|
72
|
+
// Spec: docs/specs/2026-10-13-figma-adherence-gate.md
|
|
73
|
+
// The code-side counterpart is scripts/validate-adherence.mjs. Both follow its
|
|
74
|
+
// spec's Decision 3: a raw value fails only when a token has it; any other raw
|
|
75
|
+
// value is an advisory.
|
|
76
|
+
//
|
|
77
|
+
// SNAPSHOT (what the reader returns):
|
|
78
|
+
// {
|
|
79
|
+
// fileKey, // for links; the caller passes it (TARGET.fileKey)
|
|
80
|
+
// nodes: [{
|
|
81
|
+
// id, path, // path: 'Page / Screen / … / Node'
|
|
82
|
+
// isRoot, // the screen frame itself
|
|
83
|
+
// inInstance, // an overridden node inside an INSTANCE (or the instance itself)
|
|
84
|
+
// underDetached, // a detached frame, or anything inside one
|
|
85
|
+
// overridden: ['fills' | 'strokes'], // inInstance nodes only
|
|
86
|
+
// paints: [{ kind: 'fill' | 'stroke', hex, bound, styled }], // visible SOLID paints only;
|
|
87
|
+
// // hex is '#rrggbb', or '#rrggbbaa' when the paint's opacity < 1
|
|
88
|
+
// mixedPaints, // fills is figma.mixed (text with mixed runs)
|
|
89
|
+
// gap: { value, bound, renders } | null,
|
|
90
|
+
// padding: [{ side, value, bound }],
|
|
91
|
+
// radius: [{ corner, value, bound }],
|
|
92
|
+
// detached: { kind: 'local' | 'library', name, id, key } | null,
|
|
93
|
+
// }],
|
|
94
|
+
// tokens: {
|
|
95
|
+
// colours: { '#rrggbb': [variableName] }, // every mode's value
|
|
96
|
+
// gap: { '<number>': [variableName] }, // FLOAT scoped GAP or ALL_SCOPES
|
|
97
|
+
// radius: { '<number>': [variableName] }, // FLOAT scoped CORNER_RADIUS or ALL_SCOPES
|
|
98
|
+
// },
|
|
99
|
+
// }
|
|
100
|
+
//
|
|
101
|
+
// RESULT: { pass, failures, advisories, counts, skipped }
|
|
102
|
+
// failures/advisories: [{ rule, id, path, detail, link }], sorted by path then detail
|
|
103
|
+
// counts: { [rule]: { checked, failed } }
|
|
104
|
+
// `pass` is false when any failure exists. A failed assertion is data; nothing throws.
|
|
105
|
+
|
|
106
|
+
const RULES = ['detached-component', 'raw-colour', 'raw-dimension'];
|
|
107
|
+
|
|
108
|
+
const PAINT_FIELD = { fill: 'fills', stroke: 'strokes' };
|
|
109
|
+
|
|
110
|
+
// Which dimension tables a number variable belongs in. An explicit scope decides.
|
|
111
|
+
// A variable left at ALL_SCOPES (Figma's default) counts only when its name says
|
|
112
|
+
// what it is: on Zygarden every number variable is ALL_SCOPES, and without the
|
|
113
|
+
// name test a 72 px gap failed with "bind text/7xl". Letter-spacing says "spacing"
|
|
114
|
+
// but is type, not layout.
|
|
115
|
+
const SPACING_WORDS = /spac|gap|inset|stack|inline|padding|gutter/i;
|
|
116
|
+
const NOT_SPACING = /letter/i;
|
|
117
|
+
const RADIUS_WORDS = /radius|corner|round/i;
|
|
118
|
+
function dimensionBuckets(name, scopes = []) {
|
|
119
|
+
const all = scopes.includes('ALL_SCOPES');
|
|
120
|
+
return {
|
|
121
|
+
gap: scopes.includes('GAP') || (all && SPACING_WORDS.test(name) && !NOT_SPACING.test(name)),
|
|
122
|
+
radius: scopes.includes('CORNER_RADIUS') || (all && RADIUS_WORDS.test(name)),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function checkScreens(snapshot, { skip = [] } = {}) {
|
|
127
|
+
const nodes = snapshot.nodes ?? [];
|
|
128
|
+
const tokens = snapshot.tokens ?? {};
|
|
129
|
+
const colours = tokens.colours ?? {};
|
|
130
|
+
const gapTokens = tokens.gap ?? {};
|
|
131
|
+
const radiusTokens = tokens.radius ?? {};
|
|
132
|
+
const on = (rule) => !skip.includes(rule);
|
|
133
|
+
const link = (id) => `https://www.figma.com/design/${snapshot.fileKey}?node-id=${String(id).replace(/:/g, '-')}`;
|
|
134
|
+
|
|
135
|
+
const failures = [];
|
|
136
|
+
const advisories = [];
|
|
137
|
+
const counts = Object.fromEntries(RULES.filter(on).map((rule) => [rule, { checked: 0, failed: 0 }]));
|
|
138
|
+
const entry = (rule, node, detail) => ({ rule, id: node.id, path: node.path, detail, link: link(node.id) });
|
|
139
|
+
const fail = (rule, node, detail) => {
|
|
140
|
+
counts[rule].failed += 1;
|
|
141
|
+
failures.push(entry(rule, node, detail));
|
|
142
|
+
};
|
|
143
|
+
const advise = (rule, node, detail) => advisories.push(entry(rule, node, detail));
|
|
144
|
+
|
|
145
|
+
// A bound value passes. A raw one fails when a token has it, naming the token;
|
|
146
|
+
// otherwise it is an advisory.
|
|
147
|
+
const raw = (rule, node, table, value, bound, what) => {
|
|
148
|
+
counts[rule].checked += 1;
|
|
149
|
+
if (bound) return;
|
|
150
|
+
const names = table[String(value)];
|
|
151
|
+
if (names && names.length) fail(rule, node, `${what}: bind ${names.join(', ')}`);
|
|
152
|
+
else advise(rule, node, rule === 'raw-colour' ? `${what}: no colour variable has this value` : `off-scale ${what}`);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
for (const node of nodes) {
|
|
156
|
+
if (on('detached-component')) {
|
|
157
|
+
counts['detached-component'].checked += 1;
|
|
158
|
+
const d = node.detached;
|
|
159
|
+
if (d) {
|
|
160
|
+
let from;
|
|
161
|
+
if (d.kind === 'library') from = `a library component (key ${d.key})`;
|
|
162
|
+
else if (d.name) from = d.name;
|
|
163
|
+
else from = `a local component on another page (id ${d.id})`;
|
|
164
|
+
fail('detached-component', node, `detached from ${from}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Inside a detached copy, the one detached-component failure is the finding:
|
|
169
|
+
// re-instancing fixes every raw value under it. On 01. Design System's
|
|
170
|
+
// Archtypes page, checking them anyway turned 8 detached frames into ~200 failures.
|
|
171
|
+
if (node.underDetached) continue;
|
|
172
|
+
|
|
173
|
+
if (on('raw-colour')) {
|
|
174
|
+
if (node.mixedPaints) advise('raw-colour', node, 'mixed paints not read');
|
|
175
|
+
for (const paint of node.paints ?? []) {
|
|
176
|
+
if (node.inInstance && !(node.overridden ?? []).includes(PAINT_FIELD[paint.kind])) continue;
|
|
177
|
+
raw('raw-colour', node, colours, paint.hex, paint.bound || paint.styled, `${paint.kind} ${paint.hex}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (on('raw-dimension') && !node.inInstance) {
|
|
182
|
+
const gap = node.gap;
|
|
183
|
+
if (gap && gap.renders && gap.value > 0) raw('raw-dimension', node, gapTokens, gap.value, gap.bound, `gap ${gap.value}`);
|
|
184
|
+
for (const p of node.padding ?? []) {
|
|
185
|
+
if (p.value > 0) raw('raw-dimension', node, gapTokens, p.value, p.bound, `padding-${p.side} ${p.value}`);
|
|
186
|
+
}
|
|
187
|
+
if (!node.isRoot) {
|
|
188
|
+
for (const r of node.radius ?? []) {
|
|
189
|
+
if (r.value > 0) raw('raw-dimension', node, radiusTokens, r.value, r.bound, `radius-${r.corner} ${r.value}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Every enabled rule must have had something to check (code gate Decision 7).
|
|
196
|
+
const inert = (rule, detail) => failures.push({ rule, id: null, path: '', detail, link: null });
|
|
197
|
+
if (nodes.length === 0) inert('nothing-scanned', 'no node was read; check the page and screen names');
|
|
198
|
+
if (on('raw-colour') && Object.keys(colours).length === 0) {
|
|
199
|
+
inert('colour-rule-inert', 'the file has no local colour variables, so no raw colour could be matched to one');
|
|
200
|
+
}
|
|
201
|
+
if (on('raw-dimension') && Object.keys(gapTokens).length === 0 && Object.keys(radiusTokens).length === 0) {
|
|
202
|
+
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');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
206
|
+
const byPath = (a, b) => cmp(a.path, b.path) || cmp(a.detail, b.detail);
|
|
207
|
+
failures.sort(byPath);
|
|
208
|
+
advisories.sort(byPath);
|
|
209
|
+
return { pass: failures.length === 0, failures, advisories, counts, skipped: RULES.filter((rule) => !on(rule)) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Screen composition for Figma as a pure resolver: checks a screen description
|
|
213
|
+
// against what the file has, and turns its names into what the writer sets.
|
|
214
|
+
// ZERO imports, `export const`/`export function` only. It is inlined, next to
|
|
215
|
+
// figma-adherence.mjs and the shared variable reader, into the generated Figma
|
|
216
|
+
// script (references/figma-compose.md) by build-figma-checks.mjs, so it must run
|
|
217
|
+
// in both Node and the Figma plugin sandbox, and reuse none of their top-level
|
|
218
|
+
// names.
|
|
219
|
+
//
|
|
220
|
+
// Spec: docs/specs/2026-10-13-compose-figma-screen.md
|
|
221
|
+
//
|
|
222
|
+
// DESCRIPTION (what the planner writes):
|
|
223
|
+
// {
|
|
224
|
+
// screen, page, width, height?, // width/height: the only raw numbers
|
|
225
|
+
// modes?: { [collectionName]: modeName },
|
|
226
|
+
// root: <frame>, // no sizing on the root
|
|
227
|
+
// }
|
|
228
|
+
// frame: { layout: 'vertical'|'horizontal', gap?, padding?, fill?, stroke?, radius?,
|
|
229
|
+
// align?: { main?: 'start'|'center'|'end'|'space-between', cross?: 'start'|'center'|'end' },
|
|
230
|
+
// children?: [node], sizing?, name? }
|
|
231
|
+
// padding: a variable name, or { top?, right?, bottom?, left? }
|
|
232
|
+
// component: { component, props?: { [propName]: value }, slots?: { [slotName]: [node] }, sizing?, name? }
|
|
233
|
+
// text: { text, style, color, sizing?, name? }
|
|
234
|
+
// sizing: { horizontal?: 'fill'|'hug', vertical?: 'fill'|'hug' }
|
|
235
|
+
// An axis left out stays as Figma makes it: a frame hugs, text sizes to
|
|
236
|
+
// its content, an instance keeps its component's size. 'hug' on an
|
|
237
|
+
// instance needs a component with auto layout (Figma throws otherwise).
|
|
238
|
+
// Every visual value is a variable or text-style name.
|
|
239
|
+
//
|
|
240
|
+
// INVENTORY (what the reader returns):
|
|
241
|
+
// {
|
|
242
|
+
// sets: [{ name, id, page, autoLayout, props: [{ key, name, type, options?, slotLayout? }] }],
|
|
243
|
+
// // key: 'label#10:273'; type: TEXT | BOOLEAN | VARIANT
|
|
244
|
+
// // | INSTANCE_SWAP | SLOT; slotLayout: NONE | VERTICAL | HORIZONTAL,
|
|
245
|
+
// // NONE if any variant's slot has no auto layout;
|
|
246
|
+
// // autoLayout: every variant has auto layout
|
|
247
|
+
// components: [{ name, id, page }], // standalone COMPONENTs, the swap targets
|
|
248
|
+
// variables: [{ name, id, kinds: ['colour'|'gap'|'radius'] }],
|
|
249
|
+
// textStyles: [{ name, id }],
|
|
250
|
+
// collections: [{ name, id, modes: [{ name, id }] }],
|
|
251
|
+
// target: { screen, wip } | null, // build only: 'marked' | 'unmarked' | null for
|
|
252
|
+
// // the frames named <screen> and 'WIP: <screen>'
|
|
253
|
+
// }
|
|
254
|
+
//
|
|
255
|
+
// resolveScreen(description, inventory) → { ok, errors, plan }
|
|
256
|
+
// errors: [{ kind, path, asked, nearest? }]; path is the node's place in the
|
|
257
|
+
// description ('root.children[2].slots.Body[0]').
|
|
258
|
+
// plan (only when ok): the same tree with every name replaced by ids, real
|
|
259
|
+
// property keys and values, and Figma's enum spellings.
|
|
260
|
+
|
|
261
|
+
const COMPOSE_ERROR_KINDS = [
|
|
262
|
+
'invalid', 'name-taken', 'missing-component', 'unknown-prop', 'unknown-option',
|
|
263
|
+
'prop-type', 'missing-swap-target', 'unknown-variable', 'variable-kind',
|
|
264
|
+
'unknown-text-style', 'unknown-mode',
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
const composeFold = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
268
|
+
|
|
269
|
+
// 'label#10:273' → 'label'. Variant property keys carry no suffix.
|
|
270
|
+
const propBaseName = (key) => String(key).replace(/#[^#]*$/, '');
|
|
271
|
+
|
|
272
|
+
const LAYOUT = { vertical: 'VERTICAL', horizontal: 'HORIZONTAL' };
|
|
273
|
+
const ALIGN_MAIN = { start: 'MIN', center: 'CENTER', end: 'MAX', 'space-between': 'SPACE_BETWEEN' };
|
|
274
|
+
const ALIGN_CROSS = { start: 'MIN', center: 'CENTER', end: 'MAX' };
|
|
275
|
+
const SIZING = { fill: 'FILL', hug: 'HUG' };
|
|
276
|
+
const SIDES = ['top', 'right', 'bottom', 'left'];
|
|
277
|
+
|
|
278
|
+
const NODE_FIELDS = {
|
|
279
|
+
frame: ['layout', 'gap', 'padding', 'fill', 'stroke', 'radius', 'align', 'children', 'sizing', 'name'],
|
|
280
|
+
component: ['component', 'props', 'slots', 'sizing', 'name'],
|
|
281
|
+
text: ['text', 'style', 'color', 'sizing', 'name'],
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
function editDistance(a, b) {
|
|
285
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
286
|
+
for (let i = 1; i <= a.length; i++) {
|
|
287
|
+
let diag = row[0];
|
|
288
|
+
row[0] = i;
|
|
289
|
+
for (let j = 1; j <= b.length; j++) {
|
|
290
|
+
const above = row[j];
|
|
291
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
292
|
+
diag = above;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return row[b.length];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Up to three names, by longest shared folded prefix, then edit distance.
|
|
299
|
+
function nearestNames(asked, names) {
|
|
300
|
+
const want = composeFold(asked);
|
|
301
|
+
const shared = (f) => { let n = 0; while (n < f.length && n < want.length && f[n] === want[n]) n++; return n; };
|
|
302
|
+
return [...new Set(names)]
|
|
303
|
+
.map((name) => ({ name, f: composeFold(name) }))
|
|
304
|
+
.map((x) => ({ ...x, prefix: shared(x.f), dist: editDistance(want, x.f) }))
|
|
305
|
+
.sort((a, b) => b.prefix - a.prefix || a.dist - b.dist || a.name.localeCompare(b.name))
|
|
306
|
+
.slice(0, 3)
|
|
307
|
+
.map((x) => x.name);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const byFold = (list, asked, nameOf = (x) => x.name) =>
|
|
311
|
+
list.find((x) => nameOf(x) === asked) || list.find((x) => composeFold(nameOf(x)) === composeFold(asked));
|
|
312
|
+
|
|
313
|
+
function resolveScreen(description, inventory) {
|
|
314
|
+
const errors = [];
|
|
315
|
+
const fail = (kind, path, asked, nearest) =>
|
|
316
|
+
errors.push(nearest ? { kind, path, asked, nearest } : { kind, path, asked });
|
|
317
|
+
const inv = {
|
|
318
|
+
sets: [], components: [], variables: [], textStyles: [], collections: [], target: null,
|
|
319
|
+
...inventory,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const d = description && typeof description === 'object' ? description : {};
|
|
323
|
+
if (typeof d.screen !== 'string' || !d.screen.trim()) fail('invalid', 'screen', 'a screen name');
|
|
324
|
+
if (typeof d.page !== 'string' || !d.page.trim()) fail('invalid', 'page', 'a page name');
|
|
325
|
+
if (!(typeof d.width === 'number' && d.width > 0)) fail('invalid', 'width', 'a positive number');
|
|
326
|
+
if (d.height !== undefined && !(typeof d.height === 'number' && d.height > 0)) fail('invalid', 'height', 'a positive number');
|
|
327
|
+
if (!d.root || typeof d.root !== 'object') fail('invalid', 'root', 'a frame');
|
|
328
|
+
|
|
329
|
+
if (inv.target && typeof d.screen === 'string') {
|
|
330
|
+
if (inv.target.screen === 'unmarked') fail('name-taken', 'screen', d.screen);
|
|
331
|
+
if (inv.target.wip === 'unmarked') fail('name-taken', 'screen', `WIP: ${d.screen}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const modes = [];
|
|
335
|
+
for (const [collectionName, modeName] of Object.entries(d.modes || {})) {
|
|
336
|
+
const collection = byFold(inv.collections, collectionName);
|
|
337
|
+
const mode = collection && byFold(collection.modes, String(modeName));
|
|
338
|
+
if (!collection) fail('unknown-mode', `modes.${collectionName}`, collectionName, nearestNames(collectionName, inv.collections.map((c) => c.name)));
|
|
339
|
+
else if (!mode) fail('unknown-mode', `modes.${collectionName}`, String(modeName), nearestNames(String(modeName), collection.modes.map((m) => m.name)));
|
|
340
|
+
else modes.push({ collectionId: collection.id, modeId: mode.id });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function variable(name, kind, path) {
|
|
344
|
+
if (name === undefined || name === null) return null;
|
|
345
|
+
if (typeof name !== 'string') { fail('invalid', path, `a variable name, not ${JSON.stringify(name)}`); return null; }
|
|
346
|
+
const found = byFold(inv.variables, name);
|
|
347
|
+
if (!found) {
|
|
348
|
+
fail('unknown-variable', path, name, nearestNames(name, inv.variables.filter((v) => v.kinds.includes(kind)).map((v) => v.name)));
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
if (!found.kinds.includes(kind)) { fail('variable-kind', path, `${name} is not a ${kind} variable`); return null; }
|
|
352
|
+
return found.id;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// `fallback` fills an axis the description leaves out: 'hug' for frames (a new
|
|
356
|
+
// frame is a fixed 100×100 otherwise), null (leave alone) for text and instances.
|
|
357
|
+
function sizing(node, path, parent, fallback, hugAllowed = true) {
|
|
358
|
+
const s = node.sizing === undefined ? {} : node.sizing;
|
|
359
|
+
if (!s || typeof s !== 'object') { fail('invalid', `${path}.sizing`, 'an object { horizontal, vertical }'); return null; }
|
|
360
|
+
const out = {};
|
|
361
|
+
for (const axis of ['horizontal', 'vertical']) {
|
|
362
|
+
const v = s[axis] === undefined ? fallback : s[axis];
|
|
363
|
+
if (v === null) continue;
|
|
364
|
+
if (!SIZING[v]) { fail('invalid', `${path}.sizing.${axis}`, `fill or hug, not ${JSON.stringify(v)}`); continue; }
|
|
365
|
+
if (v === 'hug' && !hugAllowed) {
|
|
366
|
+
fail('invalid', `${path}.sizing.${axis}`, 'hug on a component without auto layout');
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (v === 'fill' && parent.slotLayout === 'NONE') {
|
|
370
|
+
fail('invalid', `${path}.sizing.${axis}`, 'fill inside a slot without auto layout');
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
out[axis] = SIZING[v];
|
|
374
|
+
}
|
|
375
|
+
for (const key of Object.keys(s)) if (key !== 'horizontal' && key !== 'vertical') fail('invalid', `${path}.sizing.${key}`, 'horizontal or vertical');
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function kindOf(node, path) {
|
|
380
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) { fail('invalid', path, 'a frame, component or text node'); return null; }
|
|
381
|
+
const isComponent = 'component' in node;
|
|
382
|
+
const isText = 'text' in node;
|
|
383
|
+
if (isComponent && isText) { fail('invalid', path, 'one of component or text, not both'); return null; }
|
|
384
|
+
const kind = isComponent ? 'component' : isText ? 'text' : 'frame';
|
|
385
|
+
for (const key of Object.keys(node)) if (!NODE_FIELDS[kind].includes(key)) fail('invalid', `${path}.${key}`, `a ${kind} field`);
|
|
386
|
+
return kind;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function frame(node, path, parent, isRoot) {
|
|
390
|
+
const layoutMode = LAYOUT[node.layout];
|
|
391
|
+
if (!layoutMode) fail('invalid', `${path}.layout`, `vertical or horizontal, not ${JSON.stringify(node.layout)}`);
|
|
392
|
+
let padding = null;
|
|
393
|
+
if (node.padding !== undefined) {
|
|
394
|
+
if (typeof node.padding === 'object' && node.padding !== null) {
|
|
395
|
+
padding = {};
|
|
396
|
+
for (const [side, name] of Object.entries(node.padding)) {
|
|
397
|
+
if (!SIDES.includes(side)) { fail('invalid', `${path}.padding.${side}`, 'top, right, bottom or left'); continue; }
|
|
398
|
+
padding[side] = variable(name, 'gap', `${path}.padding.${side}`);
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
const id = variable(node.padding, 'gap', `${path}.padding`);
|
|
402
|
+
padding = Object.fromEntries(SIDES.map((side) => [side, id]));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const align = node.align || {};
|
|
406
|
+
if (node.align !== undefined && (typeof node.align !== 'object' || node.align === null)) fail('invalid', `${path}.align`, 'an object { main, cross }');
|
|
407
|
+
if (align.main !== undefined && !ALIGN_MAIN[align.main]) fail('invalid', `${path}.align.main`, `start, center, end or space-between, not ${JSON.stringify(align.main)}`);
|
|
408
|
+
if (align.cross !== undefined && !ALIGN_CROSS[align.cross]) fail('invalid', `${path}.align.cross`, `start, center or end, not ${JSON.stringify(align.cross)}`);
|
|
409
|
+
if (isRoot && node.sizing !== undefined) fail('invalid', `${path}.sizing`, 'no sizing on the root; it takes width and height');
|
|
410
|
+
const children = node.children === undefined ? [] : node.children;
|
|
411
|
+
if (!Array.isArray(children)) fail('invalid', `${path}.children`, 'a list of nodes');
|
|
412
|
+
return {
|
|
413
|
+
type: 'frame',
|
|
414
|
+
name: typeof node.name === 'string' ? node.name : 'Stack',
|
|
415
|
+
layoutMode,
|
|
416
|
+
itemSpacing: variable(node.gap, 'gap', `${path}.gap`),
|
|
417
|
+
padding,
|
|
418
|
+
fill: variable(node.fill, 'colour', `${path}.fill`),
|
|
419
|
+
stroke: variable(node.stroke, 'colour', `${path}.stroke`),
|
|
420
|
+
radius: variable(node.radius, 'radius', `${path}.radius`),
|
|
421
|
+
primaryAxisAlignItems: ALIGN_MAIN[align.main] || 'MIN',
|
|
422
|
+
counterAxisAlignItems: ALIGN_CROSS[align.cross] || 'MIN',
|
|
423
|
+
sizing: isRoot ? null : sizing(node, path, parent, 'hug'),
|
|
424
|
+
children: Array.isArray(children)
|
|
425
|
+
? children.map((child, i) => resolveNode(child, `${path}.children[${i}]`, { slotLayout: null }))
|
|
426
|
+
: [],
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function component(node, path, parent) {
|
|
431
|
+
const set = byFold(inv.sets, String(node.component));
|
|
432
|
+
const sizingPlan = sizing(node, path, parent, null, !set || set.autoLayout !== false);
|
|
433
|
+
if (!set) {
|
|
434
|
+
fail('missing-component', `${path}.component`, String(node.component), nearestNames(String(node.component), inv.sets.map((s) => s.name)));
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const findProp = (name) => set.props.find((p) => composeFold(propBaseName(p.key)) === composeFold(name));
|
|
438
|
+
const propNames = set.props.map((p) => propBaseName(p.key));
|
|
439
|
+
const variantProps = {};
|
|
440
|
+
const otherProps = {};
|
|
441
|
+
for (const [name, value] of Object.entries(node.props || {})) {
|
|
442
|
+
const at = `${path}.props.${name}`;
|
|
443
|
+
const prop = findProp(name);
|
|
444
|
+
if (!prop) { fail('unknown-prop', at, name, nearestNames(name, propNames)); continue; }
|
|
445
|
+
if (prop.type === 'SLOT') { fail('invalid', at, 'slot content goes under slots, not props'); continue; }
|
|
446
|
+
if (prop.type === 'VARIANT') {
|
|
447
|
+
const option = (prop.options || []).find((o) => composeFold(o) === composeFold(value));
|
|
448
|
+
if (option === undefined) fail('unknown-option', at, String(value), nearestNames(String(value), prop.options || []));
|
|
449
|
+
else variantProps[prop.key] = option;
|
|
450
|
+
} else if (prop.type === 'TEXT') {
|
|
451
|
+
if (typeof value !== 'string') fail('prop-type', at, `text, not ${JSON.stringify(value)}`);
|
|
452
|
+
else otherProps[prop.key] = value;
|
|
453
|
+
} else if (prop.type === 'BOOLEAN') {
|
|
454
|
+
if (typeof value !== 'boolean') fail('prop-type', at, `true or false, not ${JSON.stringify(value)}`);
|
|
455
|
+
else otherProps[prop.key] = value;
|
|
456
|
+
} else if (prop.type === 'INSTANCE_SWAP') {
|
|
457
|
+
const target = typeof value === 'string' && byFold(inv.components, value);
|
|
458
|
+
if (!target) fail('missing-swap-target', at, String(value), nearestNames(String(value), inv.components.map((c) => c.name)));
|
|
459
|
+
else otherProps[prop.key] = target.id;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const slots = [];
|
|
463
|
+
for (const [name, nodes] of Object.entries(node.slots || {})) {
|
|
464
|
+
const at = `${path}.slots.${name}`;
|
|
465
|
+
const prop = findProp(name);
|
|
466
|
+
if (!prop || prop.type !== 'SLOT') {
|
|
467
|
+
fail('unknown-prop', at, name, nearestNames(name, set.props.filter((p) => p.type === 'SLOT').map((p) => propBaseName(p.key))));
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (!Array.isArray(nodes)) { fail('invalid', at, 'a list of nodes'); continue; }
|
|
471
|
+
slots.push({
|
|
472
|
+
key: prop.key,
|
|
473
|
+
nodes: nodes.map((child, i) => resolveNode(child, `${at}[${i}]`, { slotLayout: prop.slotLayout || 'NONE' })),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
type: 'component',
|
|
478
|
+
name: typeof node.name === 'string' ? node.name : set.name,
|
|
479
|
+
setId: set.id,
|
|
480
|
+
variantProps,
|
|
481
|
+
otherProps,
|
|
482
|
+
slots,
|
|
483
|
+
sizing: sizingPlan,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function text(node, path, parent) {
|
|
488
|
+
if (typeof node.text !== 'string') fail('invalid', `${path}.text`, 'the text to show');
|
|
489
|
+
let styleId = null;
|
|
490
|
+
if (typeof node.style !== 'string') fail('invalid', `${path}.style`, 'a text style name');
|
|
491
|
+
else {
|
|
492
|
+
const style = byFold(inv.textStyles, node.style);
|
|
493
|
+
if (!style) fail('unknown-text-style', `${path}.style`, node.style, nearestNames(node.style, inv.textStyles.map((s) => s.name)));
|
|
494
|
+
else styleId = style.id;
|
|
495
|
+
}
|
|
496
|
+
if (node.color === undefined) fail('invalid', `${path}.color`, 'a colour variable name');
|
|
497
|
+
return {
|
|
498
|
+
type: 'text',
|
|
499
|
+
name: typeof node.name === 'string' ? node.name : String(node.text).slice(0, 40),
|
|
500
|
+
characters: node.text,
|
|
501
|
+
styleId,
|
|
502
|
+
color: variable(node.color, 'colour', `${path}.color`),
|
|
503
|
+
sizing: sizing(node, path, parent, null),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function resolveNode(node, path, parent) {
|
|
508
|
+
const kind = kindOf(node, path);
|
|
509
|
+
if (kind === 'component') return component(node, path, parent);
|
|
510
|
+
if (kind === 'text') return text(node, path, parent);
|
|
511
|
+
if (kind === 'frame') return frame(node, path, parent, false);
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let root = null;
|
|
516
|
+
if (d.root && typeof d.root === 'object') {
|
|
517
|
+
const kind = kindOf(d.root, 'root');
|
|
518
|
+
if (kind === 'frame') root = frame(d.root, 'root', { slotLayout: null }, true);
|
|
519
|
+
else if (kind) fail('invalid', 'root', 'a frame');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const ok = errors.length === 0;
|
|
523
|
+
return {
|
|
524
|
+
ok,
|
|
525
|
+
errors,
|
|
526
|
+
plan: ok ? { screen: d.screen, page: d.page, width: d.width, height: d.height ?? null, modes, root } : null,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Figma plugin-API helpers for reading colour variables, shared by the Figma
|
|
531
|
+
// checks reader and the screen reader. This file is NOT a Node module:
|
|
532
|
+
// build-figma-checks.mjs inlines it into both generated scripts
|
|
533
|
+
// (references/figma-checks.md, references/figma-adherence.md). It depends on
|
|
534
|
+
// nothing else, so it carries its own mode-name fold. Async getters only
|
|
535
|
+
// (references/figma-scripting.md). Untested in Node, like the readers.
|
|
536
|
+
|
|
537
|
+
const foldModeName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
538
|
+
|
|
539
|
+
function colorHex(value) {
|
|
540
|
+
if (!value || typeof value !== 'object' || !('r' in value)) return null;
|
|
541
|
+
const byte = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
542
|
+
const alpha = value.a === undefined ? 1 : value.a;
|
|
543
|
+
return `#${byte(value.r)}${byte(value.g)}${byte(value.b)}${alpha < 1 ? byte(alpha) : ''}`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// The value of `variable` in the mode named `modeName` (after the fold), else
|
|
547
|
+
// its collection's default mode, following aliases up to 10 hops.
|
|
548
|
+
async function resolveColor(variable, modeName, hops) {
|
|
549
|
+
if (!variable || hops > 10) return null;
|
|
550
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
551
|
+
if (!collection) return null;
|
|
552
|
+
const mode = collection.modes.find((m) => foldModeName(m.name) === foldModeName(modeName));
|
|
553
|
+
const value = variable.valuesByMode[mode ? mode.modeId : collection.defaultModeId];
|
|
554
|
+
if (value && value.type === 'VARIABLE_ALIAS') {
|
|
555
|
+
return resolveColor(await figma.variables.getVariableByIdAsync(value.id), modeName, hops + 1);
|
|
556
|
+
}
|
|
557
|
+
return colorHex(value);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// { modes: { [modeName]: hex | null }, defaultMode } over the variable's own collection.
|
|
561
|
+
async function variableColors(variable) {
|
|
562
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
563
|
+
if (!collection) return { modes: {}, defaultMode: undefined };
|
|
564
|
+
const modes = {};
|
|
565
|
+
for (const mode of collection.modes) modes[mode.name] = await resolveColor(variable, mode.name, 0);
|
|
566
|
+
const fallback = collection.modes.find((m) => m.modeId === collection.defaultModeId);
|
|
567
|
+
return { modes, defaultMode: fallback ? fallback.name : undefined };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Figma plugin-API reader and writer for screen composition. This file is NOT a
|
|
571
|
+
// Node module — build-figma-checks.mjs concatenates it after the inlined
|
|
572
|
+
// figma-adherence.mjs (for dimensionBuckets), figma-compose.mjs (resolveScreen)
|
|
573
|
+
// and figma-variables-read.figma.js into references/figma-compose.md, and the
|
|
574
|
+
// result runs inside figma_execute.
|
|
575
|
+
// Constraints honored here (see references/figma-scripting.md and the spec,
|
|
576
|
+
// docs/specs/2026-10-13-compose-figma-screen.md): async getters only; only the
|
|
577
|
+
// named pages are loaded; never instance.mainComponent; never getNodeByIdAsync in
|
|
578
|
+
// a loop (nodes come from the pages already loaded). Nothing is written until the
|
|
579
|
+
// whole description resolves. Every choice of key, value and variable is made by
|
|
580
|
+
// the unit-tested resolveScreen; this file only applies its plan. Untested in Node
|
|
581
|
+
// beyond the generator's fake-Figma test.
|
|
582
|
+
// Awaits are few and fixed, not per node: every font the screen needs is loaded
|
|
583
|
+
// in one batch before writing, and every text style is applied in one batch
|
|
584
|
+
// after. Live on 2026-10-13, a build awaiting per text layer stalled after about
|
|
585
|
+
// twenty awaits and timed out; the bridge then resumed it only when later calls
|
|
586
|
+
// arrived (references/figma-scripting.md, "Pass an explicit timeout").
|
|
587
|
+
//
|
|
588
|
+
// Entry point: runCompose(MODE, SPEC), MODE one of
|
|
589
|
+
// 'inventory' SPEC = { componentsPage, iconsPage? } → { summary }
|
|
590
|
+
// 'build' SPEC = { componentsPage, iconsPage?, description, fileKey }
|
|
591
|
+
// → { ok, errors } | { ok, wipId, wipName, link, ms }
|
|
592
|
+
// 'finalize' SPEC = { page, screen, fileKey } → { id, link, replaced }
|
|
593
|
+
// 'discard' SPEC = { page, screen } → { removed }
|
|
594
|
+
|
|
595
|
+
const COMPOSE_NS = 'throughline';
|
|
596
|
+
const COMPOSE_KEY = 'composedScreen';
|
|
597
|
+
const COMPOSE_CORNERS = ['topLeftRadius', 'topRightRadius', 'bottomRightRadius', 'bottomLeftRadius'];
|
|
598
|
+
const COMPOSE_PADDING = { top: 'paddingTop', right: 'paddingRight', bottom: 'paddingBottom', left: 'paddingLeft' };
|
|
599
|
+
|
|
600
|
+
// Exact name first, else the one page whose trimmed name matches (01. Design
|
|
601
|
+
// System's pages are " ↳ Templates"). Null when none matches; two trimmed
|
|
602
|
+
// matches throw, as the gate's findPage does (which isn't inlined here).
|
|
603
|
+
function composeFindPage(name) {
|
|
604
|
+
const exact = figma.root.children.find((p) => p.name === name);
|
|
605
|
+
if (exact) return exact;
|
|
606
|
+
const trimmed = figma.root.children.filter((p) => p.name.trim() === String(name).trim());
|
|
607
|
+
if (trimmed.length > 1) throw new Error(`${trimmed.length} pages are named "${String(name).trim()}"; rename one or pass the exact name`);
|
|
608
|
+
return trimmed.length === 1 ? trimmed[0] : null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function composeRequirePage(name) {
|
|
612
|
+
const page = composeFindPage(name);
|
|
613
|
+
if (!page) throw new Error(`no page named "${name}"`);
|
|
614
|
+
return page;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const composeIsMarked = (node) => node.getSharedPluginData(COMPOSE_NS, COMPOSE_KEY) !== '';
|
|
618
|
+
// Screens as the gate counts them: top-level frames, and frames directly inside a
|
|
619
|
+
// top-level section.
|
|
620
|
+
function composeTopFrames(page, name) {
|
|
621
|
+
const all = [];
|
|
622
|
+
for (const top of page.children) {
|
|
623
|
+
if (top.type === 'FRAME') all.push(top);
|
|
624
|
+
else if (top.type === 'SECTION') for (const c of top.children) if (c.type === 'FRAME') all.push(c);
|
|
625
|
+
}
|
|
626
|
+
return all.filter((n) => n.name === name);
|
|
627
|
+
}
|
|
628
|
+
const composeLink = (fileKey, id) => `https://www.figma.com/design/${fileKey}?node-id=${String(id).replace(/:/g, '-')}`;
|
|
629
|
+
|
|
630
|
+
// 'marked' | 'unmarked' | null for the top-level frames called `name`.
|
|
631
|
+
function composeNameState(page, name) {
|
|
632
|
+
const frames = composeTopFrames(page, name);
|
|
633
|
+
if (!frames.length) return null;
|
|
634
|
+
return frames.every(composeIsMarked) ? 'marked' : 'unmarked';
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Reads the INVENTORY documented in figma-compose.mjs, plus the live nodes the
|
|
638
|
+
// writer needs, keyed by id.
|
|
639
|
+
async function composeReadInventory(spec) {
|
|
640
|
+
const pages = [spec.componentsPage, spec.iconsPage].filter(Boolean).map(composeRequirePage);
|
|
641
|
+
const setNodes = new Map();
|
|
642
|
+
const sets = [];
|
|
643
|
+
const components = [];
|
|
644
|
+
for (const page of pages) {
|
|
645
|
+
await page.loadAsync();
|
|
646
|
+
for (const set of page.findAllWithCriteria({ types: ['COMPONENT_SET'] })) {
|
|
647
|
+
setNodes.set(set.id, set);
|
|
648
|
+
// Every variant, since the writer switches variant before filling a slot.
|
|
649
|
+
const slotNodes = set.findAll((n) => n.type === 'SLOT');
|
|
650
|
+
const props = Object.entries(set.componentPropertyDefinitions).map(([key, def]) => {
|
|
651
|
+
const prop = { key, name: key.replace(/#[^#]*$/, ''), type: def.type };
|
|
652
|
+
if (def.variantOptions) prop.options = def.variantOptions;
|
|
653
|
+
if (def.type === 'SLOT') {
|
|
654
|
+
const slots = slotNodes.filter((n) => n.componentPropertyReferences && n.componentPropertyReferences.slotContentId === key);
|
|
655
|
+
prop.slotLayout = slots.length && slots.every((n) => n.layoutMode !== 'NONE') ? slots[0].layoutMode : 'NONE';
|
|
656
|
+
}
|
|
657
|
+
return prop;
|
|
658
|
+
});
|
|
659
|
+
const autoLayout = set.children.every((v) => v.layoutMode && v.layoutMode !== 'NONE');
|
|
660
|
+
sets.push({ name: set.name, id: set.id, page: page.name, autoLayout, props });
|
|
661
|
+
}
|
|
662
|
+
for (const component of page.findAllWithCriteria({ types: ['COMPONENT'] })) {
|
|
663
|
+
if (component.parent && component.parent.type === 'COMPONENT_SET') continue;
|
|
664
|
+
components.push({ name: component.name, id: component.id, page: page.name });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const variableNodes = new Map();
|
|
668
|
+
const variables = [];
|
|
669
|
+
for (const variable of await figma.variables.getLocalVariablesAsync()) {
|
|
670
|
+
variableNodes.set(variable.id, variable);
|
|
671
|
+
let kinds = [];
|
|
672
|
+
if (variable.resolvedType === 'COLOR') kinds = ['colour'];
|
|
673
|
+
else if (variable.resolvedType === 'FLOAT') {
|
|
674
|
+
const buckets = dimensionBuckets(variable.name, variable.scopes || []);
|
|
675
|
+
kinds = [buckets.gap && 'gap', buckets.radius && 'radius'].filter(Boolean);
|
|
676
|
+
}
|
|
677
|
+
variables.push({ name: variable.name, id: variable.id, kinds });
|
|
678
|
+
}
|
|
679
|
+
const styleNodes = new Map();
|
|
680
|
+
const textStyles = [];
|
|
681
|
+
for (const style of await figma.getLocalTextStylesAsync()) {
|
|
682
|
+
styleNodes.set(style.id, style);
|
|
683
|
+
textStyles.push({ name: style.name, id: style.id });
|
|
684
|
+
}
|
|
685
|
+
const collectionNodes = new Map();
|
|
686
|
+
const collections = [];
|
|
687
|
+
for (const collection of await figma.variables.getLocalVariableCollectionsAsync()) {
|
|
688
|
+
collectionNodes.set(collection.id, collection);
|
|
689
|
+
collections.push({ name: collection.name, id: collection.id, modes: collection.modes.map((m) => ({ name: m.name, id: m.modeId })) });
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
inventory: { sets, components, variables, textStyles, collections, target: null },
|
|
693
|
+
live: { setNodes, variableNodes, collectionNodes, styleNodes, styled: [] },
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// What the planner reads: names only, grouped.
|
|
698
|
+
function composeSummary(inventory) {
|
|
699
|
+
const byKind = (kind) => inventory.variables.filter((v) => v.kinds.includes(kind)).map((v) => v.name);
|
|
700
|
+
return {
|
|
701
|
+
sets: inventory.sets.map((s) => ({
|
|
702
|
+
name: s.name,
|
|
703
|
+
autoLayout: s.autoLayout,
|
|
704
|
+
props: Object.fromEntries(s.props.map((p) => [
|
|
705
|
+
p.name,
|
|
706
|
+
p.type === 'VARIANT' ? p.options : p.type === 'SLOT' ? `SLOT (${p.slotLayout})` : p.type,
|
|
707
|
+
])),
|
|
708
|
+
})),
|
|
709
|
+
swapTargets: inventory.components.map((c) => c.name),
|
|
710
|
+
variables: { colour: byKind('colour'), gap: byKind('gap'), radius: byKind('radius') },
|
|
711
|
+
textStyles: inventory.textStyles.map((s) => s.name),
|
|
712
|
+
modes: Object.fromEntries(inventory.collections.map((c) => [c.name, c.modes.map((m) => m.name)])),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function composeBoundPaint(variable) {
|
|
717
|
+
return figma.variables.setBoundVariableForPaint({ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, 'color', variable);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Only the axes the plan names; the others stay as Figma made the node.
|
|
721
|
+
function composeSizing(node, sizing) {
|
|
722
|
+
if (!sizing) return;
|
|
723
|
+
if (sizing.horizontal) node.layoutSizingHorizontal = sizing.horizontal;
|
|
724
|
+
if (sizing.vertical) node.layoutSizingVertical = sizing.vertical;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function composeStyleFrame(frame, plan, live) {
|
|
728
|
+
const v = (id) => live.variableNodes.get(id);
|
|
729
|
+
frame.name = plan.name;
|
|
730
|
+
frame.layoutMode = plan.layoutMode;
|
|
731
|
+
frame.primaryAxisAlignItems = plan.primaryAxisAlignItems;
|
|
732
|
+
frame.counterAxisAlignItems = plan.counterAxisAlignItems;
|
|
733
|
+
// A new frame starts with a white fill; left in place it would be a raw colour.
|
|
734
|
+
frame.fills = plan.fill ? [composeBoundPaint(v(plan.fill))] : [];
|
|
735
|
+
if (plan.stroke) {
|
|
736
|
+
frame.strokes = [composeBoundPaint(v(plan.stroke))];
|
|
737
|
+
frame.strokeWeight = 1;
|
|
738
|
+
}
|
|
739
|
+
if (plan.itemSpacing) frame.setBoundVariable('itemSpacing', v(plan.itemSpacing));
|
|
740
|
+
for (const [side, id] of Object.entries(plan.padding || {})) {
|
|
741
|
+
if (id) frame.setBoundVariable(COMPOSE_PADDING[side], v(id));
|
|
742
|
+
}
|
|
743
|
+
if (plan.radius) for (const corner of COMPOSE_CORNERS) frame.setBoundVariable(corner, v(plan.radius));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const composeFontKey = (f) => `${f.family}\u0000${f.style}`;
|
|
747
|
+
|
|
748
|
+
// Every font the plan can touch: each text style it uses, every text layer in
|
|
749
|
+
// each component set it places (any variant can be switched to), and Inter
|
|
750
|
+
// Regular, which a new text node starts in.
|
|
751
|
+
function composeFontsFor(plan, live) {
|
|
752
|
+
const fonts = new Map([[composeFontKey({ family: 'Inter', style: 'Regular' }), { family: 'Inter', style: 'Regular' }]]);
|
|
753
|
+
const add = (f) => fonts.set(composeFontKey(f), { family: f.family, style: f.style });
|
|
754
|
+
const seenSets = new Set();
|
|
755
|
+
const walk = (node) => {
|
|
756
|
+
if (!node) return;
|
|
757
|
+
if (node.type === 'text') add(live.styleNodes.get(node.styleId).fontName);
|
|
758
|
+
if (node.type === 'component' && !seenSets.has(node.setId)) {
|
|
759
|
+
seenSets.add(node.setId);
|
|
760
|
+
for (const text of live.setNodes.get(node.setId).findAll((n) => n.type === 'TEXT')) {
|
|
761
|
+
if (text.fontName !== figma.mixed) add(text.fontName);
|
|
762
|
+
else for (const f of text.getRangeAllFontNames(0, text.characters.length)) add(f);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
for (const child of node.children || []) walk(child);
|
|
766
|
+
for (const slot of node.slots || []) slot.nodes.forEach(walk);
|
|
767
|
+
};
|
|
768
|
+
walk(plan.root);
|
|
769
|
+
return [...fonts.values()];
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Synchronous: fonts are already loaded, and text styles are applied afterwards
|
|
773
|
+
// in one batch from live.styled.
|
|
774
|
+
function composeNode(plan, parent, live) {
|
|
775
|
+
if (plan.type === 'frame') {
|
|
776
|
+
const frame = figma.createFrame();
|
|
777
|
+
parent.appendChild(frame);
|
|
778
|
+
composeStyleFrame(frame, plan, live);
|
|
779
|
+
for (const child of plan.children) composeNode(child, frame, live);
|
|
780
|
+
composeSizing(frame, plan.sizing);
|
|
781
|
+
return frame;
|
|
782
|
+
}
|
|
783
|
+
if (plan.type === 'text') {
|
|
784
|
+
const text = figma.createText();
|
|
785
|
+
parent.appendChild(text);
|
|
786
|
+
text.name = plan.name;
|
|
787
|
+
text.characters = plan.characters;
|
|
788
|
+
text.fills = [composeBoundPaint(live.variableNodes.get(plan.color))];
|
|
789
|
+
composeSizing(text, plan.sizing);
|
|
790
|
+
live.styled.push([text, plan.styleId]);
|
|
791
|
+
return text;
|
|
792
|
+
}
|
|
793
|
+
const set = live.setNodes.get(plan.setId);
|
|
794
|
+
const instance = set.defaultVariant.createInstance();
|
|
795
|
+
parent.appendChild(instance);
|
|
796
|
+
instance.name = plan.name;
|
|
797
|
+
// Variant first: switching variant can swap the text layers the TEXT props drive.
|
|
798
|
+
if (Object.keys(plan.variantProps).length) instance.setProperties(plan.variantProps);
|
|
799
|
+
if (Object.keys(plan.otherProps).length) instance.setProperties(plan.otherProps);
|
|
800
|
+
for (const slotPlan of plan.slots) {
|
|
801
|
+
const slot = instance.findAll((n) => n.type === 'SLOT')
|
|
802
|
+
.find((n) => n.componentPropertyReferences && n.componentPropertyReferences.slotContentId === slotPlan.key);
|
|
803
|
+
if (!slot) throw new Error(`no slot ${slotPlan.key} on ${set.name}`);
|
|
804
|
+
for (const placeholder of [...slot.children]) placeholder.remove();
|
|
805
|
+
for (const child of slotPlan.nodes) composeNode(child, slot, live);
|
|
806
|
+
}
|
|
807
|
+
composeSizing(instance, plan.sizing);
|
|
808
|
+
return instance;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function composeBuild(spec) {
|
|
812
|
+
const started = Date.now();
|
|
813
|
+
const { inventory, live } = await composeReadInventory(spec);
|
|
814
|
+
const d = spec.description || {};
|
|
815
|
+
let page = typeof d.page === 'string' ? composeFindPage(d.page) : null;
|
|
816
|
+
if (page) {
|
|
817
|
+
await page.loadAsync();
|
|
818
|
+
inventory.target = { screen: composeNameState(page, d.screen), wip: composeNameState(page, `WIP: ${d.screen}`) };
|
|
819
|
+
}
|
|
820
|
+
const resolved = resolveScreen(d, inventory);
|
|
821
|
+
if (!resolved.ok) return { ok: false, errors: resolved.errors };
|
|
822
|
+
const plan = resolved.plan;
|
|
823
|
+
await Promise.all(composeFontsFor(plan, live).map((f) => figma.loadFontAsync(f)));
|
|
824
|
+
const createdPage = !page;
|
|
825
|
+
if (!page) {
|
|
826
|
+
page = figma.createPage();
|
|
827
|
+
page.name = plan.page;
|
|
828
|
+
}
|
|
829
|
+
const wipName = `WIP: ${plan.screen}`;
|
|
830
|
+
for (const old of composeTopFrames(page, wipName)) old.remove();
|
|
831
|
+
const right = page.children.reduce((max, n) => Math.max(max, n.x + n.width), 0);
|
|
832
|
+
const root = figma.createFrame();
|
|
833
|
+
try {
|
|
834
|
+
page.appendChild(root);
|
|
835
|
+
root.setSharedPluginData(COMPOSE_NS, COMPOSE_KEY, JSON.stringify(d));
|
|
836
|
+
composeStyleFrame(root, plan.root, live);
|
|
837
|
+
root.name = wipName;
|
|
838
|
+
root.x = page.children.length > 1 ? right + 200 : 0;
|
|
839
|
+
root.y = 0;
|
|
840
|
+
root.resize(plan.width, plan.height || 100);
|
|
841
|
+
root.layoutSizingHorizontal = 'FIXED';
|
|
842
|
+
root.layoutSizingVertical = plan.height ? 'FIXED' : 'HUG';
|
|
843
|
+
for (const { collectionId, modeId } of plan.modes) {
|
|
844
|
+
root.setExplicitVariableModeForCollection(live.collectionNodes.get(collectionId), modeId);
|
|
845
|
+
}
|
|
846
|
+
for (const child of plan.root.children) composeNode(child, root, live);
|
|
847
|
+
await Promise.all(live.styled.map(([text, styleId]) => text.setTextStyleIdAsync(styleId)));
|
|
848
|
+
} catch (error) {
|
|
849
|
+
// A throw mid-write is a composer bug, not a finding: leave nothing behind.
|
|
850
|
+
root.remove();
|
|
851
|
+
if (createdPage) page.remove();
|
|
852
|
+
throw new Error(`build failed and was rolled back, nothing written: ${error.message}`);
|
|
853
|
+
}
|
|
854
|
+
return { ok: true, wipId: root.id, wipName, link: composeLink(spec.fileKey || figma.fileKey, root.id), ms: Date.now() - started };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function composeFinalize(spec) {
|
|
858
|
+
const page = composeRequirePage(spec.page);
|
|
859
|
+
await page.loadAsync();
|
|
860
|
+
const wips = composeTopFrames(page, `WIP: ${spec.screen}`);
|
|
861
|
+
if (wips.length !== 1 || !composeIsMarked(wips[0])) {
|
|
862
|
+
throw new Error(`expected one composed frame "WIP: ${spec.screen}" on "${page.name}", found ${wips.length}${wips.length === 1 ? ' without the composed marker' : ''}`);
|
|
863
|
+
}
|
|
864
|
+
const wip = wips[0];
|
|
865
|
+
const existing = composeTopFrames(page, spec.screen);
|
|
866
|
+
if (existing.some((n) => !composeIsMarked(n))) {
|
|
867
|
+
throw new Error(`"${spec.screen}" on "${page.name}" wasn't composed by throughline; not replacing it. "WIP: ${spec.screen}" is left in place.`);
|
|
868
|
+
}
|
|
869
|
+
if (existing.length) {
|
|
870
|
+
const old = existing[0];
|
|
871
|
+
old.parent.insertChild(old.parent.children.indexOf(old), wip);
|
|
872
|
+
wip.x = old.x;
|
|
873
|
+
wip.y = old.y;
|
|
874
|
+
for (const n of existing) n.remove();
|
|
875
|
+
}
|
|
876
|
+
wip.name = spec.screen;
|
|
877
|
+
return { id: wip.id, link: composeLink(spec.fileKey || figma.fileKey, wip.id), replaced: existing.length > 0 };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function composeDiscard(spec) {
|
|
881
|
+
const page = composeRequirePage(spec.page);
|
|
882
|
+
await page.loadAsync();
|
|
883
|
+
const wips = composeTopFrames(page, `WIP: ${spec.screen}`);
|
|
884
|
+
if (wips.some((n) => !composeIsMarked(n))) throw new Error(`"WIP: ${spec.screen}" wasn't composed by throughline; not removing it`);
|
|
885
|
+
for (const wip of wips) wip.remove();
|
|
886
|
+
return { removed: wips.length };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function runCompose(MODE, SPEC) {
|
|
890
|
+
if (MODE === 'inventory') return { summary: composeSummary((await composeReadInventory(SPEC)).inventory) };
|
|
891
|
+
if (MODE === 'build') return composeBuild(SPEC);
|
|
892
|
+
if (MODE === 'finalize') return composeFinalize(SPEC);
|
|
893
|
+
if (MODE === 'discard') return composeDiscard(SPEC);
|
|
894
|
+
throw new Error(`unknown MODE ${JSON.stringify(MODE)}: inventory, build, finalize or discard`);
|
|
895
|
+
}
|
|
896
|
+
```
|