@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
@@ -99,3 +99,40 @@ export function planDocCard(record, bodyTextStyle) {
99
99
  rows,
100
100
  };
101
101
  }
102
+
103
+ // 32-bit FNV-1a — the render hash: fnv1a(JSON.stringify(plan)). Only ever
104
+ // compared against itself (the manifest's surfaces.docCard.render), so it does
105
+ // not need to match the sha256-based canonical fingerprint, which cannot run in
106
+ // the Figma sandbox. Lives here, not in the renderer, so Node can compute the
107
+ // hash a render should return (doc-card-payload.mjs) with the same code.
108
+ export function fnv1a(str) {
109
+ let h = 0x811c9dc5;
110
+ for (let i = 0; i < str.length; i++) {
111
+ h ^= str.charCodeAt(i);
112
+ h = Math.imul(h, 0x01000193) >>> 0;
113
+ }
114
+ return h.toString(16).padStart(8, '0');
115
+ }
116
+
117
+ // When a header's title row has no node named `Status Pill`/`Status`, name the
118
+ // node that most likely IS the chip so the error can say what to rename. Never
119
+ // used to pick a node to write — the renderer still refuses to guess. A
120
+ // candidate is a FRAME below the title row whose only child is a TEXT node
121
+ // (the chip shape: a pill frame around its label). Exactly one candidate or
122
+ // no hint. Reads only type/name/id/characters/children, so it runs on Figma
123
+ // nodes and on plain test objects alike.
124
+ export function statusChipHint(titleRow) {
125
+ if (!titleRow || titleRow.type !== 'FRAME') return null;
126
+ const found = [];
127
+ const walk = (node) => {
128
+ for (const child of node.children || []) {
129
+ if (child.type === 'FRAME' && child.children && child.children.length === 1
130
+ && child.children[0].type === 'TEXT') {
131
+ found.push({ id: child.id, name: child.name, label: child.children[0].characters });
132
+ }
133
+ walk(child);
134
+ }
135
+ };
136
+ walk(titleRow);
137
+ return found.length === 1 ? found[0] : null;
138
+ }
@@ -6,20 +6,8 @@
6
6
  // BEFORE .characters, fonts loaded up front, resize() sizing modes re-asserted,
7
7
  // bound paints seeded light-gray (never pure black).
8
8
  //
9
- // In-scope globals when assembled: planDocCard, DOC_CARD_RENDERER_VERSION
10
- // (inlined planner) and the caller-filled slots RECORD, CANONICAL_FP.
11
-
12
- // 32-bit FNV-1a — the render hash. Only ever compared against itself (the
13
- // manifest's surfaces.docCard.render), so it does not need to match the
14
- // sha256-based canonical fingerprint, which cannot run in the Figma sandbox.
15
- function fnv1a(str) {
16
- let h = 0x811c9dc5;
17
- for (let i = 0; i < str.length; i++) {
18
- h ^= str.charCodeAt(i);
19
- h = Math.imul(h, 0x01000193) >>> 0;
20
- }
21
- return h.toString(16).padStart(8, '0');
22
- }
9
+ // In-scope globals when assembled: planDocCard, DOC_CARD_RENDERER_VERSION,
10
+ // fnv1a, statusChipHint (inlined planner) and the caller-filled slots RECORD, CANONICAL_FP.
23
11
 
24
12
  const REQUIRED_VARS = [
25
13
  'textDefault', 'textMuted', 'tonePositive', 'toneNegative', 'border',
@@ -151,7 +139,13 @@ async function renderDocCard({ card, record, vars, bodyTextStyle }) {
151
139
  const descAmbiguous = !named && descCandidates.length > 1;
152
140
 
153
141
  const missingAnchors = [];
154
- if (!hasStatusAnchor) missingAnchors.push('status (title row must contain a descendant named "Status Pill" or "Status")');
142
+ if (!hasStatusAnchor) {
143
+ const hint = statusChipHint(titleRow);
144
+ missingAnchors.push('status (title row must contain a descendant named "Status Pill" or "Status"'
145
+ + (hint ? ' — frame ' + hint.id + ' "' + hint.name + '" holding "' + hint.label
146
+ + '" looks like the status chip; rename it "Status Pill" and re-run' : '')
147
+ + ')');
148
+ }
155
149
  if (!headerDescCandidate) {
156
150
  missingAnchors.push(descAmbiguous
157
151
  ? 'description (found ' + descCandidates.length + ' candidate TEXT children, cannot tell which is the description — name the right one "Header Description" by hand and re-run)'
@@ -111,6 +111,19 @@ function overriddenRecords(instance, path, underDetached) {
111
111
  return records;
112
112
  }
113
113
 
114
+ // The SLOT nodes on this instance's own surface: its slots, and the slots of
115
+ // instances nested in its component, reached only through component sublayers
116
+ // (ids 'I…'). A slot inside content added to a slot is left out here; the walk
117
+ // reaches it through that content.
118
+ function slotsOf(instance) {
119
+ return instance.findAll((n) => n.type === 'SLOT').filter((slot) => {
120
+ for (let node = slot.parent; node && node !== instance; node = node.parent) {
121
+ if (!String(node.id).startsWith('I')) return false;
122
+ }
123
+ return true;
124
+ });
125
+ }
126
+
114
127
  function screensOn(page, names) {
115
128
  const all = [];
116
129
  for (const top of page.children) {
@@ -171,6 +184,14 @@ async function readScreens(target) {
171
184
  if (node.visible === false) return;
172
185
  if (node.type === 'INSTANCE') {
173
186
  nodes.push(...overriddenRecords(node, path, insideDetached));
187
+ // Content added to a slot is the screen's own, and isn't listed in
188
+ // instance.overrides. A slot's placeholder children keep instance-sublayer
189
+ // ids ('I…;…') and stay part of the component.
190
+ for (const slot of slotsOf(node)) {
191
+ for (const child of slot.children) {
192
+ if (!String(child.id).startsWith('I')) walk(child, `${path} / ${slot.name} / ${child.name}`, false, insideDetached);
193
+ }
194
+ }
174
195
  return;
175
196
  }
176
197
  const mixedPaints = node.fills === figma.mixed;
@@ -0,0 +1,326 @@
1
+ // Figma plugin-API reader and writer for screen composition. This file is NOT a
2
+ // Node module — build-figma-checks.mjs concatenates it after the inlined
3
+ // figma-adherence.mjs (for dimensionBuckets), figma-compose.mjs (resolveScreen)
4
+ // and figma-variables-read.figma.js into references/figma-compose.md, and the
5
+ // result runs inside figma_execute.
6
+ // Constraints honored here (see references/figma-scripting.md and the spec,
7
+ // docs/specs/2026-10-13-compose-figma-screen.md): async getters only; only the
8
+ // named pages are loaded; never instance.mainComponent; never getNodeByIdAsync in
9
+ // a loop (nodes come from the pages already loaded). Nothing is written until the
10
+ // whole description resolves. Every choice of key, value and variable is made by
11
+ // the unit-tested resolveScreen; this file only applies its plan. Untested in Node
12
+ // beyond the generator's fake-Figma test.
13
+ // Awaits are few and fixed, not per node: every font the screen needs is loaded
14
+ // in one batch before writing, and every text style is applied in one batch
15
+ // after. Live on 2026-10-13, a build awaiting per text layer stalled after about
16
+ // twenty awaits and timed out; the bridge then resumed it only when later calls
17
+ // arrived (references/figma-scripting.md, "Pass an explicit timeout").
18
+ //
19
+ // Entry point: runCompose(MODE, SPEC), MODE one of
20
+ // 'inventory' SPEC = { componentsPage, iconsPage? } → { summary }
21
+ // 'build' SPEC = { componentsPage, iconsPage?, description, fileKey }
22
+ // → { ok, errors } | { ok, wipId, wipName, link, ms }
23
+ // 'finalize' SPEC = { page, screen, fileKey } → { id, link, replaced }
24
+ // 'discard' SPEC = { page, screen } → { removed }
25
+
26
+ const COMPOSE_NS = 'throughline';
27
+ const COMPOSE_KEY = 'composedScreen';
28
+ const COMPOSE_CORNERS = ['topLeftRadius', 'topRightRadius', 'bottomRightRadius', 'bottomLeftRadius'];
29
+ const COMPOSE_PADDING = { top: 'paddingTop', right: 'paddingRight', bottom: 'paddingBottom', left: 'paddingLeft' };
30
+
31
+ // Exact name first, else the one page whose trimmed name matches (01. Design
32
+ // System's pages are " ↳ Templates"). Null when none matches; two trimmed
33
+ // matches throw, as the gate's findPage does (which isn't inlined here).
34
+ function composeFindPage(name) {
35
+ const exact = figma.root.children.find((p) => p.name === name);
36
+ if (exact) return exact;
37
+ const trimmed = figma.root.children.filter((p) => p.name.trim() === String(name).trim());
38
+ if (trimmed.length > 1) throw new Error(`${trimmed.length} pages are named "${String(name).trim()}"; rename one or pass the exact name`);
39
+ return trimmed.length === 1 ? trimmed[0] : null;
40
+ }
41
+
42
+ function composeRequirePage(name) {
43
+ const page = composeFindPage(name);
44
+ if (!page) throw new Error(`no page named "${name}"`);
45
+ return page;
46
+ }
47
+
48
+ const composeIsMarked = (node) => node.getSharedPluginData(COMPOSE_NS, COMPOSE_KEY) !== '';
49
+ // Screens as the gate counts them: top-level frames, and frames directly inside a
50
+ // top-level section.
51
+ function composeTopFrames(page, name) {
52
+ const all = [];
53
+ for (const top of page.children) {
54
+ if (top.type === 'FRAME') all.push(top);
55
+ else if (top.type === 'SECTION') for (const c of top.children) if (c.type === 'FRAME') all.push(c);
56
+ }
57
+ return all.filter((n) => n.name === name);
58
+ }
59
+ const composeLink = (fileKey, id) => `https://www.figma.com/design/${fileKey}?node-id=${String(id).replace(/:/g, '-')}`;
60
+
61
+ // 'marked' | 'unmarked' | null for the top-level frames called `name`.
62
+ function composeNameState(page, name) {
63
+ const frames = composeTopFrames(page, name);
64
+ if (!frames.length) return null;
65
+ return frames.every(composeIsMarked) ? 'marked' : 'unmarked';
66
+ }
67
+
68
+ // Reads the INVENTORY documented in figma-compose.mjs, plus the live nodes the
69
+ // writer needs, keyed by id.
70
+ async function composeReadInventory(spec) {
71
+ const pages = [spec.componentsPage, spec.iconsPage].filter(Boolean).map(composeRequirePage);
72
+ const setNodes = new Map();
73
+ const sets = [];
74
+ const components = [];
75
+ for (const page of pages) {
76
+ await page.loadAsync();
77
+ for (const set of page.findAllWithCriteria({ types: ['COMPONENT_SET'] })) {
78
+ setNodes.set(set.id, set);
79
+ // Every variant, since the writer switches variant before filling a slot.
80
+ const slotNodes = set.findAll((n) => n.type === 'SLOT');
81
+ const props = Object.entries(set.componentPropertyDefinitions).map(([key, def]) => {
82
+ const prop = { key, name: key.replace(/#[^#]*$/, ''), type: def.type };
83
+ if (def.variantOptions) prop.options = def.variantOptions;
84
+ if (def.type === 'SLOT') {
85
+ const slots = slotNodes.filter((n) => n.componentPropertyReferences && n.componentPropertyReferences.slotContentId === key);
86
+ prop.slotLayout = slots.length && slots.every((n) => n.layoutMode !== 'NONE') ? slots[0].layoutMode : 'NONE';
87
+ }
88
+ return prop;
89
+ });
90
+ const autoLayout = set.children.every((v) => v.layoutMode && v.layoutMode !== 'NONE');
91
+ sets.push({ name: set.name, id: set.id, page: page.name, autoLayout, props });
92
+ }
93
+ for (const component of page.findAllWithCriteria({ types: ['COMPONENT'] })) {
94
+ if (component.parent && component.parent.type === 'COMPONENT_SET') continue;
95
+ components.push({ name: component.name, id: component.id, page: page.name });
96
+ }
97
+ }
98
+ const variableNodes = new Map();
99
+ const variables = [];
100
+ for (const variable of await figma.variables.getLocalVariablesAsync()) {
101
+ variableNodes.set(variable.id, variable);
102
+ let kinds = [];
103
+ if (variable.resolvedType === 'COLOR') kinds = ['colour'];
104
+ else if (variable.resolvedType === 'FLOAT') {
105
+ const buckets = dimensionBuckets(variable.name, variable.scopes || []);
106
+ kinds = [buckets.gap && 'gap', buckets.radius && 'radius'].filter(Boolean);
107
+ }
108
+ variables.push({ name: variable.name, id: variable.id, kinds });
109
+ }
110
+ const styleNodes = new Map();
111
+ const textStyles = [];
112
+ for (const style of await figma.getLocalTextStylesAsync()) {
113
+ styleNodes.set(style.id, style);
114
+ textStyles.push({ name: style.name, id: style.id });
115
+ }
116
+ const collectionNodes = new Map();
117
+ const collections = [];
118
+ for (const collection of await figma.variables.getLocalVariableCollectionsAsync()) {
119
+ collectionNodes.set(collection.id, collection);
120
+ collections.push({ name: collection.name, id: collection.id, modes: collection.modes.map((m) => ({ name: m.name, id: m.modeId })) });
121
+ }
122
+ return {
123
+ inventory: { sets, components, variables, textStyles, collections, target: null },
124
+ live: { setNodes, variableNodes, collectionNodes, styleNodes, styled: [] },
125
+ };
126
+ }
127
+
128
+ // What the planner reads: names only, grouped.
129
+ function composeSummary(inventory) {
130
+ const byKind = (kind) => inventory.variables.filter((v) => v.kinds.includes(kind)).map((v) => v.name);
131
+ return {
132
+ sets: inventory.sets.map((s) => ({
133
+ name: s.name,
134
+ autoLayout: s.autoLayout,
135
+ props: Object.fromEntries(s.props.map((p) => [
136
+ p.name,
137
+ p.type === 'VARIANT' ? p.options : p.type === 'SLOT' ? `SLOT (${p.slotLayout})` : p.type,
138
+ ])),
139
+ })),
140
+ swapTargets: inventory.components.map((c) => c.name),
141
+ variables: { colour: byKind('colour'), gap: byKind('gap'), radius: byKind('radius') },
142
+ textStyles: inventory.textStyles.map((s) => s.name),
143
+ modes: Object.fromEntries(inventory.collections.map((c) => [c.name, c.modes.map((m) => m.name)])),
144
+ };
145
+ }
146
+
147
+ function composeBoundPaint(variable) {
148
+ return figma.variables.setBoundVariableForPaint({ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, 'color', variable);
149
+ }
150
+
151
+ // Only the axes the plan names; the others stay as Figma made the node.
152
+ function composeSizing(node, sizing) {
153
+ if (!sizing) return;
154
+ if (sizing.horizontal) node.layoutSizingHorizontal = sizing.horizontal;
155
+ if (sizing.vertical) node.layoutSizingVertical = sizing.vertical;
156
+ }
157
+
158
+ function composeStyleFrame(frame, plan, live) {
159
+ const v = (id) => live.variableNodes.get(id);
160
+ frame.name = plan.name;
161
+ frame.layoutMode = plan.layoutMode;
162
+ frame.primaryAxisAlignItems = plan.primaryAxisAlignItems;
163
+ frame.counterAxisAlignItems = plan.counterAxisAlignItems;
164
+ // A new frame starts with a white fill; left in place it would be a raw colour.
165
+ frame.fills = plan.fill ? [composeBoundPaint(v(plan.fill))] : [];
166
+ if (plan.stroke) {
167
+ frame.strokes = [composeBoundPaint(v(plan.stroke))];
168
+ frame.strokeWeight = 1;
169
+ }
170
+ if (plan.itemSpacing) frame.setBoundVariable('itemSpacing', v(plan.itemSpacing));
171
+ for (const [side, id] of Object.entries(plan.padding || {})) {
172
+ if (id) frame.setBoundVariable(COMPOSE_PADDING[side], v(id));
173
+ }
174
+ if (plan.radius) for (const corner of COMPOSE_CORNERS) frame.setBoundVariable(corner, v(plan.radius));
175
+ }
176
+
177
+ const composeFontKey = (f) => `${f.family}\u0000${f.style}`;
178
+
179
+ // Every font the plan can touch: each text style it uses, every text layer in
180
+ // each component set it places (any variant can be switched to), and Inter
181
+ // Regular, which a new text node starts in.
182
+ function composeFontsFor(plan, live) {
183
+ const fonts = new Map([[composeFontKey({ family: 'Inter', style: 'Regular' }), { family: 'Inter', style: 'Regular' }]]);
184
+ const add = (f) => fonts.set(composeFontKey(f), { family: f.family, style: f.style });
185
+ const seenSets = new Set();
186
+ const walk = (node) => {
187
+ if (!node) return;
188
+ if (node.type === 'text') add(live.styleNodes.get(node.styleId).fontName);
189
+ if (node.type === 'component' && !seenSets.has(node.setId)) {
190
+ seenSets.add(node.setId);
191
+ for (const text of live.setNodes.get(node.setId).findAll((n) => n.type === 'TEXT')) {
192
+ if (text.fontName !== figma.mixed) add(text.fontName);
193
+ else for (const f of text.getRangeAllFontNames(0, text.characters.length)) add(f);
194
+ }
195
+ }
196
+ for (const child of node.children || []) walk(child);
197
+ for (const slot of node.slots || []) slot.nodes.forEach(walk);
198
+ };
199
+ walk(plan.root);
200
+ return [...fonts.values()];
201
+ }
202
+
203
+ // Synchronous: fonts are already loaded, and text styles are applied afterwards
204
+ // in one batch from live.styled.
205
+ function composeNode(plan, parent, live) {
206
+ if (plan.type === 'frame') {
207
+ const frame = figma.createFrame();
208
+ parent.appendChild(frame);
209
+ composeStyleFrame(frame, plan, live);
210
+ for (const child of plan.children) composeNode(child, frame, live);
211
+ composeSizing(frame, plan.sizing);
212
+ return frame;
213
+ }
214
+ if (plan.type === 'text') {
215
+ const text = figma.createText();
216
+ parent.appendChild(text);
217
+ text.name = plan.name;
218
+ text.characters = plan.characters;
219
+ text.fills = [composeBoundPaint(live.variableNodes.get(plan.color))];
220
+ composeSizing(text, plan.sizing);
221
+ live.styled.push([text, plan.styleId]);
222
+ return text;
223
+ }
224
+ const set = live.setNodes.get(plan.setId);
225
+ const instance = set.defaultVariant.createInstance();
226
+ parent.appendChild(instance);
227
+ instance.name = plan.name;
228
+ // Variant first: switching variant can swap the text layers the TEXT props drive.
229
+ if (Object.keys(plan.variantProps).length) instance.setProperties(plan.variantProps);
230
+ if (Object.keys(plan.otherProps).length) instance.setProperties(plan.otherProps);
231
+ for (const slotPlan of plan.slots) {
232
+ const slot = instance.findAll((n) => n.type === 'SLOT')
233
+ .find((n) => n.componentPropertyReferences && n.componentPropertyReferences.slotContentId === slotPlan.key);
234
+ if (!slot) throw new Error(`no slot ${slotPlan.key} on ${set.name}`);
235
+ for (const placeholder of [...slot.children]) placeholder.remove();
236
+ for (const child of slotPlan.nodes) composeNode(child, slot, live);
237
+ }
238
+ composeSizing(instance, plan.sizing);
239
+ return instance;
240
+ }
241
+
242
+ async function composeBuild(spec) {
243
+ const started = Date.now();
244
+ const { inventory, live } = await composeReadInventory(spec);
245
+ const d = spec.description || {};
246
+ let page = typeof d.page === 'string' ? composeFindPage(d.page) : null;
247
+ if (page) {
248
+ await page.loadAsync();
249
+ inventory.target = { screen: composeNameState(page, d.screen), wip: composeNameState(page, `WIP: ${d.screen}`) };
250
+ }
251
+ const resolved = resolveScreen(d, inventory);
252
+ if (!resolved.ok) return { ok: false, errors: resolved.errors };
253
+ const plan = resolved.plan;
254
+ await Promise.all(composeFontsFor(plan, live).map((f) => figma.loadFontAsync(f)));
255
+ const createdPage = !page;
256
+ if (!page) {
257
+ page = figma.createPage();
258
+ page.name = plan.page;
259
+ }
260
+ const wipName = `WIP: ${plan.screen}`;
261
+ for (const old of composeTopFrames(page, wipName)) old.remove();
262
+ const right = page.children.reduce((max, n) => Math.max(max, n.x + n.width), 0);
263
+ const root = figma.createFrame();
264
+ try {
265
+ page.appendChild(root);
266
+ root.setSharedPluginData(COMPOSE_NS, COMPOSE_KEY, JSON.stringify(d));
267
+ composeStyleFrame(root, plan.root, live);
268
+ root.name = wipName;
269
+ root.x = page.children.length > 1 ? right + 200 : 0;
270
+ root.y = 0;
271
+ root.resize(plan.width, plan.height || 100);
272
+ root.layoutSizingHorizontal = 'FIXED';
273
+ root.layoutSizingVertical = plan.height ? 'FIXED' : 'HUG';
274
+ for (const { collectionId, modeId } of plan.modes) {
275
+ root.setExplicitVariableModeForCollection(live.collectionNodes.get(collectionId), modeId);
276
+ }
277
+ for (const child of plan.root.children) composeNode(child, root, live);
278
+ await Promise.all(live.styled.map(([text, styleId]) => text.setTextStyleIdAsync(styleId)));
279
+ } catch (error) {
280
+ // A throw mid-write is a composer bug, not a finding: leave nothing behind.
281
+ root.remove();
282
+ if (createdPage) page.remove();
283
+ throw new Error(`build failed and was rolled back, nothing written: ${error.message}`);
284
+ }
285
+ return { ok: true, wipId: root.id, wipName, link: composeLink(spec.fileKey || figma.fileKey, root.id), ms: Date.now() - started };
286
+ }
287
+
288
+ async function composeFinalize(spec) {
289
+ const page = composeRequirePage(spec.page);
290
+ await page.loadAsync();
291
+ const wips = composeTopFrames(page, `WIP: ${spec.screen}`);
292
+ if (wips.length !== 1 || !composeIsMarked(wips[0])) {
293
+ throw new Error(`expected one composed frame "WIP: ${spec.screen}" on "${page.name}", found ${wips.length}${wips.length === 1 ? ' without the composed marker' : ''}`);
294
+ }
295
+ const wip = wips[0];
296
+ const existing = composeTopFrames(page, spec.screen);
297
+ if (existing.some((n) => !composeIsMarked(n))) {
298
+ throw new Error(`"${spec.screen}" on "${page.name}" wasn't composed by throughline; not replacing it. "WIP: ${spec.screen}" is left in place.`);
299
+ }
300
+ if (existing.length) {
301
+ const old = existing[0];
302
+ old.parent.insertChild(old.parent.children.indexOf(old), wip);
303
+ wip.x = old.x;
304
+ wip.y = old.y;
305
+ for (const n of existing) n.remove();
306
+ }
307
+ wip.name = spec.screen;
308
+ return { id: wip.id, link: composeLink(spec.fileKey || figma.fileKey, wip.id), replaced: existing.length > 0 };
309
+ }
310
+
311
+ async function composeDiscard(spec) {
312
+ const page = composeRequirePage(spec.page);
313
+ await page.loadAsync();
314
+ const wips = composeTopFrames(page, `WIP: ${spec.screen}`);
315
+ if (wips.some((n) => !composeIsMarked(n))) throw new Error(`"WIP: ${spec.screen}" wasn't composed by throughline; not removing it`);
316
+ for (const wip of wips) wip.remove();
317
+ return { removed: wips.length };
318
+ }
319
+
320
+ async function runCompose(MODE, SPEC) {
321
+ if (MODE === 'inventory') return { summary: composeSummary((await composeReadInventory(SPEC)).inventory) };
322
+ if (MODE === 'build') return composeBuild(SPEC);
323
+ if (MODE === 'finalize') return composeFinalize(SPEC);
324
+ if (MODE === 'discard') return composeDiscard(SPEC);
325
+ throw new Error(`unknown MODE ${JSON.stringify(MODE)}: inventory, build, finalize or discard`);
326
+ }