@toghen/figma 0.0.0-stage → 0.1.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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/cli/bin.mjs +19 -0
  4. package/cli/bridge.ts +243 -0
  5. package/cli/check.ts +62 -0
  6. package/cli/figcraft.ts +764 -0
  7. package/cli/figcraftApply.ts +1058 -0
  8. package/cli/figcraftFile.ts +476 -0
  9. package/cli/figcraftLibrary.ts +147 -0
  10. package/cli/figcraftRecord.ts +121 -0
  11. package/cli/figcraftScene.ts +661 -0
  12. package/cli/main.ts +218 -0
  13. package/cli/members.ts +65 -0
  14. package/cli/run.ts +756 -0
  15. package/component-files.md +665 -0
  16. package/dist/code.js +24147 -0
  17. package/dist/manifest.json +23 -0
  18. package/dist/ui.html +6918 -0
  19. package/guide.md +985 -0
  20. package/main/code.ts +50 -0
  21. package/main/components/apply.ts +704 -0
  22. package/main/components/endOfRun.ts +168 -0
  23. package/main/components/errors.ts +17 -0
  24. package/main/components/fieldWriter.ts +497 -0
  25. package/main/components/file.ts +263 -0
  26. package/main/components/receipt.ts +228 -0
  27. package/main/components/resolve.ts +1193 -0
  28. package/main/components/run.ts +273 -0
  29. package/main/components/snapshot.ts +341 -0
  30. package/main/components/stamp.ts +312 -0
  31. package/main/components/update.ts +465 -0
  32. package/main/dispatcher.ts +33 -0
  33. package/main/engines/batchRunner.ts +200 -0
  34. package/main/engines/ioAdapter.ts +132 -0
  35. package/main/engines/operationExecutor.ts +512 -0
  36. package/main/engines/propertyMapper.ts +221 -0
  37. package/main/engines/queryEngine.ts +45 -0
  38. package/main/engines/styleSyncEngine.ts +444 -0
  39. package/main/engines/variableSyncEngine.ts +561 -0
  40. package/main/handlers/auditSelection.ts +60 -0
  41. package/main/handlers/boundLayers.ts +51 -0
  42. package/main/handlers/cancelOperation.ts +18 -0
  43. package/main/handlers/executeApplyBindings.ts +69 -0
  44. package/main/handlers/executeScaffold.ts +59 -0
  45. package/main/handlers/executeStyleSync.ts +121 -0
  46. package/main/handlers/executeVariableSync.ts +82 -0
  47. package/main/handlers/exportDocument.ts +114 -0
  48. package/main/handlers/getSelection.ts +43 -0
  49. package/main/handlers/index.ts +30 -0
  50. package/main/handlers/inspectCorrespondence.ts +172 -0
  51. package/main/handlers/loadConventions.ts +86 -0
  52. package/main/handlers/loadDocument.ts +253 -0
  53. package/main/handlers/lock.ts +270 -0
  54. package/main/handlers/planApply.ts +157 -0
  55. package/main/handlers/planApplyBindings.ts +79 -0
  56. package/main/handlers/planScaffold.ts +84 -0
  57. package/main/handlers/planStyleSync.ts +49 -0
  58. package/main/handlers/planVariableSync.ts +49 -0
  59. package/main/handlers/readSelectionBindings.ts +160 -0
  60. package/main/handlers/resizeUi.ts +105 -0
  61. package/main/handlers/runComponent.ts +65 -0
  62. package/main/handlers/templates.ts +462 -0
  63. package/main/handlers/tokenGraph.ts +122 -0
  64. package/main/runtimeState.ts +66 -0
  65. package/main/utils/abortController.ts +11 -0
  66. package/main/utils/boundVariables.ts +84 -0
  67. package/main/utils/buildPayloads.ts +85 -0
  68. package/main/utils/correspondence.ts +658 -0
  69. package/main/utils/designSource.ts +122 -0
  70. package/main/utils/figmaCollectionIndex.ts +120 -0
  71. package/main/utils/figmaStyleAdapter.ts +268 -0
  72. package/main/utils/figmaTokenAdapter.ts +255 -0
  73. package/main/utils/flattenDesignSystem.ts +38 -0
  74. package/main/utils/folds.ts +203 -0
  75. package/main/utils/fontStack.ts +57 -0
  76. package/main/utils/lists.ts +4 -0
  77. package/main/utils/lockStore.ts +72 -0
  78. package/main/utils/moves.ts +67 -0
  79. package/main/utils/pageRoots.ts +58 -0
  80. package/main/utils/planSymbols.ts +96 -0
  81. package/main/utils/purpose.ts +351 -0
  82. package/main/utils/recentStore.ts +166 -0
  83. package/main/utils/resolverModes.ts +271 -0
  84. package/main/utils/rootRecord.ts +137 -0
  85. package/main/utils/shardedData.ts +142 -0
  86. package/main/utils/standing.ts +106 -0
  87. package/main/utils/styleTarget.ts +51 -0
  88. package/main/utils/sum.ts +612 -0
  89. package/main/utils/symbolicIdRegistry.ts +26 -0
  90. package/main/utils/syncState.ts +133 -0
  91. package/main/utils/templateStore.ts +73 -0
  92. package/main/utils/tokenPath.ts +28 -0
  93. package/main/utils/tokenStamps.ts +91 -0
  94. package/manifest.json +15 -0
  95. package/package.json +64 -4
  96. package/reference.md +1609 -0
  97. package/shared/components/artboard.ts +126 -0
  98. package/shared/components/axes.ts +420 -0
  99. package/shared/components/bindings.ts +64 -0
  100. package/shared/components/blueprint.ts +248 -0
  101. package/shared/components/check.ts +705 -0
  102. package/shared/components/diff.ts +668 -0
  103. package/shared/components/fields.ts +404 -0
  104. package/shared/components/groups.ts +306 -0
  105. package/shared/components/index.ts +140 -0
  106. package/shared/components/members.ts +280 -0
  107. package/shared/components/model.ts +273 -0
  108. package/shared/components/order.ts +53 -0
  109. package/shared/components/read.ts +480 -0
  110. package/shared/components/stack.ts +124 -0
  111. package/shared/components/steps.ts +186 -0
  112. package/shared/components/validate.ts +548 -0
  113. package/shared/conventions.ts +28 -0
  114. package/shared/inputs.ts +40 -0
  115. package/shared/operations.ts +233 -0
  116. package/shared/pendingNames.ts +39 -0
  117. package/shared/protocol.ts +588 -0
  118. package/shared/tokenTypeMap.ts +21 -0
@@ -0,0 +1,704 @@
1
+ /**
2
+ * Steps to a file that has changed, in the order Figma insists on.
3
+ *
4
+ * Every ordering rule below is a trap somebody paid for, and each is keyed to
5
+ * `.reference/figma/measured_behaviour.md`, *Component sets, slots and layout*,
6
+ * or to the typings. Do not reorder to tidy: a call that reads as out of place
7
+ * here is usually the one that keeps a later call from throwing.
8
+ */
9
+
10
+ import { memberBoard, slotKey } from '../../shared/components/diff.js';
11
+ import { FIELDS, TEXT_FIELDS } from '../../shared/components/fields.js';
12
+ import type {
13
+ ComponentRef,
14
+ Constraints,
15
+ FieldValue,
16
+ GridPlacement,
17
+ Layer,
18
+ Member,
19
+ PreferredValue,
20
+ Property,
21
+ SetField,
22
+ Track,
23
+ Value,
24
+ } from '../../shared/components/model.js';
25
+ import type { SetSpec, Step } from '../../shared/components/steps.js';
26
+ import type { Resolved } from './resolve.js';
27
+ import { BOUND_BY_PARTS } from '../utils/boundVariables.js';
28
+ import { writeField } from './fieldWriter.js';
29
+ import { stampReceipts, type Written } from './receipt.js';
30
+ import { receiptOf, updateStamp, type Identity } from './stamp.js';
31
+
32
+ /** The size limits, written after the sizing: `width` and `height` are a resize, written as such. */
33
+ const LIMITS = Object.keys(FIELDS).filter((name) => FIELDS[name].value === 'size' && name !== 'width' && name !== 'height');
34
+ /** A layer's sizing: its sizes and how each axis is sized. */
35
+ const SIZING = [...Object.keys(FIELDS).filter((name) => FIELDS[name].value === 'size'), 'layoutSizingHorizontal', 'layoutSizingVertical'];
36
+ /** The dimension fields written in one go: all but a stroke's weights, which take the steps of their own below, and a text's (`applyTextFields`). */
37
+ const DIMENSIONS = Object.keys(FIELDS).filter(
38
+ (name) =>
39
+ FIELDS[name].value === 'dimension' && name !== 'strokeWeight' && !BOUND_BY_PARTS.strokeWeight.includes(name) && !(name in TEXT_FIELDS),
40
+ );
41
+
42
+ /** The font a new text node starts with, loaded before any text is written. // unmeasured: which font that is; Inter Regular is the usual answer */
43
+ const DEFAULT_FONT: FontName = { family: 'Inter', style: 'Regular' };
44
+
45
+ export interface Applied {
46
+ setId: string;
47
+ created: string[];
48
+ warnings: string[];
49
+ }
50
+
51
+ export interface AppliedArtboard {
52
+ frameId: string;
53
+ /** The addresses of the layers made, the frame's first. */
54
+ created: string[];
55
+ warnings: string[];
56
+ }
57
+
58
+ /** What building one member needs to remember between its layers. */
59
+ export interface Build {
60
+ member: ComponentNode;
61
+ /**
62
+ * Where the member's properties live: the member itself until it is combined, the set once it is in
63
+ * one. Reading a variant member's definitions throws, so a member in a set asks its set.
64
+ */
65
+ host: ComponentNode | ComponentSetNode;
66
+ /** When set, references are collected instead of assigned: a member not yet in its set has no keys to reference. */
67
+ deferred?: (() => void)[];
68
+ spec: SetSpec;
69
+ resolved: Resolved;
70
+ /** Property name to the key this member's `addComponentProperty` returned, for wiring. */
71
+ keys: Map<string, string>;
72
+ /** Property name to its default, which a wired field takes as its own value. */
73
+ defaults: Map<string, string | boolean>;
74
+ warnings: string[];
75
+ /** The layers the run wrote, whose receipts are stamped once its writes are done (`stampReceipts`). */
76
+ written: Written[];
77
+ /** An artboard run: an instance takes its slot contents. */
78
+ artboard?: boolean;
79
+ }
80
+
81
+ export const nameOf = (key: string): string => key.split('#')[0];
82
+
83
+ /** Preferred values as Figma takes them, by published key. One by name never gets here: the run refuses it in Figma (`run.ts`). */
84
+ export const keyedPreferred = (values: PreferredValue[]): InstanceSwapPreferredValue[] =>
85
+ values.filter((value): value is InstanceSwapPreferredValue => 'key' in value);
86
+
87
+ /** A field's plain value, or undefined when it is absent or not plain. */
88
+ function plain(value: FieldValue | undefined): string | number | boolean | null | undefined {
89
+ const scalar = value as Value | undefined;
90
+ return scalar && scalar.kind === 'plain' ? scalar.value : undefined;
91
+ }
92
+
93
+ function literal(value: FieldValue | undefined): number | undefined {
94
+ const scalar = value as Value | undefined;
95
+ return scalar && scalar.kind === 'literal' && typeof scalar.value === 'number' ? scalar.value : undefined;
96
+ }
97
+
98
+ function setTracks(node: SceneNode, field: 'gridRowSizes' | 'gridColumnSizes', tracks: Track[]): void {
99
+ // The typings: a grid's tracks are objects whose type and value are set through the frame's own
100
+ // list, after its count is set. // unmeasured: whether assigning the whole array is also accepted
101
+ const grid = node as unknown as GridLayoutMixin;
102
+ if (field === 'gridRowSizes') grid.gridRowCount = tracks.length;
103
+ else grid.gridColumnCount = tracks.length;
104
+ tracks.forEach((track, index) => {
105
+ const target = grid[field][index];
106
+ target.type = track.type;
107
+ if (track.value !== undefined) target.value = track.value;
108
+ });
109
+ }
110
+
111
+ function componentOf(build: Build, value: Value | undefined, address: string): ComponentNode {
112
+ if (value?.kind === 'plain' && typeof value.value === 'string') return build.resolved.component({ name: value.value });
113
+ if (value?.kind === 'key') return build.resolved.component({ key: value.key });
114
+ if (value?.kind === 'property') {
115
+ const declared = build.spec.properties.find((p) => p.type === 'INSTANCE_SWAP' && p.name === value.name) as
116
+ | (Property & { type: 'INSTANCE_SWAP'; default: ComponentRef })
117
+ | undefined;
118
+ if (declared) return build.resolved.component(declared.default);
119
+ }
120
+ throw new Error(`INSTANCE "${address}" names no component to make an instance of`);
121
+ }
122
+
123
+ /**
124
+ * A slot is a property of the component as much as a layer: `createSlot()` makes
125
+ * both, and the property's name is the slot's address.
126
+ */
127
+ function createSlot(build: Build, address: string): SlotNode {
128
+ const before = new Set(Object.keys(build.host.componentPropertyDefinitions));
129
+ // unmeasured: where createSlot() puts the property when the member is already in a set
130
+ const slot = build.member.createSlot();
131
+ const added = Object.entries(build.host.componentPropertyDefinitions)
132
+ .filter(([key, definition]) => definition.type === 'SLOT' && !before.has(key))
133
+ .map(([key]) => key);
134
+
135
+ slot.name = address;
136
+
137
+ // unmeasured: whether renaming the slot renames its property. If it did not, the property still
138
+ // carries the name it was made with, and a slot shared across members by name would not be shared.
139
+ const definitions = build.host.componentPropertyDefinitions;
140
+ const named = Object.entries(definitions).some(([key, definition]) => definition.type === 'SLOT' && nameOf(key) === address);
141
+ if (!named) {
142
+ if (added.length !== 1) throw new Error(`cannot tell which component property is the slot "${address}"`);
143
+ build.host.editComponentProperty(added[0], { name: address });
144
+ }
145
+ return slot;
146
+ }
147
+
148
+ /** The slots of an instance, each the node that holds what fills it. A slot's contents are not searched: they belong to the layers in it. */
149
+ export function slotNodesOf(instance: BaseNode): SlotNode[] {
150
+ const found: SlotNode[] = [];
151
+ const visit = (node: BaseNode): void => {
152
+ if (!('children' in node)) return;
153
+ for (const child of node.children) {
154
+ if (child.type === 'SLOT') found.push(child);
155
+ else visit(child);
156
+ }
157
+ };
158
+ visit(instance);
159
+ return found;
160
+ }
161
+
162
+ /** The slot of an instance by the name of the slot property it fills. // unmeasured: that an instance's slot node is named for its property */
163
+ export function slotNodeOf(instance: BaseNode, slot: string, address: string): SlotNode {
164
+ const found = slotNodesOf(instance).find((candidate) => candidate.name === slot);
165
+ if (!found) throw new Error(`INSTANCE "${address}" has no slot "${slot}"`);
166
+ return found;
167
+ }
168
+
169
+ /** Records the slots of an instance the run has just made or changed under the key their layers name as their parent. */
170
+ export function registerSlots(layers: Map<string, SceneNode>, address: string, instance: BaseNode): void {
171
+ for (const slot of slotNodesOf(instance)) layers.set(slotKey(address, slot.name), slot);
172
+ }
173
+
174
+ /**
175
+ * Stamps a node the run has just made with what it is, before its fields are set, so a step that fails partway leaves a
176
+ * layer the next run finds. Its receipt follows once the run's writes are done: every field it declares is written.
177
+ */
178
+ export function stampNew(build: Build, node: SceneNode, layer: Layer, is: Identity = {}): void {
179
+ updateStamp(node, { is: { ...is, address: layer.address }, receipt: {} });
180
+ build.written.push({ node, layer });
181
+ }
182
+
183
+ /** The set's receipt: for each property Toghen added, what Figma reports for its definition. */
184
+ export function stampSet(set: ComponentSetNode, spec: SetSpec): void {
185
+ const definitions = set.componentPropertyDefinitions;
186
+ const reported: Record<string, unknown> = {};
187
+ for (const property of spec.properties) {
188
+ if (property.type !== 'VARIANT')
189
+ reported[property.name] = definitions[findKey(definitions, property.type, property.name) ?? ''] ?? null;
190
+ }
191
+ updateStamp(set, { is: { component: spec.name }, receipt: receiptOf(reported) });
192
+ }
193
+
194
+ /**
195
+ * Sets the properties an instance declares with `set`, by the display names its component gives them.
196
+ *
197
+ * Figma keys a property that is not a variant with a suffix (`Name#12:0`); the file never writes it, so the key is
198
+ * found among the instance's own by name. A property the file does not mention is not touched, and with `only`, nor
199
+ * is one whose `set <Name>` it does not list.
200
+ * // unmeasured: that setProperties takes those keys, a variant by its plain name, and an instance swap's component by id
201
+ */
202
+ export async function applyInstanceProperties(build: Build, node: SceneNode, layer: Layer, only?: ReadonlySet<string>): Promise<void> {
203
+ const set = Object.entries((layer.fields.set as SetField | undefined) ?? {}).filter(([name]) => !only || only.has(`set ${name}`));
204
+ if (layer.kind !== 'INSTANCE' || set.length === 0) return;
205
+ const instance = node as InstanceNode;
206
+ const present = instance.componentProperties;
207
+ const next: Record<string, string | boolean | VariableAlias> = {};
208
+ let text = false;
209
+ for (const [name, entry] of set) {
210
+ const key = Object.keys(present).find((candidate) => nameOf(candidate) === name);
211
+ if (key === undefined) throw new Error(`INSTANCE "${layer.address}" has no property "${name}"`);
212
+ const value = entry.value;
213
+ // A token's variable is bound to the property: the value is an alias to it (roster, *Instance and component*).
214
+ if (typeof value === 'object' && 'token' in value)
215
+ next[key] = figma.variables.createVariableAlias(build.resolved.variable(value.token));
216
+ else if (typeof value === 'object') next[key] = build.resolved.component({ key: value.key }).id;
217
+ else if (present[key].type === 'INSTANCE_SWAP') next[key] = build.resolved.component({ name: String(value) }).id;
218
+ else next[key] = value;
219
+ text = text || present[key].type === 'TEXT';
220
+ }
221
+ if (text) {
222
+ // A text that follows a property is written through the instance, so its font has to be loaded first. // unmeasured: that this is needed
223
+ const loadFonts = async (parent: BaseNode): Promise<void> => {
224
+ if (!('children' in parent)) return;
225
+ for (const child of parent.children) {
226
+ // A mixed font (a symbol) is not one to load; what is in a slot is the artboard's own, loaded already.
227
+ if (child.type === 'TEXT' && typeof child.fontName === 'object') await figma.loadFontAsync(child.fontName);
228
+ if (child.type !== 'SLOT') await loadFonts(child);
229
+ }
230
+ };
231
+ await loadFonts(instance);
232
+ }
233
+ instance.setProperties(next);
234
+ }
235
+
236
+ export function createNode(build: Build, layer: Layer): SceneNode & ChildrenMixin {
237
+ switch (layer.kind) {
238
+ case 'FRAME':
239
+ return figma.createFrame();
240
+ case 'SLOT':
241
+ return createSlot(build, layer.address);
242
+ case 'TEXT':
243
+ return figma.createText() as unknown as SceneNode & ChildrenMixin;
244
+ case 'LINE':
245
+ return figma.createLine() as unknown as SceneNode & ChildrenMixin;
246
+ case 'INSTANCE':
247
+ return componentOf(build, layer.fields.component as Value | undefined, layer.address).createInstance() as unknown as SceneNode &
248
+ ChildrenMixin;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * The fields of one layer, in the order that keeps each call from throwing.
254
+ *
255
+ * The node is already in its parent, and its parent's own fields are already
256
+ * set, so a `FILL` has an auto-layout parent to fill and a grid child has tracks
257
+ * to sit in.
258
+ *
259
+ * With `only`, a layer that exists has just those of its fields written (`withResets` says which those are), and
260
+ * what a new node is given beyond its fields (a cleared fill or stroke, no stroke weight) is not given again.
261
+ */
262
+ export async function applyFields(build: Build, node: SceneNode, layer: Layer, root: boolean, only?: ReadonlySet<string>): Promise<void> {
263
+ const f = layer.fields;
264
+ const has = (name: string): boolean => name in f && (!only || only.has(name));
265
+ const target = node as unknown as Record<string, unknown>;
266
+ const container = layer.kind === 'FRAME' || layer.kind === 'SLOT';
267
+ const references: { visible?: string; characters?: string; mainComponent?: string } = {};
268
+ // A `(literal)` on a wirable field takes it off its property, which a layer built before may still be wired to.
269
+ let unwire = false;
270
+ // The references are written as one, so each is read from the file whichever of them is written.
271
+ for (const [field, reference] of [
272
+ ['visible', 'visible'],
273
+ ['characters', 'characters'],
274
+ ['component', 'mainComponent'],
275
+ ] as const) {
276
+ const value = f[field] as Value | undefined;
277
+ if (value?.kind === 'property') references[reference] = build.keys.get(value.name);
278
+ else if (value?.kind === 'literal') unwire = true;
279
+ // A token takes a layer built before off its property too; a new layer, the root and an artboard's have none.
280
+ else if (value?.kind === 'token' && only && !root && !build.artboard) unwire = true;
281
+ }
282
+
283
+ // Layout first: everything below depends on what kind of layout the node has.
284
+ if (has('layoutMode')) target.layoutMode = plain(f.layoutMode);
285
+ if (plain(f.layoutMode) === 'GRID') {
286
+ // Tracks before the children are placed, and the children are placed as they are appended.
287
+ if (has('gridRowSizes')) setTracks(node, 'gridRowSizes', f.gridRowSizes as Track[]);
288
+ if (has('gridColumnSizes')) setTracks(node, 'gridColumnSizes', f.gridColumnSizes as Track[]);
289
+ }
290
+ if (has('grid')) {
291
+ const cell = f.grid as GridPlacement;
292
+ (node as unknown as GridChildrenMixin).setGridChildPosition(cell.row, cell.column);
293
+ if (cell.rowSpan !== undefined) (node as unknown as GridChildrenMixin).gridRowSpan = cell.rowSpan;
294
+ if (cell.columnSpan !== undefined) (node as unknown as GridChildrenMixin).gridColumnSpan = cell.columnSpan;
295
+ }
296
+
297
+ // Wrap, a grid child's alignment, and position. Measured: WRAP throws on a frame with no layout (§14), so it follows
298
+ // layoutMode; counterAxisSpacing, a dimension, follows it below. A grid child is aligned once it has its cell. An
299
+ // absolute child leaves the flow before it is sized or placed. // unmeasured: whether FILL on an absolute child throws
300
+ for (const field of [
301
+ 'layoutWrap',
302
+ 'counterAxisAlignContent',
303
+ 'gridChildHorizontalAlign',
304
+ 'gridChildVerticalAlign',
305
+ 'layoutPositioning',
306
+ ]) {
307
+ if (has(field)) target[field] = plain(f[field]);
308
+ }
309
+
310
+ // Measured: a text that is given FILL while it is still empty is 0 wide, and stays 0 wide when its characters
311
+ // arrive, so it wraps one letter to a line. The style and the characters go in before any sizing.
312
+ if (layer.kind === 'TEXT') {
313
+ const text = node as TextNode;
314
+ // A text built before may be in a font this run has not loaded, and every write to a text needs its font.
315
+ if (only && typeof text.fontName === 'object') await figma.loadFontAsync(text.fontName);
316
+ if (has('textStyle')) await writeField(build, node, layer, 'textStyle');
317
+ if (has('characters')) {
318
+ const characters = f.characters as Value;
319
+ // A wired text shows the property's default, and follows the property from there.
320
+ if (characters.kind === 'token') await writeField(build, node, layer, 'characters');
321
+ else
322
+ text.characters =
323
+ characters.kind === 'property' ? String(build.defaults.get(characters.name)) : String((characters as { value: string }).value);
324
+ }
325
+ await applyTextFields(build, text, layer, has);
326
+ }
327
+
328
+ // Measured: resize() resets hug sizing, so it comes before any HUG; and an empty hugging frame stays
329
+ // 100 tall until resized, so one with a minimum is resized to it and then set back to HUG.
330
+ const width = literal(has('width') ? f.width : undefined);
331
+ const height = literal(has('height') ? f.height : undefined);
332
+ let nextWidth = width ?? node.width;
333
+ let nextHeight = height ?? node.height;
334
+ if (container && layer.children.length === 0) {
335
+ if (width === undefined && plain(f.layoutSizingHorizontal) === 'HUG') nextWidth = literal(f.minWidth) ?? nextWidth;
336
+ if (height === undefined && plain(f.layoutSizingVertical) === 'HUG') nextHeight = literal(f.minHeight) ?? nextHeight;
337
+ }
338
+ const resized = nextWidth !== node.width || nextHeight !== node.height;
339
+ if (resized) (node as LayoutMixin).resize(nextWidth, nextHeight);
340
+ // A resize resets hug sizing, so after one the sizing the file declares is written again.
341
+ const sizing = (name: string): boolean => has(name) || (resized && name in f);
342
+
343
+ // Measured: FILL and HUG throw on a node with no auto-layout parent, which is why the node was appended first.
344
+ if (sizing('layoutSizingHorizontal')) target.layoutSizingHorizontal = plain(f.layoutSizingHorizontal);
345
+ if (sizing('layoutSizingVertical')) target.layoutSizingVertical = plain(f.layoutSizingVertical);
346
+ for (const field of LIMITS) if (has(field)) await writeField(build, node, layer, field);
347
+ for (const field of ['width', 'height'])
348
+ if (has(field) && (f[field] as Value).kind === 'token') await writeField(build, node, layer, field);
349
+ if (has('rotation')) (node as LayoutMixin).rotation = (f.rotation as { value: number }).value;
350
+
351
+ if (has('primaryAxisAlignItems')) target.primaryAxisAlignItems = plain(f.primaryAxisAlignItems);
352
+ if (has('counterAxisAlignItems')) target.counterAxisAlignItems = plain(f.counterAxisAlignItems);
353
+ // `cornerRadius` first: each corner field then overrides its own corner.
354
+ for (const field of DIMENSIONS) if (has(field)) await writeField(build, node, layer, field);
355
+
356
+ // Measured: a new frame starts with a white fill, and a fill Toghen did not declare is not one it wants.
357
+ // unmeasured: whether a new slot and a new component start with it too; they are cleared the same way
358
+ if (has('fills')) await writeField(build, node, layer, 'fills');
359
+ else if (!only && (container || root)) (node as GeometryMixin).fills = [];
360
+ if (has('strokes')) await writeField(build, node, layer, 'strokes');
361
+ // a new line starts with a 1 px black stroke (measured 10 October 2026); one the file does not declare is not one it wants, as with the frame's fill
362
+ else if (!only && layer.kind === 'LINE') (node as GeometryMixin).strokes = [];
363
+ if (has('effectStyle')) await writeField(build, node, layer, 'effectStyle');
364
+ // Unit 4: effects and layout grids, each written whole.
365
+ if (has('effects')) await writeField(build, node, layer, 'effects');
366
+ if (has('layoutGrids')) await writeField(build, node, layer, 'layoutGrids');
367
+
368
+ // Appearance, constraints and the stroke's shape: none depends on another field. `x`, `y`, `cornerSmoothing` and
369
+ // `counterAxisSpacing` are dimensions, written above with the others.
370
+ for (const field of ['blendMode', 'strokeCap', 'strokeJoin']) if (has(field)) target[field] = plain(f[field]);
371
+ if (has('constraints')) target.constraints = { ...(f.constraints as Constraints) };
372
+ if (has('dashPattern')) await writeField(build, node, layer, 'dashPattern');
373
+
374
+ // Measured: per-side stroke weights take strokeAlign INSIDE, and strokeWeight is set before them. Neither the align
375
+ // nor the zero weight below is a field: they are steps that go with writing a side (specs, *Drawing fields*), kept so a
376
+ // set built before draws as it did. A declared `strokeAlign`, a field, is written after the sides.
377
+ const sides = BOUND_BY_PARTS.strokeWeight;
378
+ const anySide = sides.some(has);
379
+ if (anySide) target.strokeAlign = 'INSIDE';
380
+ if (has('strokeWeight')) await writeField(build, node, layer, 'strokeWeight');
381
+ // A file that gives only a side means the other sides are none.
382
+ else if (!only && anySide) target.strokeWeight = 0;
383
+ for (const side of sides) if (has(side)) await writeField(build, node, layer, side);
384
+ // A declared strokeAlign wins over the INSIDE above: written after the sides, and again whenever a side is, so a side
385
+ // patched alone does not leave it INSIDE. // unmeasured: whether Figma keeps per-side weights at OUTSIDE or CENTER
386
+ if (has('strokeAlign') || (anySide && 'strokeAlign' in f)) target.strokeAlign = plain(f.strokeAlign);
387
+
388
+ // Measured: slots clip by default; the file says so whenever it wants otherwise, and a declared value is always written.
389
+ if (has('clipsContent')) target.clipsContent = plain(f.clipsContent);
390
+ if (has('isExposedInstance')) target.isExposedInstance = plain(f.isExposedInstance);
391
+
392
+ if (layer.kind === 'TEXT' && has('textAutoResize'))
393
+ (node as TextNode).textAutoResize = plain(f.textAutoResize) as TextNode['textAutoResize'];
394
+
395
+ if (has('visible')) {
396
+ const visible = f.visible as Value;
397
+ if (visible.kind === 'token') await writeField(build, node, layer, 'visible');
398
+ else node.visible = visible.kind === 'property' ? Boolean(build.defaults.get(visible.name)) : (visible as { value: boolean }).value;
399
+ }
400
+
401
+ // Pins are applied before the members are combined: a pin set later would have to be set on each member of the set.
402
+ for (const [collection, mode] of Object.entries(has('modes') ? (f.modes as Record<string, string>) : {})) {
403
+ const pin = build.resolved.mode(collection, mode);
404
+ // `resolve` refuses a library collection's pin in Figma, where it is not measured.
405
+ if (pin.library) throw new Error(`modes ${collection}=${mode}: a library collection's mode is not measured in Figma yet`);
406
+ node.setExplicitVariableModeForCollection(pin.collection, pin.modeId);
407
+ }
408
+
409
+ if ((!only || ['visible', 'characters', 'component'].some(has)) && (unwire || Object.keys(references).length > 0)) {
410
+ const assign = (): void => {
411
+ node.componentPropertyReferences = references;
412
+ };
413
+ if (build.deferred) build.deferred.push(assign);
414
+ else assign();
415
+ }
416
+ }
417
+
418
+ /** What writing `fields` of a layer comes to: `cornerRadius` sets every corner and `strokeWeight` every side, so those the file declares are written again after it. */
419
+ export function withResets(layer: Layer, fields: string[]): Set<string> {
420
+ const out = new Set(fields);
421
+ for (const [field, after] of Object.entries(BOUND_BY_PARTS)) {
422
+ if (out.has(field)) for (const other of after) if (other in layer.fields) out.add(other);
423
+ }
424
+ // New characters take the text fields the file declares again. unmeasured: whether a whole-text binding survives new characters
425
+ if (out.has('characters')) for (const field of Object.keys(TEXT_FIELDS)) if (field in layer.fields) out.add(field);
426
+ // An instance's properties may switch its variant, and with it its size: the sizing the file declares is written again.
427
+ if (layer.kind === 'INSTANCE' && fields.some((field) => field.startsWith('set ')))
428
+ for (const field of SIZING) if (field in layer.fields) out.add(field);
429
+ return out;
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Text fields
434
+ // ---------------------------------------------------------------------------
435
+
436
+ /** A font's halves first: they decide the font, which every later write to the text needs loaded. */
437
+ const FONT_HALVES = ['fontFamily', 'fontStyle', 'fontWeight'];
438
+ /** The rest, written after the font; `maxLines` last, after `textTruncation`. */
439
+ const TEXT_REST = Object.keys(TEXT_FIELDS).filter((name) => !FONT_HALVES.includes(name) && name !== 'maxLines');
440
+
441
+ /**
442
+ * The fields of a text beyond its characters, style and sizing, after its characters: a field is bound on the whole
443
+ * text, so the text has its length. The font a binding names is loaded before it is bound, and the font the text
444
+ * ends with after.
445
+ */
446
+ async function applyTextFields(build: Build, text: TextNode, layer: Layer, has: (name: string) => boolean): Promise<void> {
447
+ const f = layer.fields;
448
+ const target = text as unknown as Record<string, unknown>;
449
+ const halves = FONT_HALVES.filter(has);
450
+ if (halves.length > 0) {
451
+ // The family and the style the variables hold for this text; a weight's style is Figma's to choose.
452
+ const current = text.fontName as FontName;
453
+ const holds = (field: string, otherwise: string): string => {
454
+ const value = f[field] as Value | undefined;
455
+ return value?.kind === 'token' && has(field) ? String(build.resolved.variable(value.path).resolveForConsumer(text).value) : otherwise;
456
+ };
457
+ await figma.loadFontAsync({ family: holds('fontFamily', current.family), style: holds('fontStyle', current.style) });
458
+ for (const field of halves) await writeField(build, text, layer, field);
459
+ // unmeasured: whether binding a weight needs the style it picks loaded first; the run loads it after
460
+ if (typeof text.fontName === 'object') await figma.loadFontAsync(text.fontName);
461
+ }
462
+ for (const field of TEXT_REST) {
463
+ if (!has(field)) continue;
464
+ if (FIELDS[field].value === 'enum') target[field] = (f[field] as { value: unknown }).value;
465
+ else await writeField(build, text, layer, field);
466
+ }
467
+ // Measured: `maxLines` set while `textTruncation` is DISABLED is dropped silently, so it comes after it.
468
+ if (has('maxLines')) await writeField(build, text, layer, 'maxLines');
469
+ }
470
+
471
+ export async function buildChildren(build: Build, parent: ChildrenMixin, layer: Layer): Promise<void> {
472
+ for (const child of layer.children) {
473
+ // A layer in an instance's slot goes into that slot, which the instance has once it is made.
474
+ const into =
475
+ child.slot === undefined ? parent : (slotNodeOf(parent as unknown as BaseNode, child.slot, layer.address) as ChildrenMixin);
476
+ const node = createNode(build, child);
477
+ // Append before anything is sized: FILL and HUG need the auto-layout parent to exist.
478
+ into.appendChild(node as SceneNode);
479
+ node.name = child.name ?? child.address;
480
+ stampNew(build, node as SceneNode, child);
481
+ // An instance's properties first: a variant switch may resize it, and the size the file states comes after.
482
+ await applyInstanceProperties(build, node as SceneNode, child);
483
+ await applyFields(build, node as SceneNode, child, false);
484
+ if (child.kind === 'FRAME' || child.kind === 'SLOT' || child.kind === 'INSTANCE') await buildChildren(build, node, child);
485
+ }
486
+ }
487
+
488
+ async function buildMember(
489
+ member: Member,
490
+ spec: SetSpec,
491
+ resolved: Resolved,
492
+ warnings: string[],
493
+ written: Written[],
494
+ ): Promise<ComponentNode> {
495
+ // Named for the combination when it is created: renaming variants one by one breaks
496
+ // variantProperties partway through the loop (measured).
497
+ const root = figma.createComponent();
498
+ root.name = member.name;
499
+
500
+ const build: Build = { member: root, host: root, spec, resolved, keys: new Map(), defaults: new Map(), warnings, written };
501
+ // The root layer is the member itself: one node, one stamp that says both.
502
+ stampNew(build, root, member.tree, { member: member.name });
503
+
504
+ // Properties go on the member before the members are combined, and are wired as their layers are built.
505
+ for (const property of spec.properties) {
506
+ if (property.type === 'BOOLEAN' || property.type === 'TEXT') {
507
+ build.keys.set(property.name, root.addComponentProperty(property.name, property.type, defaultOf(resolved, property)));
508
+ build.defaults.set(property.name, shownDefault(resolved, property));
509
+ } else if (property.type === 'INSTANCE_SWAP') {
510
+ // Typed: the default of an instance swap is a component's node id.
511
+ build.keys.set(property.name, root.addComponentProperty(property.name, 'INSTANCE_SWAP', resolved.component(property.default).id));
512
+ }
513
+ }
514
+
515
+ await applyFields(build, root, member.tree, true);
516
+ await buildChildren(build, root, member.tree);
517
+ return root;
518
+ }
519
+
520
+ /** A `BOOLEAN` or `TEXT` property's default as Figma takes it: a token's as an alias to its variable, which binds it. */
521
+ export function defaultOf(resolved: Resolved, property: Property): string | boolean | VariableAlias {
522
+ const given = (property as { default: string | boolean | { token: string } }).default;
523
+ return typeof given === 'object' ? figma.variables.createVariableAlias(resolved.variable(given.token)) : given;
524
+ }
525
+
526
+ /** What a layer wired to a `BOOLEAN` or `TEXT` property shows: its default, or what the default's variable holds in its default mode. */
527
+ export function shownDefault(resolved: Resolved, property: Property): string | boolean {
528
+ const given = (property as { default: string | boolean | { token: string } }).default;
529
+ return typeof given === 'object' ? resolved.shows(given.token) : given;
530
+ }
531
+
532
+ export function findKey(definitions: ComponentPropertyDefinitions, type: ComponentPropertyType, name: string): string | undefined {
533
+ return Object.keys(definitions).find((key) => definitions[key].type === type && nameOf(key) === name);
534
+ }
535
+
536
+ export function placeMembers(set: ComponentSetNode, members: Member[], spec: SetSpec, copies: ReadonlySet<SceneNode> = new Set()): void {
537
+ const { cells, columnGaps, rowGaps } = memberBoard(members, spec);
538
+ const byName = new Map<string, ComponentNode>();
539
+ // A person's copy of a member is not Toghen's to move.
540
+ for (const child of set.children) if (child.type === 'COMPONENT' && !copies.has(child)) byName.set(child.name, child);
541
+
542
+ // A column or row no member is in takes no size, but keeps its gap.
543
+ const widths: number[] = new Array(columnGaps.length).fill(0);
544
+ const heights: number[] = new Array(rowGaps.length).fill(0);
545
+ for (const cell of cells) {
546
+ const node = byName.get(cell.name);
547
+ if (!node) continue;
548
+ widths[cell.column] = Math.max(widths[cell.column], node.width);
549
+ heights[cell.row] = Math.max(heights[cell.row], node.height);
550
+ }
551
+ const offsets = (sizes: number[], gaps: number[]): number[] => {
552
+ let at = 0;
553
+ return sizes.map((size, index) => {
554
+ at += gaps[index];
555
+ const start = at;
556
+ at += size;
557
+ return start;
558
+ });
559
+ };
560
+ const xs = offsets(widths, columnGaps);
561
+ const ys = offsets(heights, rowGaps);
562
+
563
+ let right = 1;
564
+ let bottom = 1;
565
+ for (const cell of cells) {
566
+ const node = byName.get(cell.name);
567
+ if (!node) continue;
568
+ node.x = xs[cell.column];
569
+ node.y = ys[cell.row];
570
+ right = Math.max(right, node.x + node.width);
571
+ bottom = Math.max(bottom, node.y + node.height);
572
+ }
573
+ // The set is resized from the members' measured bounds: combining does not shrink or grow it around them.
574
+ set.resizeWithoutConstraints(right, bottom);
575
+ }
576
+
577
+ /**
578
+ * Runs a step that creates nodes, and if it throws removes every node it made before the error goes on.
579
+ *
580
+ * A plugin has no rollback: a node made before the failure stays in the file. `made` is what the step has put outside
581
+ * the page's top level (a member appended to a set); what it left at the top level, the node being built included from
582
+ * the moment it exists, is found by comparing the page with what it held before.
583
+ */
584
+ export async function orRemoveWhatItMade<T>(page: PageNode, made: SceneNode[], step: () => Promise<T>): Promise<T> {
585
+ const before = new Set(page.children.map((node) => node.id));
586
+ try {
587
+ return await step();
588
+ } catch (error) {
589
+ for (const node of [...made, ...page.children.filter((child) => !before.has(child.id))]) {
590
+ try {
591
+ if (!node.removed) node.remove();
592
+ } catch (_gone) {
593
+ // Removing is for tidiness; the step's own error is the one to report.
594
+ }
595
+ }
596
+ throw error;
597
+ }
598
+ }
599
+
600
+ export function editProperties(set: ComponentSetNode, spec: SetSpec): void {
601
+ const definitions = set.componentPropertyDefinitions;
602
+ for (const property of spec.properties) {
603
+ if (property.type !== 'SLOT' && property.type !== 'INSTANCE_SWAP') continue;
604
+ const key = findKey(definitions, property.type, property.name);
605
+ if (key === undefined) continue;
606
+ const options: Parameters<ComponentSetNode['editComponentProperty']>[1] = {};
607
+ if (property.preferredValues) options.preferredValues = keyedPreferred(property.preferredValues);
608
+ if (property.type === 'SLOT') {
609
+ if (property.description !== undefined) options.description = property.description;
610
+ if (property.slotSettings) options.slotSettings = property.slotSettings;
611
+ }
612
+ if (Object.keys(options).length > 0) set.editComponentProperty(key, options);
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Creates a component set that does not exist yet, in the order the traps impose.
618
+ *
619
+ * One page switch, fonts loaded, every member built whole (properties, layers,
620
+ * pins), then combined, named, stamped, laid out and given the settings a
621
+ * property can only take once it belongs to a set. A plugin has no rollback, so a
622
+ * failure anywhere removes what the step made (`orRemoveWhatItMade`) before it
623
+ * throws: a set is created whole or not at all. Where it stands on the page is the
624
+ * restack's (`endOfRun.ts`), at the end of the run.
625
+ */
626
+ export async function applyCreateSet(step: Extract<Step, { kind: 'CREATE_SET' }>, resolved: Resolved): Promise<Applied> {
627
+ const warnings: string[] = [];
628
+ const { component, members } = step;
629
+
630
+ // Page context resets on every call, so this is the one switch, before any node exists.
631
+ await figma.setCurrentPageAsync(resolved.page);
632
+
633
+ // A font has to be loaded before a node that uses it is written to.
634
+ const hasText = (layer: Layer): boolean => layer.kind === 'TEXT' || layer.children.some(hasText);
635
+ if (members.some((member) => hasText(member.tree))) await figma.loadFontAsync(DEFAULT_FONT);
636
+ for (const style of resolved.textStyles()) await figma.loadFontAsync(style.fontName);
637
+
638
+ return orRemoveWhatItMade(resolved.page, [], async () => {
639
+ const built: ComponentNode[] = [];
640
+ const written: Written[] = [];
641
+ for (const member of members) built.push(await buildMember(member, component, resolved, warnings, written));
642
+
643
+ // Properties and pins are on the members already: a set's members cannot take them afterwards.
644
+ const set = figma.combineAsVariants(built, resolved.page);
645
+ set.name = component.name;
646
+ if (component.description !== undefined) set.description = component.description;
647
+
648
+ placeMembers(set, members, component);
649
+ editProperties(set, component);
650
+ // Last: what Figma reports once the members are in the set and its properties are settled.
651
+ stampSet(set, component);
652
+ await stampReceipts(written);
653
+
654
+ return { setId: set.id, created: members.map((member) => member.name), warnings };
655
+ });
656
+ }
657
+
658
+ /**
659
+ * Creates an artboard that does not exist yet: a frame on the run's page, stamped, with its layers in it.
660
+ *
661
+ * Fonts are loaded first, the frame is made and sized, and then its layers are appended before they are sized, as for a
662
+ * member. An instance takes its properties before its slots are filled, since a variant may decide which slots it has.
663
+ */
664
+ export async function applyCreateArtboard(step: Extract<Step, { kind: 'CREATE_ARTBOARD' }>, resolved: Resolved): Promise<AppliedArtboard> {
665
+ const warnings: string[] = [];
666
+ const { member } = step;
667
+
668
+ // Page context resets on every call, so this is the one switch, before any node exists.
669
+ await figma.setCurrentPageAsync(resolved.page);
670
+ const hasText = (layer: Layer): boolean => layer.kind === 'TEXT' || layer.children.some(hasText);
671
+ if (hasText(member.tree)) await figma.loadFontAsync(DEFAULT_FONT);
672
+ for (const style of resolved.textStyles()) await figma.loadFontAsync(style.fontName);
673
+
674
+ const frame = figma.createFrame();
675
+ frame.name = member.tree.name ?? member.tree.address;
676
+ const build = artboardBuild(frame, member.name, resolved, warnings, []);
677
+ stampNew(build, frame, member.tree, { artboard: member.name });
678
+ await applyFields(build, frame, member.tree, true);
679
+ await buildChildren(build, frame, member.tree);
680
+ await stampReceipts(build.written);
681
+
682
+ const created: string[] = [];
683
+ const collect = (layer: Layer): void => {
684
+ created.push(layer.address);
685
+ layer.children.forEach(collect);
686
+ };
687
+ collect(member.tree);
688
+ return { frameId: frame.id, created, warnings };
689
+ }
690
+
691
+ /** What building the layers of an artboard needs: it has no component properties, so none of a member's wiring. */
692
+ export function artboardBuild(frame: FrameNode, name: string, resolved: Resolved, warnings: string[], written: Written[]): Build {
693
+ return {
694
+ member: frame as unknown as ComponentNode,
695
+ host: frame as unknown as ComponentNode,
696
+ spec: { name, properties: [] },
697
+ resolved,
698
+ keys: new Map(),
699
+ defaults: new Map(),
700
+ warnings,
701
+ written,
702
+ artboard: true,
703
+ };
704
+ }