@radicool/throughline 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +8 -4
  2. package/adapters/codex/AGENTS.md +2 -0
  3. package/adapters/codex/prompts/component-builder.md +5 -3
  4. package/adapters/codex/prompts/compose-code.md +78 -0
  5. package/adapters/codex/prompts/compose-figma.md +59 -0
  6. package/adapters/codex/prompts/document-component.md +3 -2
  7. package/adapters/cursor/.cursor/commands/compose-code.md +78 -0
  8. package/adapters/cursor/.cursor/commands/compose-figma.md +59 -0
  9. package/adapters/cursor/.cursor/commands/document-component.md +3 -2
  10. package/adapters/cursor/.cursor/rules/component-builder.mdc +5 -3
  11. package/adapters/generic/AGENTS.md +2 -0
  12. package/adapters/generic/commands/compose-code.md +78 -0
  13. package/adapters/generic/commands/compose-figma.md +59 -0
  14. package/adapters/generic/commands/document-component.md +3 -2
  15. package/adapters/generic/skills/component-builder/SKILL.md +5 -3
  16. package/package.json +1 -1
  17. package/references/doc-card-builder.md +62 -15
  18. package/references/figma-adherence.md +21 -0
  19. package/references/figma-compose.md +896 -0
  20. package/scripts/README.md +8 -4
  21. package/scripts/build-doc-card-builder.mjs +16 -0
  22. package/scripts/build-figma-checks.mjs +89 -2
  23. package/scripts/compose-code.mjs +271 -0
  24. package/scripts/doc-card-payload.mjs +115 -0
  25. package/scripts/install.mjs +5 -3
  26. package/scripts/lib/doc-card-plan.mjs +37 -0
  27. package/scripts/lib/doc-card-render.figma.js +9 -15
  28. package/scripts/lib/figma-adherence-read.figma.js +21 -0
  29. package/scripts/lib/figma-compose.figma.js +326 -0
  30. package/scripts/lib/figma-compose.mjs +317 -0
  31. package/scripts/lib/fixtures/figma-compose/casing-and-suffix.json +8 -0
  32. package/scripts/lib/fixtures/figma-compose/fill-in-unlaid-slot.json +7 -0
  33. package/scripts/lib/fixtures/figma-compose/hug-on-fixed-component.json +34 -0
  34. package/scripts/lib/fixtures/figma-compose/inventory.json +295 -0
  35. package/scripts/lib/fixtures/figma-compose/many-errors.json +8 -0
  36. package/scripts/lib/fixtures/figma-compose/missing-component.json +7 -0
  37. package/scripts/lib/fixtures/figma-compose/name-taken.json +6 -0
  38. package/scripts/lib/fixtures/figma-compose/padding-sides.json +23 -0
  39. package/scripts/lib/fixtures/figma-compose/radius-kind.json +27 -0
  40. package/scripts/lib/fixtures/figma-compose/raw-number.json +5 -0
  41. package/scripts/lib/fixtures/figma-compose/sign-in-ok.json +16 -0
  42. package/scripts/lib/fixtures/figma-compose/slot-ok.json +9 -0
  43. package/scripts/lib/fixtures/figma-compose/unknown-prop-and-option.json +7 -0
  44. 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
+ ```