@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,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
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Screen composition for Figma as a pure resolver: checks a screen description
|
|
2
|
+
// against what the file has, and turns its names into what the writer sets.
|
|
3
|
+
// ZERO imports, `export const`/`export function` only. It is inlined, next to
|
|
4
|
+
// figma-adherence.mjs and the shared variable reader, into the generated Figma
|
|
5
|
+
// script (references/figma-compose.md) by build-figma-checks.mjs, so it must run
|
|
6
|
+
// in both Node and the Figma plugin sandbox, and reuse none of their top-level
|
|
7
|
+
// names.
|
|
8
|
+
//
|
|
9
|
+
// Spec: docs/specs/2026-10-13-compose-figma-screen.md
|
|
10
|
+
//
|
|
11
|
+
// DESCRIPTION (what the planner writes):
|
|
12
|
+
// {
|
|
13
|
+
// screen, page, width, height?, // width/height: the only raw numbers
|
|
14
|
+
// modes?: { [collectionName]: modeName },
|
|
15
|
+
// root: <frame>, // no sizing on the root
|
|
16
|
+
// }
|
|
17
|
+
// frame: { layout: 'vertical'|'horizontal', gap?, padding?, fill?, stroke?, radius?,
|
|
18
|
+
// align?: { main?: 'start'|'center'|'end'|'space-between', cross?: 'start'|'center'|'end' },
|
|
19
|
+
// children?: [node], sizing?, name? }
|
|
20
|
+
// padding: a variable name, or { top?, right?, bottom?, left? }
|
|
21
|
+
// component: { component, props?: { [propName]: value }, slots?: { [slotName]: [node] }, sizing?, name? }
|
|
22
|
+
// text: { text, style, color, sizing?, name? }
|
|
23
|
+
// sizing: { horizontal?: 'fill'|'hug', vertical?: 'fill'|'hug' }
|
|
24
|
+
// An axis left out stays as Figma makes it: a frame hugs, text sizes to
|
|
25
|
+
// its content, an instance keeps its component's size. 'hug' on an
|
|
26
|
+
// instance needs a component with auto layout (Figma throws otherwise).
|
|
27
|
+
// Every visual value is a variable or text-style name.
|
|
28
|
+
//
|
|
29
|
+
// INVENTORY (what the reader returns):
|
|
30
|
+
// {
|
|
31
|
+
// sets: [{ name, id, page, autoLayout, props: [{ key, name, type, options?, slotLayout? }] }],
|
|
32
|
+
// // key: 'label#10:273'; type: TEXT | BOOLEAN | VARIANT
|
|
33
|
+
// // | INSTANCE_SWAP | SLOT; slotLayout: NONE | VERTICAL | HORIZONTAL,
|
|
34
|
+
// // NONE if any variant's slot has no auto layout;
|
|
35
|
+
// // autoLayout: every variant has auto layout
|
|
36
|
+
// components: [{ name, id, page }], // standalone COMPONENTs, the swap targets
|
|
37
|
+
// variables: [{ name, id, kinds: ['colour'|'gap'|'radius'] }],
|
|
38
|
+
// textStyles: [{ name, id }],
|
|
39
|
+
// collections: [{ name, id, modes: [{ name, id }] }],
|
|
40
|
+
// target: { screen, wip } | null, // build only: 'marked' | 'unmarked' | null for
|
|
41
|
+
// // the frames named <screen> and 'WIP: <screen>'
|
|
42
|
+
// }
|
|
43
|
+
//
|
|
44
|
+
// resolveScreen(description, inventory) → { ok, errors, plan }
|
|
45
|
+
// errors: [{ kind, path, asked, nearest? }]; path is the node's place in the
|
|
46
|
+
// description ('root.children[2].slots.Body[0]').
|
|
47
|
+
// plan (only when ok): the same tree with every name replaced by ids, real
|
|
48
|
+
// property keys and values, and Figma's enum spellings.
|
|
49
|
+
|
|
50
|
+
export const COMPOSE_ERROR_KINDS = [
|
|
51
|
+
'invalid', 'name-taken', 'missing-component', 'unknown-prop', 'unknown-option',
|
|
52
|
+
'prop-type', 'missing-swap-target', 'unknown-variable', 'variable-kind',
|
|
53
|
+
'unknown-text-style', 'unknown-mode',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const composeFold = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
57
|
+
|
|
58
|
+
// 'label#10:273' → 'label'. Variant property keys carry no suffix.
|
|
59
|
+
const propBaseName = (key) => String(key).replace(/#[^#]*$/, '');
|
|
60
|
+
|
|
61
|
+
const LAYOUT = { vertical: 'VERTICAL', horizontal: 'HORIZONTAL' };
|
|
62
|
+
const ALIGN_MAIN = { start: 'MIN', center: 'CENTER', end: 'MAX', 'space-between': 'SPACE_BETWEEN' };
|
|
63
|
+
const ALIGN_CROSS = { start: 'MIN', center: 'CENTER', end: 'MAX' };
|
|
64
|
+
const SIZING = { fill: 'FILL', hug: 'HUG' };
|
|
65
|
+
const SIDES = ['top', 'right', 'bottom', 'left'];
|
|
66
|
+
|
|
67
|
+
const NODE_FIELDS = {
|
|
68
|
+
frame: ['layout', 'gap', 'padding', 'fill', 'stroke', 'radius', 'align', 'children', 'sizing', 'name'],
|
|
69
|
+
component: ['component', 'props', 'slots', 'sizing', 'name'],
|
|
70
|
+
text: ['text', 'style', 'color', 'sizing', 'name'],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function editDistance(a, b) {
|
|
74
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
75
|
+
for (let i = 1; i <= a.length; i++) {
|
|
76
|
+
let diag = row[0];
|
|
77
|
+
row[0] = i;
|
|
78
|
+
for (let j = 1; j <= b.length; j++) {
|
|
79
|
+
const above = row[j];
|
|
80
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
81
|
+
diag = above;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return row[b.length];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Up to three names, by longest shared folded prefix, then edit distance.
|
|
88
|
+
export function nearestNames(asked, names) {
|
|
89
|
+
const want = composeFold(asked);
|
|
90
|
+
const shared = (f) => { let n = 0; while (n < f.length && n < want.length && f[n] === want[n]) n++; return n; };
|
|
91
|
+
return [...new Set(names)]
|
|
92
|
+
.map((name) => ({ name, f: composeFold(name) }))
|
|
93
|
+
.map((x) => ({ ...x, prefix: shared(x.f), dist: editDistance(want, x.f) }))
|
|
94
|
+
.sort((a, b) => b.prefix - a.prefix || a.dist - b.dist || a.name.localeCompare(b.name))
|
|
95
|
+
.slice(0, 3)
|
|
96
|
+
.map((x) => x.name);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const byFold = (list, asked, nameOf = (x) => x.name) =>
|
|
100
|
+
list.find((x) => nameOf(x) === asked) || list.find((x) => composeFold(nameOf(x)) === composeFold(asked));
|
|
101
|
+
|
|
102
|
+
export function resolveScreen(description, inventory) {
|
|
103
|
+
const errors = [];
|
|
104
|
+
const fail = (kind, path, asked, nearest) =>
|
|
105
|
+
errors.push(nearest ? { kind, path, asked, nearest } : { kind, path, asked });
|
|
106
|
+
const inv = {
|
|
107
|
+
sets: [], components: [], variables: [], textStyles: [], collections: [], target: null,
|
|
108
|
+
...inventory,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const d = description && typeof description === 'object' ? description : {};
|
|
112
|
+
if (typeof d.screen !== 'string' || !d.screen.trim()) fail('invalid', 'screen', 'a screen name');
|
|
113
|
+
if (typeof d.page !== 'string' || !d.page.trim()) fail('invalid', 'page', 'a page name');
|
|
114
|
+
if (!(typeof d.width === 'number' && d.width > 0)) fail('invalid', 'width', 'a positive number');
|
|
115
|
+
if (d.height !== undefined && !(typeof d.height === 'number' && d.height > 0)) fail('invalid', 'height', 'a positive number');
|
|
116
|
+
if (!d.root || typeof d.root !== 'object') fail('invalid', 'root', 'a frame');
|
|
117
|
+
|
|
118
|
+
if (inv.target && typeof d.screen === 'string') {
|
|
119
|
+
if (inv.target.screen === 'unmarked') fail('name-taken', 'screen', d.screen);
|
|
120
|
+
if (inv.target.wip === 'unmarked') fail('name-taken', 'screen', `WIP: ${d.screen}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const modes = [];
|
|
124
|
+
for (const [collectionName, modeName] of Object.entries(d.modes || {})) {
|
|
125
|
+
const collection = byFold(inv.collections, collectionName);
|
|
126
|
+
const mode = collection && byFold(collection.modes, String(modeName));
|
|
127
|
+
if (!collection) fail('unknown-mode', `modes.${collectionName}`, collectionName, nearestNames(collectionName, inv.collections.map((c) => c.name)));
|
|
128
|
+
else if (!mode) fail('unknown-mode', `modes.${collectionName}`, String(modeName), nearestNames(String(modeName), collection.modes.map((m) => m.name)));
|
|
129
|
+
else modes.push({ collectionId: collection.id, modeId: mode.id });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function variable(name, kind, path) {
|
|
133
|
+
if (name === undefined || name === null) return null;
|
|
134
|
+
if (typeof name !== 'string') { fail('invalid', path, `a variable name, not ${JSON.stringify(name)}`); return null; }
|
|
135
|
+
const found = byFold(inv.variables, name);
|
|
136
|
+
if (!found) {
|
|
137
|
+
fail('unknown-variable', path, name, nearestNames(name, inv.variables.filter((v) => v.kinds.includes(kind)).map((v) => v.name)));
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (!found.kinds.includes(kind)) { fail('variable-kind', path, `${name} is not a ${kind} variable`); return null; }
|
|
141
|
+
return found.id;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `fallback` fills an axis the description leaves out: 'hug' for frames (a new
|
|
145
|
+
// frame is a fixed 100×100 otherwise), null (leave alone) for text and instances.
|
|
146
|
+
function sizing(node, path, parent, fallback, hugAllowed = true) {
|
|
147
|
+
const s = node.sizing === undefined ? {} : node.sizing;
|
|
148
|
+
if (!s || typeof s !== 'object') { fail('invalid', `${path}.sizing`, 'an object { horizontal, vertical }'); return null; }
|
|
149
|
+
const out = {};
|
|
150
|
+
for (const axis of ['horizontal', 'vertical']) {
|
|
151
|
+
const v = s[axis] === undefined ? fallback : s[axis];
|
|
152
|
+
if (v === null) continue;
|
|
153
|
+
if (!SIZING[v]) { fail('invalid', `${path}.sizing.${axis}`, `fill or hug, not ${JSON.stringify(v)}`); continue; }
|
|
154
|
+
if (v === 'hug' && !hugAllowed) {
|
|
155
|
+
fail('invalid', `${path}.sizing.${axis}`, 'hug on a component without auto layout');
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (v === 'fill' && parent.slotLayout === 'NONE') {
|
|
159
|
+
fail('invalid', `${path}.sizing.${axis}`, 'fill inside a slot without auto layout');
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
out[axis] = SIZING[v];
|
|
163
|
+
}
|
|
164
|
+
for (const key of Object.keys(s)) if (key !== 'horizontal' && key !== 'vertical') fail('invalid', `${path}.sizing.${key}`, 'horizontal or vertical');
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function kindOf(node, path) {
|
|
169
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) { fail('invalid', path, 'a frame, component or text node'); return null; }
|
|
170
|
+
const isComponent = 'component' in node;
|
|
171
|
+
const isText = 'text' in node;
|
|
172
|
+
if (isComponent && isText) { fail('invalid', path, 'one of component or text, not both'); return null; }
|
|
173
|
+
const kind = isComponent ? 'component' : isText ? 'text' : 'frame';
|
|
174
|
+
for (const key of Object.keys(node)) if (!NODE_FIELDS[kind].includes(key)) fail('invalid', `${path}.${key}`, `a ${kind} field`);
|
|
175
|
+
return kind;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function frame(node, path, parent, isRoot) {
|
|
179
|
+
const layoutMode = LAYOUT[node.layout];
|
|
180
|
+
if (!layoutMode) fail('invalid', `${path}.layout`, `vertical or horizontal, not ${JSON.stringify(node.layout)}`);
|
|
181
|
+
let padding = null;
|
|
182
|
+
if (node.padding !== undefined) {
|
|
183
|
+
if (typeof node.padding === 'object' && node.padding !== null) {
|
|
184
|
+
padding = {};
|
|
185
|
+
for (const [side, name] of Object.entries(node.padding)) {
|
|
186
|
+
if (!SIDES.includes(side)) { fail('invalid', `${path}.padding.${side}`, 'top, right, bottom or left'); continue; }
|
|
187
|
+
padding[side] = variable(name, 'gap', `${path}.padding.${side}`);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
const id = variable(node.padding, 'gap', `${path}.padding`);
|
|
191
|
+
padding = Object.fromEntries(SIDES.map((side) => [side, id]));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const align = node.align || {};
|
|
195
|
+
if (node.align !== undefined && (typeof node.align !== 'object' || node.align === null)) fail('invalid', `${path}.align`, 'an object { main, cross }');
|
|
196
|
+
if (align.main !== undefined && !ALIGN_MAIN[align.main]) fail('invalid', `${path}.align.main`, `start, center, end or space-between, not ${JSON.stringify(align.main)}`);
|
|
197
|
+
if (align.cross !== undefined && !ALIGN_CROSS[align.cross]) fail('invalid', `${path}.align.cross`, `start, center or end, not ${JSON.stringify(align.cross)}`);
|
|
198
|
+
if (isRoot && node.sizing !== undefined) fail('invalid', `${path}.sizing`, 'no sizing on the root; it takes width and height');
|
|
199
|
+
const children = node.children === undefined ? [] : node.children;
|
|
200
|
+
if (!Array.isArray(children)) fail('invalid', `${path}.children`, 'a list of nodes');
|
|
201
|
+
return {
|
|
202
|
+
type: 'frame',
|
|
203
|
+
name: typeof node.name === 'string' ? node.name : 'Stack',
|
|
204
|
+
layoutMode,
|
|
205
|
+
itemSpacing: variable(node.gap, 'gap', `${path}.gap`),
|
|
206
|
+
padding,
|
|
207
|
+
fill: variable(node.fill, 'colour', `${path}.fill`),
|
|
208
|
+
stroke: variable(node.stroke, 'colour', `${path}.stroke`),
|
|
209
|
+
radius: variable(node.radius, 'radius', `${path}.radius`),
|
|
210
|
+
primaryAxisAlignItems: ALIGN_MAIN[align.main] || 'MIN',
|
|
211
|
+
counterAxisAlignItems: ALIGN_CROSS[align.cross] || 'MIN',
|
|
212
|
+
sizing: isRoot ? null : sizing(node, path, parent, 'hug'),
|
|
213
|
+
children: Array.isArray(children)
|
|
214
|
+
? children.map((child, i) => resolveNode(child, `${path}.children[${i}]`, { slotLayout: null }))
|
|
215
|
+
: [],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function component(node, path, parent) {
|
|
220
|
+
const set = byFold(inv.sets, String(node.component));
|
|
221
|
+
const sizingPlan = sizing(node, path, parent, null, !set || set.autoLayout !== false);
|
|
222
|
+
if (!set) {
|
|
223
|
+
fail('missing-component', `${path}.component`, String(node.component), nearestNames(String(node.component), inv.sets.map((s) => s.name)));
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const findProp = (name) => set.props.find((p) => composeFold(propBaseName(p.key)) === composeFold(name));
|
|
227
|
+
const propNames = set.props.map((p) => propBaseName(p.key));
|
|
228
|
+
const variantProps = {};
|
|
229
|
+
const otherProps = {};
|
|
230
|
+
for (const [name, value] of Object.entries(node.props || {})) {
|
|
231
|
+
const at = `${path}.props.${name}`;
|
|
232
|
+
const prop = findProp(name);
|
|
233
|
+
if (!prop) { fail('unknown-prop', at, name, nearestNames(name, propNames)); continue; }
|
|
234
|
+
if (prop.type === 'SLOT') { fail('invalid', at, 'slot content goes under slots, not props'); continue; }
|
|
235
|
+
if (prop.type === 'VARIANT') {
|
|
236
|
+
const option = (prop.options || []).find((o) => composeFold(o) === composeFold(value));
|
|
237
|
+
if (option === undefined) fail('unknown-option', at, String(value), nearestNames(String(value), prop.options || []));
|
|
238
|
+
else variantProps[prop.key] = option;
|
|
239
|
+
} else if (prop.type === 'TEXT') {
|
|
240
|
+
if (typeof value !== 'string') fail('prop-type', at, `text, not ${JSON.stringify(value)}`);
|
|
241
|
+
else otherProps[prop.key] = value;
|
|
242
|
+
} else if (prop.type === 'BOOLEAN') {
|
|
243
|
+
if (typeof value !== 'boolean') fail('prop-type', at, `true or false, not ${JSON.stringify(value)}`);
|
|
244
|
+
else otherProps[prop.key] = value;
|
|
245
|
+
} else if (prop.type === 'INSTANCE_SWAP') {
|
|
246
|
+
const target = typeof value === 'string' && byFold(inv.components, value);
|
|
247
|
+
if (!target) fail('missing-swap-target', at, String(value), nearestNames(String(value), inv.components.map((c) => c.name)));
|
|
248
|
+
else otherProps[prop.key] = target.id;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const slots = [];
|
|
252
|
+
for (const [name, nodes] of Object.entries(node.slots || {})) {
|
|
253
|
+
const at = `${path}.slots.${name}`;
|
|
254
|
+
const prop = findProp(name);
|
|
255
|
+
if (!prop || prop.type !== 'SLOT') {
|
|
256
|
+
fail('unknown-prop', at, name, nearestNames(name, set.props.filter((p) => p.type === 'SLOT').map((p) => propBaseName(p.key))));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!Array.isArray(nodes)) { fail('invalid', at, 'a list of nodes'); continue; }
|
|
260
|
+
slots.push({
|
|
261
|
+
key: prop.key,
|
|
262
|
+
nodes: nodes.map((child, i) => resolveNode(child, `${at}[${i}]`, { slotLayout: prop.slotLayout || 'NONE' })),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
type: 'component',
|
|
267
|
+
name: typeof node.name === 'string' ? node.name : set.name,
|
|
268
|
+
setId: set.id,
|
|
269
|
+
variantProps,
|
|
270
|
+
otherProps,
|
|
271
|
+
slots,
|
|
272
|
+
sizing: sizingPlan,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function text(node, path, parent) {
|
|
277
|
+
if (typeof node.text !== 'string') fail('invalid', `${path}.text`, 'the text to show');
|
|
278
|
+
let styleId = null;
|
|
279
|
+
if (typeof node.style !== 'string') fail('invalid', `${path}.style`, 'a text style name');
|
|
280
|
+
else {
|
|
281
|
+
const style = byFold(inv.textStyles, node.style);
|
|
282
|
+
if (!style) fail('unknown-text-style', `${path}.style`, node.style, nearestNames(node.style, inv.textStyles.map((s) => s.name)));
|
|
283
|
+
else styleId = style.id;
|
|
284
|
+
}
|
|
285
|
+
if (node.color === undefined) fail('invalid', `${path}.color`, 'a colour variable name');
|
|
286
|
+
return {
|
|
287
|
+
type: 'text',
|
|
288
|
+
name: typeof node.name === 'string' ? node.name : String(node.text).slice(0, 40),
|
|
289
|
+
characters: node.text,
|
|
290
|
+
styleId,
|
|
291
|
+
color: variable(node.color, 'colour', `${path}.color`),
|
|
292
|
+
sizing: sizing(node, path, parent, null),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function resolveNode(node, path, parent) {
|
|
297
|
+
const kind = kindOf(node, path);
|
|
298
|
+
if (kind === 'component') return component(node, path, parent);
|
|
299
|
+
if (kind === 'text') return text(node, path, parent);
|
|
300
|
+
if (kind === 'frame') return frame(node, path, parent, false);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let root = null;
|
|
305
|
+
if (d.root && typeof d.root === 'object') {
|
|
306
|
+
const kind = kindOf(d.root, 'root');
|
|
307
|
+
if (kind === 'frame') root = frame(d.root, 'root', { slotLayout: null }, true);
|
|
308
|
+
else if (kind) fail('invalid', 'root', 'a frame');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const ok = errors.length === 0;
|
|
312
|
+
return {
|
|
313
|
+
ok,
|
|
314
|
+
errors,
|
|
315
|
+
plan: ok ? { screen: d.screen, page: d.page, width: d.width, height: d.height ?? null, modes, root } : null,
|
|
316
|
+
};
|
|
317
|
+
}
|