@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.
- package/README.md +8 -4
- package/adapters/codex/AGENTS.md +2 -0
- package/adapters/codex/prompts/component-builder.md +5 -3
- package/adapters/codex/prompts/compose-code.md +78 -0
- package/adapters/codex/prompts/compose-figma.md +59 -0
- package/adapters/codex/prompts/document-component.md +3 -2
- package/adapters/cursor/.cursor/commands/compose-code.md +78 -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/compose-code.md +78 -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 +21 -0
- package/references/figma-compose.md +896 -0
- package/scripts/README.md +8 -4
- package/scripts/build-doc-card-builder.mjs +16 -0
- package/scripts/build-figma-checks.mjs +89 -2
- package/scripts/compose-code.mjs +271 -0
- package/scripts/doc-card-payload.mjs +115 -0
- package/scripts/install.mjs +5 -3
- 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 +21 -0
- package/scripts/lib/figma-compose.figma.js +326 -0
- package/scripts/lib/figma-compose.mjs +317 -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
|
@@ -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)
|
|
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
|
+
}
|