@toghen/figma 0.0.0-stage → 0.1.1

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 +1067 -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 +24161 -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 +565 -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,1067 @@
1
+ /**
2
+ * The component engine's writes, in FigCraft's commands: the FigCraft target's apply (plan, *Components in FigCraft*).
3
+ *
4
+ * The run is the plugin's own (`main/components/run.ts`): it resolves, snapshots and plans through the FigCraft view of
5
+ * the file (`figcraftScene.ts`), and hands the steps here instead of to Figma's apply. Each step becomes FigCraft
6
+ * commands, sent as one batch, one undo step: `{"$step": n}` stands for what an earlier command of the batch made, so a
7
+ * member is built whole in one request. The receipts are then taken as in Figma, last, from what FigCraft reports once
8
+ * every write is in, through the same `stampReceipts`, and sent as one more batch.
9
+ *
10
+ * Not every field is built for FigCraft yet: `refuses` names those a run would write, and the run stops before its first
11
+ * write, a dry run included.
12
+ */
13
+
14
+ import { renameAxis, slotKey } from '../shared/components/diff.js';
15
+ import { FIELDS } from '../shared/components/fields.js';
16
+ import type {
17
+ ComponentRef,
18
+ Constraints,
19
+ GridPlacement,
20
+ Layer,
21
+ LayoutGridRecord,
22
+ Member,
23
+ Property,
24
+ SetField,
25
+ Track,
26
+ Value,
27
+ } from '../shared/components/model.js';
28
+ import type { Desired, SetSpec, Step } from '../shared/components/steps.js';
29
+ import { placeMembers, slotNodesOf, stampSet, withResets, type Applied, type AppliedArtboard } from '../main/components/apply.js';
30
+ import { alphaOf, effectsOf, gradientPaint, layoutGridOf, literalBlur, withAlias } from '../main/components/fieldWriter.js';
31
+ import { stampReceipts, type Written } from '../main/components/receipt.js';
32
+ import { preferredOf, type Resolved } from '../main/components/resolve.js';
33
+ import { membersOf, stampedTree } from '../main/components/snapshot.js';
34
+ import { stampOn, strip, updateStamp, type Identity } from '../main/components/stamp.js';
35
+ import type { Updated } from '../main/components/update.js';
36
+ import { UNREPORTED_KEY, type Scene, type Unreported } from './figcraftScene.js';
37
+ import { effectsValue, paintsValue, type Step as Command } from './figcraftFile.js';
38
+ import { splitKey } from './figcraftLibrary.js';
39
+
40
+ /** What the apply needs of the target: a batch in the pinned file, the file as it is now, and the view's writes sent. */
41
+ export interface Target {
42
+ batch(label: string, commands: Command[]): Promise<unknown[]>;
43
+ scene(): Promise<Scene>;
44
+ flush(label: string): Promise<void>;
45
+ }
46
+
47
+ /** A node the batch has not made yet, as the batch names it, or one that exists, by its id. */
48
+ type Ref = number | { $step: number };
49
+
50
+ class Batch {
51
+ readonly commands: Command[] = [];
52
+ add(command: string, params: Record<string, unknown>): Ref {
53
+ this.commands.push({ command, params });
54
+ return { $step: this.commands.length - 1 };
55
+ }
56
+ }
57
+
58
+ const idOf = (results: unknown[], ref: Ref): number => (typeof ref === 'number' ? ref : Number((results[ref.$step] as { id: unknown }).id));
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // what is built for FigCraft
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** A plain field: its `node.set` name, and its value in FigCraft's spelling. */
65
+ const lower = (value: unknown): string => String(value).toLowerCase();
66
+ const camel = (value: unknown): string => lower(value).replace(/_(.)/g, (_, c: string) => c.toUpperCase());
67
+ const PLAIN: Record<string, [string, (value: unknown) => unknown]> = {
68
+ layoutMode: ['layout', (value) => (value === 'NONE' ? null : lower(value))],
69
+ layoutWrap: ['wrap', (value) => value === 'WRAP'],
70
+ layoutPositioning: ['absolute', (value) => value === 'ABSOLUTE'],
71
+ layoutSizingHorizontal: ['sizingH', lower],
72
+ layoutSizingVertical: ['sizingV', lower],
73
+ primaryAxisAlignItems: ['mainAlign', camel],
74
+ counterAxisAlignItems: ['crossAlign', lower],
75
+ strokeAlign: ['strokeAlign', lower],
76
+ clipsContent: ['clip', (value) => value],
77
+ textAutoResize: ['textAutoResize', camel],
78
+ textAlignHorizontal: ['textAlign', lower],
79
+ textAlignVertical: ['textAlignVertical', lower],
80
+ textCase: ['textCase', camel],
81
+ textDecoration: ['textDecoration', lower],
82
+ visible: ['visible', (value) => value],
83
+ rotation: ['rotation', (value) => value],
84
+ counterAxisAlignContent: ['contentAlign', camel],
85
+ strokeJoin: ['strokeJoin', lower],
86
+ strokeCap: ['strokeCap', (value) => STROKE_CAP[String(value)] ?? camel(value)],
87
+ blendMode: ['blend', (value) => BLEND[String(value)] ?? camel(value)],
88
+ leadingTrim: ['leadingTrim', camel],
89
+ gridChildHorizontalAlign: ['gridAlignH', (value) => (value === 'AUTO' ? null : lower(value))],
90
+ gridChildVerticalAlign: ['gridAlignV', (value) => (value === 'AUTO' ? null : lower(value))],
91
+ };
92
+
93
+ /** Figma's line caps FigCraft spells otherwise than in camel case (measured 23 October 2026, 0.10.0). */
94
+ export const STROKE_CAP: Record<string, string> = { ARROW_LINES: 'lineArrow', ARROW_EQUILATERAL: 'triangleArrow' };
95
+
96
+ /** Figma's blend modes FigCraft spells otherwise than in camel case. */
97
+ export const BLEND: Record<string, string> = { PASS_THROUGH: 'pass through', LINEAR_BURN: 'plusDarker', LINEAR_DODGE: 'plusLighter' };
98
+
99
+ /** Figma's constraints in FigCraft's words, per axis. */
100
+ export const CONSTRAINT: Record<'horizontal' | 'vertical', Record<string, string>> = {
101
+ horizontal: { MIN: 'left', MAX: 'right', STRETCH: 'leftRight', CENTER: 'center', SCALE: 'scale' },
102
+ vertical: { MIN: 'top', MAX: 'bottom', STRETCH: 'topBottom', CENTER: 'center', SCALE: 'scale' },
103
+ };
104
+
105
+ /**
106
+ * Fields FigCraft takes and does not report (measured 23 October 2026): normal against pass-through, corner smoothing,
107
+ * a line's cap, a text's maximum lines, paragraph spacing and indent, and constraints where FigCraft leaves them out. What
108
+ * Toghen wrote of them is kept beside the stamp, and read back from there wherever FigCraft reports nothing.
109
+ */
110
+ export const UNREPORTED_FIELDS = [
111
+ 'blendMode',
112
+ 'cornerSmoothing',
113
+ 'strokeCap',
114
+ 'maxLines',
115
+ 'paragraphSpacing',
116
+ 'paragraphIndent',
117
+ 'constraints',
118
+ 'isExposedInstance',
119
+ ];
120
+
121
+ /** A dimension: its `node.set` name, and the field `variable.bind` binds. */
122
+ const DIMENSION: Record<string, string> = {
123
+ itemSpacing: 'gap',
124
+ counterAxisSpacing: 'crossGap',
125
+ paddingTop: 'paddingTop',
126
+ paddingRight: 'paddingRight',
127
+ paddingBottom: 'paddingBottom',
128
+ paddingLeft: 'paddingLeft',
129
+ cornerRadius: 'cornerRadius',
130
+ strokeWeight: 'strokeWeight',
131
+ strokeTopWeight: 'strokeTopWeight',
132
+ strokeRightWeight: 'strokeRightWeight',
133
+ strokeBottomWeight: 'strokeBottomWeight',
134
+ strokeLeftWeight: 'strokeLeftWeight',
135
+ opacity: 'opacity',
136
+ width: 'width',
137
+ height: 'height',
138
+ minWidth: 'minWidth',
139
+ maxWidth: 'maxWidth',
140
+ minHeight: 'minHeight',
141
+ maxHeight: 'maxHeight',
142
+ x: 'x',
143
+ y: 'y',
144
+ // In a grid, `gap` is the column gap and `crossGap` the row gap (measured 23 October 2026).
145
+ gridColumnGap: 'gap',
146
+ gridRowGap: 'crossGap',
147
+ cornerSmoothing: 'cornerSmoothing',
148
+ listSpacing: 'listSpacing',
149
+ maxLines: 'maxLines',
150
+ };
151
+ const CORNERS = ['topLeftRadius', 'topRightRadius', 'bottomRightRadius', 'bottomLeftRadius'];
152
+ const TEXT = ['fontFamily', 'fontStyle', 'fontWeight', 'fontSize', 'lineHeight', 'letterSpacing', 'paragraphSpacing', 'paragraphIndent'];
153
+ const OTHER = [
154
+ 'modes',
155
+ 'fills',
156
+ 'strokes',
157
+ 'effects',
158
+ 'layoutGrids',
159
+ 'effectStyle',
160
+ 'textStyle',
161
+ 'characters',
162
+ 'component',
163
+ 'set',
164
+ 'constraints',
165
+ 'dashPattern',
166
+ 'grid',
167
+ 'gridRowSizes',
168
+ 'gridColumnSizes',
169
+ 'isExposedInstance',
170
+ ];
171
+
172
+ /** The fields a FigCraft run writes. Any other is refused by name, before the run writes anything. */
173
+ export const BUILT = new Set([...Object.keys(PLAIN), ...Object.keys(DIMENSION), ...CORNERS, ...TEXT, ...OTHER]);
174
+
175
+ /** Where a new member stands while it joins its set: off the set's top-left corner. The end of the run lays the members out. */
176
+ const AWAY = 100000;
177
+
178
+ /** Why a run cannot be built in FigCraft: each field, kind or property it would need that is not built here yet. */
179
+ export function refuses(desired: Desired, steps: Step[], _resolved: Resolved): string[] {
180
+ const out = new Set<string>();
181
+ const where = (layer: Layer): string => `"${desired.component.name}" ${layer.address}`;
182
+ const visit = (layer: Layer, fields: readonly string[] = Object.keys(layer.fields), deep = true): void => {
183
+ for (const field of fields) {
184
+ if (!BUILT.has(field) && !field.startsWith('set ')) out.add(`${where(layer)}: the field ${field} is not built in FigCraft yet`);
185
+ if (field === 'strokeCap' && (layer.fields.strokeCap as { value?: unknown }).value === 'TRIANGLE_FILLED')
186
+ out.add(`${where(layer)}: the line cap TRIANGLE_FILLED has no match in FigCraft, whose one triangle is ARROW_EQUILATERAL`);
187
+ if (field === 'textTruncation')
188
+ out.add(
189
+ `${where(layer)}: textTruncation is not built in FigCraft, whose truncation is a text's sizing (textAutoResize "truncate"), not Figma's ending`,
190
+ );
191
+ const named = layer.fields.component as Value;
192
+ if (field === 'component' && named.kind === 'key' && !splitKey(named.key))
193
+ out.add(
194
+ `${where(layer)}: a component named by a Figma key is not built in FigCraft, whose keys are <library id>:<asset id>; name the component, and declare its library with --use`,
195
+ );
196
+ }
197
+ if (deep) {
198
+ layer.children.forEach((child) => {
199
+ visit(child);
200
+ });
201
+ }
202
+ };
203
+ for (const step of steps) {
204
+ if (step.kind === 'CREATE_SET') {
205
+ step.members.forEach((member) => {
206
+ visit(member.tree);
207
+ });
208
+ } else if (step.kind === 'CREATE_ARTBOARD' || step.kind === 'CREATE_MEMBER') visit(step.member.tree);
209
+ else if (step.kind === 'CREATE_LAYER' || step.kind === 'REPLACE_LAYER') visit(step.layer, Object.keys(step.layer.fields), false);
210
+ else if (step.kind === 'PATCH_LAYER') visit(step.layer, step.fields, false);
211
+ }
212
+ for (const property of desired.component.properties) {
213
+ if (preferredOf(property).some((value) => 'key' in value)) {
214
+ out.add(
215
+ `"${desired.component.name}": the preferred values of "${property.name}" name Figma's published keys, which FigCraft does not hold; name the components with name=`,
216
+ );
217
+ }
218
+ const given = (property as { default?: unknown }).default;
219
+ if ((property.type === 'TEXT' || property.type === 'BOOLEAN') && typeof given === 'object')
220
+ out.add(`"${desired.component.name}": the default of "${property.name}" is a token, which FigCraft does not bind to a property yet`);
221
+ }
222
+ return [...out];
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // one layer's fields, as commands
227
+ // ---------------------------------------------------------------------------
228
+
229
+ interface Build {
230
+ resolved: Resolved;
231
+ spec: SetSpec;
232
+ /** Property name to its default, which a wired field shows. */
233
+ defaults: Map<string, string | boolean>;
234
+ /** Wires to make once the set has its properties: a layer, the field, the property (or none, to unwire). */
235
+ wires: Array<{ ref: Ref; field: 'visible' | 'characters' | 'mainComponent'; property: string | null }>;
236
+ /** What fills a new instance's slot, made once the instance and its slot layer exist. */
237
+ later: Array<{ instance: Ref; layer: Layer }>;
238
+ /** Frames to make slots, and instances to expose, once they are inside a main component, which FigCraft requires. */
239
+ slots: Array<{ ref: Ref; name: string }>;
240
+ exposes: Array<{ ref: Ref; exposed: boolean }>;
241
+ }
242
+
243
+ /** A length as FigCraft takes one: pixels as a number, a percentage as `"150%"`, auto as `"auto"`. */
244
+ function lengthFor(value: unknown): unknown {
245
+ if (typeof value !== 'object' || value === null) return value;
246
+ const { unit, value: amount } = value as { unit: string; value?: number };
247
+ return unit === 'AUTO' ? 'auto' : unit === 'PERCENT' ? `${amount}%` : amount;
248
+ }
249
+
250
+ /**
251
+ * A file's paint as FigCraft takes it, and what is then bound in it, by the field after the paint's index: `color` for
252
+ * a solid, `stops/<j>/color` for a gradient's stop that follows a token.
253
+ */
254
+ function paintFor(build: Build, paint: Value): { paint: unknown; binds: Array<[string, string]> } {
255
+ if (paint.kind !== 'token') return { paint: String((paint as { value: string }).value), binds: [] };
256
+ const composite = build.resolved.composite(paint.path);
257
+ if (composite?.paint) {
258
+ const gradient = gradientPaint(build.resolved, composite);
259
+ const binds = gradient.gradientStops.flatMap(
260
+ (stop, j): Array<[string, string]> => (stop.boundVariables?.color ? [[`stops/${j}/color`, stop.boundVariables.color.id]] : []),
261
+ );
262
+ return { paint: paintsValue({ paints: [gradient] })[0], binds };
263
+ }
264
+ const variable = build.resolved.color(paint.path);
265
+ const alpha = alphaOf(build.resolved, paint.path);
266
+ // The colour is the variable's once it is bound, which FigCraft paints at once (measured); black stands in until then.
267
+ return { paint: { type: 'solid', color: '#000000', ...(alpha < 1 ? { opacity: alpha } : {}) }, binds: [['color', variable.id]] };
268
+ }
269
+
270
+ /** A grid track as FigCraft takes one: Figma's `FIXED`, `FLEX` and `HUG` are FigCraft's `fixed`, `fill` and `hug`. */
271
+ const trackFor = (track: Track): { sizing: string; value?: number } =>
272
+ track.type === 'HUG' ? { sizing: 'hug' } : { sizing: track.type === 'FLEX' ? 'fill' : 'fixed', value: track.value ?? 1 };
273
+
274
+ /** FigCraft's names for a layout grid's fields, and for the parts `variable.bind` binds (`layoutGrids/<i>/count`). */
275
+ const GRID_AS: Record<string, string> = { count: 'count', gutterSize: 'gutter', offset: 'offset', sectionSize: 'size' };
276
+
277
+ /** A layout grid as FigCraft's `grids` takes it, from Figma's. */
278
+ function gridFor(grid: LayoutGrid): Record<string, unknown> {
279
+ const read = grid as unknown as Record<string, unknown>;
280
+ const out: Record<string, unknown> = { kind: grid.pattern.toLowerCase(), visible: grid.visible ?? true, color: grid.color };
281
+ if (grid.pattern !== 'GRID') out.align = grid.alignment.toLowerCase();
282
+ for (const [figma, fc] of Object.entries(GRID_AS)) if (typeof read[figma] === 'number') out[fc] = read[figma];
283
+ return out;
284
+ }
285
+
286
+ /**
287
+ * The commands that write a layer's fields, in Figma's order (`applyFields`): layout first, then the text, the size,
288
+ * the dimensions, the paints, the strokes, visibility, modes. With `only`, those fields alone, and nothing a new node
289
+ * is given beyond them.
290
+ */
291
+ function fieldCommands(batch: Batch, build: Build, ref: Ref, layer: Layer, root: boolean, only?: ReadonlySet<string>): void {
292
+ const f = layer.fields;
293
+ const has = (name: string): boolean => name in f && (!only || only.has(name));
294
+ const set = (props: Record<string, unknown>): void => void batch.add('node.set', { ids: [ref], props });
295
+ const bind = (field: string, variable: string | null): void => void batch.add('variable.bind', { ids: [ref], field, variable });
296
+ const value = (name: string): Value => f[name] as Value;
297
+ const plainOf = (v: Value): unknown => (v as { value: unknown }).value;
298
+ const container = layer.kind === 'FRAME';
299
+
300
+ const writePlain = (name: string): void => {
301
+ const v = value(name);
302
+ if (v.kind === 'property' || v.kind === 'token') return;
303
+ const [prop, to] = PLAIN[name];
304
+ set({ [prop]: to(plainOf(v)) });
305
+ };
306
+ const writeDimension = (name: string): void => {
307
+ const v = value(name);
308
+ const fc = DIMENSION[name] ?? name;
309
+ if (v.kind === 'token' && FIELDS[name]?.token?.bind) bind(fc, build.resolved.variable(v.path).id);
310
+ else if (v.kind === 'token') set({ [fc]: build.resolved.valueOf(v.path)!.value });
311
+ else set({ [fc]: plainOf(v) });
312
+ };
313
+ const wire = (field: 'visible' | 'characters' | 'mainComponent', name: string): void => {
314
+ const v = f[name] as Value | undefined;
315
+ if (v?.kind === 'property') {
316
+ build.wires.push({ ref, field, property: v.name });
317
+ } else if (v?.kind === 'literal' || (v?.kind === 'token' && only && !root)) build.wires.push({ ref, field, property: null });
318
+ };
319
+
320
+ if (has('layoutMode')) writePlain('layoutMode');
321
+ for (const [name, count, tracks] of [
322
+ ['gridColumnSizes', 'gridColumns', 'columnTracks'],
323
+ ['gridRowSizes', 'gridRows', 'rowTracks'],
324
+ ] as const) {
325
+ if (has(name)) set({ [count]: (f[name] as Track[]).length, [tracks]: (f[name] as Track[]).map(trackFor) });
326
+ }
327
+ for (const name of ['layoutWrap', 'counterAxisAlignContent', 'layoutPositioning']) if (has(name)) writePlain(name);
328
+ if (has('grid')) {
329
+ const cell = f.grid as GridPlacement;
330
+ set({ gridRow: cell.row, gridColumn: cell.column, gridRowSpan: cell.rowSpan ?? 1, gridColumnSpan: cell.columnSpan ?? 1 });
331
+ }
332
+ for (const name of ['gridChildHorizontalAlign', 'gridChildVerticalAlign']) if (has(name)) writePlain(name);
333
+
334
+ if (layer.kind === 'TEXT') {
335
+ if (has('textStyle')) applyStyle(batch, ref, build.resolved.textStyle((value('textStyle') as { path: string }).path).id);
336
+ if (has('characters')) {
337
+ const v = value('characters');
338
+ if (v.kind === 'token') bind('characters', build.resolved.variable(v.path).id);
339
+ else set({ characters: v.kind === 'property' ? String(build.defaults.get(v.name)) : String(plainOf(v)) });
340
+ wire('characters', 'characters');
341
+ }
342
+ for (const name of TEXT) {
343
+ if (!has(name)) continue;
344
+ const v = value(name);
345
+ if (v.kind === 'token') {
346
+ const written = build.resolved.textValue(name, v.path);
347
+ if (written) set({ [name]: lengthFor(written.value) });
348
+ else bind(name, build.resolved.variable(v.path).id);
349
+ } else set({ [name]: plainOf(v) });
350
+ }
351
+ }
352
+
353
+ for (const name of ['width', 'height']) if (has(name) && value(name).kind !== 'token') writeDimension(name);
354
+ // An instance carries its main's bound size, and FigCraft keeps a bound size fixed whatever sizing is set beside it, so
355
+ // an instance that hugs or fills lets that binding go first; the main keeps it (measured 25 October 2026, 0.10.0).
356
+ for (const [name, size] of [
357
+ ['layoutSizingHorizontal', 'width'],
358
+ ['layoutSizingVertical', 'height'],
359
+ ] as const) {
360
+ if (layer.kind === 'INSTANCE' && has(name) && !(size in f) && ['FILL', 'HUG'].includes(plainOf(value(name)) as string))
361
+ bind(size, null);
362
+ }
363
+ for (const name of ['layoutSizingHorizontal', 'layoutSizingVertical']) if (has(name)) writePlain(name);
364
+ for (const name of ['minWidth', 'maxWidth', 'minHeight', 'maxHeight']) if (has(name)) writeDimension(name);
365
+ for (const name of ['width', 'height']) if (has(name) && value(name).kind === 'token') writeDimension(name);
366
+ if (has('rotation')) writePlain('rotation');
367
+ for (const name of ['primaryAxisAlignItems', 'counterAxisAlignItems']) if (has(name)) writePlain(name);
368
+
369
+ for (const name of [
370
+ 'itemSpacing',
371
+ 'counterAxisSpacing',
372
+ 'gridRowGap',
373
+ 'gridColumnGap',
374
+ 'paddingTop',
375
+ 'paddingRight',
376
+ 'paddingBottom',
377
+ 'paddingLeft',
378
+ 'opacity',
379
+ 'x',
380
+ 'y',
381
+ 'cornerSmoothing',
382
+ ])
383
+ if (has(name)) writeDimension(name);
384
+ // A corner the file names beside `cornerRadius` overrides its corner. FigCraft keeps the four as one field: a value
385
+ // written to it lets every corner's variable go, and a corner bound beside a bound `cornerRadius` does nothing
386
+ // (measured 23 October 2026). So with any corner of its own, the four are written as one list, then each corner that
387
+ // follows a token is bound on its own, and `cornerRadius` never is.
388
+ if (CORNERS.some((name) => name in f)) {
389
+ if (has('cornerRadius') || CORNERS.some(has)) {
390
+ const cornerValue = (name: string): Value | undefined => (name in f ? value(name) : (f.cornerRadius as Value | undefined));
391
+ // A bound corner shows its variable's value once bound; 0 stands in until then.
392
+ set({
393
+ cornerRadius: CORNERS.map((name) => {
394
+ const corner = cornerValue(name);
395
+ return corner === undefined || corner.kind === 'token' ? 0 : Number(plainOf(corner));
396
+ }),
397
+ });
398
+ for (const name of CORNERS) {
399
+ const corner = cornerValue(name);
400
+ if (corner?.kind === 'token') bind(name, build.resolved.variable(corner.path).id);
401
+ }
402
+ }
403
+ } else if (has('cornerRadius')) writeDimension('cornerRadius');
404
+
405
+ // A list is written whole, then its parts bound: written after a binding, a value lets it go.
406
+ for (const list of ['fills', 'strokes'] as const) {
407
+ if (has(list)) {
408
+ const paints = (f[list] as Value[]).map((paint) => paintFor(build, paint));
409
+ set({ [list]: paints.map((paint) => paint.paint) });
410
+ paints.forEach((paint, at) => {
411
+ paint.binds.forEach(([part, variable]) => {
412
+ bind(`${list}/${at}/${part}`, variable);
413
+ });
414
+ });
415
+ }
416
+ }
417
+ if (has('effects')) {
418
+ const effects: Effect[] = [];
419
+ for (const effect of f.effects as Value[]) {
420
+ if (effect.kind === 'token') effects.push(...effectsOf(build.resolved, build.resolved.composite(effect.path)!, withAlias));
421
+ else effects.push(literalBlur(Number(plainOf(effect))));
422
+ }
423
+ set({ effects: effectsValue({ effects }) });
424
+ effects.forEach((effect, at) => {
425
+ for (const [part, alias] of Object.entries((effect as { boundVariables?: Record<string, VariableAlias> }).boundVariables ?? {}))
426
+ bind(`effects/${at}/${part}`, alias.id);
427
+ });
428
+ }
429
+ if (has('layoutGrids')) {
430
+ // A bound field starts at 0 and shows its variable's value once bound.
431
+ const grids = (f.layoutGrids as LayoutGridRecord[]).map((record) => layoutGridOf(build.resolved, record, withAlias, () => 0));
432
+ set({ grids: grids.map(gridFor) });
433
+ grids.forEach((grid, at) => {
434
+ for (const [part, alias] of Object.entries((grid as { boundVariables?: Record<string, VariableAlias> }).boundVariables ?? {}))
435
+ bind(`layoutGrids/${at}/${GRID_AS[part]}`, alias.id);
436
+ });
437
+ }
438
+ // A new frame starts with a fill, and one the file does not declare is not one it wants.
439
+ if (!only && !has('fills') && (container || root)) set({ fills: [] });
440
+ if (has('effectStyle')) applyStyle(batch, ref, build.resolved.effectStyle((value('effectStyle') as { path: string }).path).id);
441
+
442
+ // Per-side strokes take strokeAlign INSIDE, after the weight (Figma's order, kept so both targets draw alike).
443
+ const sides = ['strokeTopWeight', 'strokeRightWeight', 'strokeBottomWeight', 'strokeLeftWeight'];
444
+ const anySide = sides.some(has);
445
+ if (anySide) set({ strokeAlign: 'inside' });
446
+ // FigCraft keeps the four sides as one field: a side written as a value lets every side's variable go, and the bound
447
+ // `strokeWeight`'s too (measured 23 October 2026, 0.10.0). So with any side of its own, as with the corners, the four
448
+ // are written as one list, a side the file does not name taking `strokeWeight` (else none), then each side that follows
449
+ // a token is bound on its own, and `strokeWeight` never is.
450
+ if (sides.some((side) => side in f)) {
451
+ if (has('strokeWeight') || anySide) {
452
+ const sideValue = (side: string): Value | undefined => (side in f ? value(side) : (f.strokeWeight as Value | undefined));
453
+ const bound = (v: Value | undefined): v is Extract<Value, { kind: 'token' }> =>
454
+ v?.kind === 'token' && !!FIELDS.strokeWeight?.token?.bind;
455
+ // A bound side shows its variable's value once bound; 0 stands in until then.
456
+ set(
457
+ Object.fromEntries(
458
+ sides.map((side) => {
459
+ const v = sideValue(side);
460
+ return [side, v === undefined || bound(v) ? 0 : plainOf(v)];
461
+ }),
462
+ ),
463
+ );
464
+ for (const side of sides) {
465
+ const v = sideValue(side);
466
+ if (bound(v)) bind(side, build.resolved.variable(v.path).id);
467
+ }
468
+ }
469
+ } else if (has('strokeWeight')) writeDimension('strokeWeight');
470
+ if (has('strokeAlign') || (anySide && 'strokeAlign' in f)) writePlain('strokeAlign');
471
+ for (const name of ['strokeJoin', 'strokeCap']) if (has(name)) writePlain(name);
472
+ if (has('dashPattern')) {
473
+ const dashes = f.dashPattern as Value[];
474
+ set({
475
+ dashes: dashes[0]?.kind === 'token' ? build.resolved.valueOf(dashes[0].path)!.value : dashes.map((dash) => Number(plainOf(dash))),
476
+ });
477
+ }
478
+ if (has('clipsContent')) writePlain('clipsContent');
479
+ if (has('blendMode')) writePlain('blendMode');
480
+ if (has('constraints')) {
481
+ const { horizontal, vertical } = f.constraints as Constraints;
482
+ set({ constraints: { horizontal: CONSTRAINT.horizontal[horizontal], vertical: CONSTRAINT.vertical[vertical] } });
483
+ }
484
+ if (layer.kind === 'TEXT') {
485
+ for (const name of ['textAutoResize', 'leadingTrim']) if (has(name)) writePlain(name);
486
+ // After truncation, as in Figma (`maxLines` holds only beside it); listed for a text that gives it alone.
487
+ for (const name of ['listSpacing', 'maxLines']) if (has(name)) writeDimension(name);
488
+ }
489
+
490
+ if (has('visible')) {
491
+ const v = value('visible');
492
+ if (v.kind === 'token') bind('visible', build.resolved.variable(v.path).id);
493
+ else set({ visible: v.kind === 'property' ? Boolean(build.defaults.get(v.name)) : plainOf(v) });
494
+ wire('visible', 'visible');
495
+ }
496
+ if (has('component')) wire('mainComponent', 'component');
497
+ if (has('isExposedInstance')) build.exposes.push({ ref, exposed: Boolean(plainOf(value('isExposedInstance'))) });
498
+
499
+ if (has('modes')) {
500
+ for (const [collection, mode] of Object.entries(f.modes as Record<string, string>)) {
501
+ const pin = build.resolved.mode(collection, mode);
502
+ // A library's collection is copied in (linked, read-only) by the command that pins it; FigCraft checks the mode.
503
+ if (pin.library) {
504
+ const params = { library: pin.library.id ?? pin.library.name, collection: pin.collection.name, mode: pin.modeId, ids: [ref] };
505
+ pinnedAt.set(params, pin.where);
506
+ batch.add('library.setVariableMode', params);
507
+ } else batch.add('variable.setMode', { ids: [ref], collection: pin.collection.id, mode: pin.modeId });
508
+ }
509
+ }
510
+ // FigCraft keeps the wiring and the modes but does not report them: what the layer now holds of them, as the file
511
+ // says, is kept beside the stamp whenever one of them is written, a wiring the file took away included.
512
+ if (['visible', 'characters', 'component', 'modes', ...UNREPORTED_FIELDS].some(has))
513
+ set({ pluginData: { [UNREPORTED_KEY]: JSON.stringify(unreportedOf(build, layer)) } });
514
+ }
515
+
516
+ /** A library mode's pin, by its command's params, as the file wrote it: FigCraft's error about the mode names no line. */
517
+ const pinnedAt = new WeakMap<object, string>();
518
+
519
+ /** A failed batch that pinned a library collection's mode says where the file pinned it. */
520
+ function withPins(error: unknown, commands: readonly Command[]): Error {
521
+ const wheres = commands.flatMap((c) => (pinnedAt.has(c.params) ? [pinnedAt.get(c.params)!] : []));
522
+ const message = (error as Error).message;
523
+ return wheres.length === 0
524
+ ? (error as Error)
525
+ : new Error(`${message}; this batch pinned a library collection's mode at ${wheres.join(', ')}`);
526
+ }
527
+
528
+ /** A layer's wiring by field, and its pinned modes by collection id, as the file declares them. */
529
+ function unreportedOf(build: Build, layer: Layer): Unreported {
530
+ const out: Unreported = {};
531
+ for (const [field, name] of [
532
+ ['visible', 'visible'],
533
+ ['characters', 'characters'],
534
+ ['mainComponent', 'component'],
535
+ ] as const) {
536
+ const v = layer.fields[name] as Value | undefined;
537
+ if (v?.kind === 'property') out.references = { ...out.references, [field]: v.name };
538
+ }
539
+ if (layer.fields.modes) {
540
+ out.modes = {};
541
+ for (const [collection, mode] of Object.entries(layer.fields.modes as Record<string, string>)) {
542
+ const pin = build.resolved.mode(collection, mode);
543
+ out.modes[pin.collection.id] = pin.modeId;
544
+ }
545
+ }
546
+ // The fields FigCraft does not report, as Figma would hold them: a bound one is read from its binding instead.
547
+ for (const field of UNREPORTED_FIELDS) {
548
+ const v = layer.fields[field] as Value | undefined;
549
+ if (v === undefined || (v.kind === 'token' && FIELDS[field]?.token?.bind)) continue;
550
+ const held =
551
+ field === 'constraints'
552
+ ? { ...(v as unknown as Constraints) }
553
+ : v.kind === 'token'
554
+ ? build.resolved.valueOf(v.path)?.value
555
+ : (v as { value: unknown }).value;
556
+ out.fields = { ...out.fields, [field]: held };
557
+ }
558
+ return out;
559
+ }
560
+
561
+ /** An instance's properties, by the names its component gives them: a token's variable bound, a swap by its component. */
562
+ function instanceCommands(batch: Batch, build: Build, ref: Ref, layer: Layer, only?: ReadonlySet<string>): void {
563
+ const entries = Object.entries((layer.fields.set as SetField | undefined) ?? {}).filter(([name]) => !only || only.has(`set ${name}`));
564
+ if (layer.kind !== 'INSTANCE' || entries.length === 0) return;
565
+ const properties: Record<string, unknown> = {};
566
+ for (const [name, { value }] of entries) {
567
+ if (typeof value === 'object' && 'token' in value)
568
+ batch.add('variable.bind', { ids: [ref], field: `componentProperties/${name}`, variable: build.resolved.variable(value.token).id });
569
+ else if (typeof value === 'string' && componentNamed(build, value)) properties[name] = Number(componentNamed(build, value));
570
+ else properties[name] = value;
571
+ }
572
+ if (Object.keys(properties).length > 0) batch.add('instance.setProperties', { ids: [ref], properties });
573
+ }
574
+
575
+ /**
576
+ * A style applied to a layer. A library's style is copied into the file only as it is applied (FigCraft has no import
577
+ * alone), so its id, `<library id>:<asset id>`, is applied with `library.applyStyle`, which copies it in once.
578
+ */
579
+ function applyStyle(batch: Batch, ref: Ref, id: string): void {
580
+ const library = splitKey(id);
581
+ if (library) batch.add('library.applyStyle', { library: library.library, style: library.asset, ids: [ref] });
582
+ else batch.add('style.apply', { ids: [ref], style: id });
583
+ }
584
+
585
+ /** A component of the file by its name, for an instance swap's value: its id, or undefined when the text names none. */
586
+ function componentNamed(build: Build, name: string): string | undefined {
587
+ try {
588
+ return build.resolved.component({ name } as ComponentRef).id;
589
+ } catch (_notAComponent) {
590
+ return undefined;
591
+ }
592
+ }
593
+
594
+ /** The component an instance layer shows: as the file names it, or a swap property's default. */
595
+ function componentOf(build: Build, layer: Layer): string {
596
+ const value = layer.fields.component as Value | undefined;
597
+ if (value?.kind === 'plain' && typeof value.value === 'string') return build.resolved.component({ name: value.value }).id;
598
+ if (value?.kind === 'property') {
599
+ const declared = build.spec.properties.find((p) => p.type === 'INSTANCE_SWAP' && p.name === value.name) as
600
+ | (Property & { default: ComponentRef })
601
+ | undefined;
602
+ if (declared) return build.resolved.component(declared.default).id;
603
+ }
604
+ throw new Error(`INSTANCE "${layer.address}" names no component to make an instance of`);
605
+ }
606
+
607
+ /** Makes a layer and what is in it. Answers its ref; `made` collects each layer made with its ref. */
608
+ function createCommands(
609
+ batch: Batch,
610
+ build: Build,
611
+ layer: Layer,
612
+ parent: Ref | undefined,
613
+ made: Array<{ ref: Ref; layer: Layer }>,
614
+ root = false,
615
+ ): Ref {
616
+ const name = root ? undefined : (layer.name ?? layer.address);
617
+ const at = parent !== undefined ? { parent } : {};
618
+ const ref =
619
+ layer.kind === 'TEXT'
620
+ ? batch.add('text.create', { text: '', x: 0, y: 0, ...at })
621
+ : layer.kind === 'INSTANCE'
622
+ ? batch.add('instance.create', { component: Number(componentOf(build, layer)), ...at })
623
+ : layer.kind === 'LINE'
624
+ ? batch.add('node.create', { type: 'line', ...at })
625
+ : batch.add('frame.create', { ...at, ...(root ? {} : { name }) });
626
+ if (name !== undefined) batch.add('node.set', { ids: [ref], props: { name } });
627
+ if (layer.kind === 'INSTANCE') forgetMains(batch, ref);
628
+ made.push({ ref, layer });
629
+ if (layer.kind === 'SLOT') build.slots.push({ ref, name: layer.address });
630
+ // An instance's properties before its fields: a variant switch may resize it, and the size the file states comes after.
631
+ instanceCommands(batch, build, ref, layer);
632
+ fieldCommands(batch, build, ref, layer, root);
633
+ if (layer.kind === 'FRAME' || layer.kind === 'SLOT') for (const child of layer.children) createCommands(batch, build, child, ref, made);
634
+ // What fills a new instance's slot goes in the instance's slot layer, which has an id only once the instance is made:
635
+ // it is made by a second batch (`createArtboard`), any structure command taking the slot layer as its parent (measured).
636
+ if (layer.kind === 'INSTANCE')
637
+ for (const child of layer.children) if (child.slot !== undefined) build.later.push({ instance: ref, layer: child });
638
+ return ref;
639
+ }
640
+
641
+ /**
642
+ * An instance is made with a copy of its main's layer data (measured), Toghen's stamp and record among it: the run
643
+ * empties both on an instance it makes, so what the instance holds is only ever its own.
644
+ */
645
+ const forgetMains = (batch: Batch, ref: Ref): void =>
646
+ void batch.add('node.set', { ids: [ref], props: { pluginData: { 'toghen.stamp': '', [UNREPORTED_KEY]: '' } } });
647
+
648
+ /** A member: its root frame, named for its combination, its layers, then made a component. */
649
+ function memberCommands(batch: Batch, build: Build, member: Member, made: Array<{ ref: Ref; layer: Layer; is?: Identity }>): Ref {
650
+ const ref = batch.add('frame.create', { name: member.name });
651
+ made.push({ ref, layer: member.tree, is: { member: member.name } });
652
+ fieldCommands(batch, build, ref, member.tree, true);
653
+ for (const child of member.tree.children) createCommands(batch, build, child, ref, made);
654
+ batch.add('component.create', { ids: [ref] });
655
+ slotsMade(batch, build);
656
+ return ref;
657
+ }
658
+
659
+ /** The ids of the components and sets a property offers first, as FigCraft takes them: each by name, resolved (one by key is refused). */
660
+ const preferredIds = (resolved: Resolved, property: Property): number[] =>
661
+ preferredOf(property).map((value) => Number(resolved.preferred(value)!.id));
662
+
663
+ /** The frames made into slots, each with its property, by the slot's name: FigCraft makes a slot only inside a main. */
664
+ const slotsMade = (batch: Batch, build: Build): void => {
665
+ for (const { ref, name } of build.slots.splice(0)) {
666
+ const property = build.spec.properties.find((p) => p.type === 'SLOT' && p.name === name);
667
+ const preferred = property ? preferredIds(build.resolved, property) : [];
668
+ batch.add('component.makeSlot', { id: ref, name, ...(preferred.length > 0 ? { preferred } : {}) });
669
+ }
670
+ };
671
+
672
+ const PROPERTY_TYPE: Record<string, string> = { TEXT: 'text', BOOLEAN: 'boolean', INSTANCE_SWAP: 'instanceSwap' };
673
+
674
+ function propertyCommands(batch: Batch, build: Build, set: Ref, property: Property): void {
675
+ if (!(property.type in PROPERTY_TYPE)) return;
676
+ const given =
677
+ property.type === 'INSTANCE_SWAP'
678
+ ? Number(build.resolved.component(property.default).id)
679
+ : (property as { default: string | boolean }).default;
680
+ const preferred = preferredIds(build.resolved, property);
681
+ batch.add('component.addProperty', {
682
+ id: set,
683
+ name: property.name,
684
+ type: PROPERTY_TYPE[property.type],
685
+ default: given,
686
+ ...(preferred.length > 0 ? { preferred } : {}),
687
+ });
688
+ }
689
+
690
+ const wireCommands = (batch: Batch, build: Build): void => {
691
+ slotsMade(batch, build);
692
+ for (const { ref, field, property } of build.wires.splice(0)) batch.add('component.bindProperty', { layer: ref, property, field });
693
+ for (const { ref, exposed } of build.exposes.splice(0)) batch.add('component.exposeInstance', { id: ref, exposed });
694
+ };
695
+
696
+ /** A slot property's description and settings, which FigCraft's slots do not hold, kept on the set beside its stamp. */
697
+ function slotRecord(batch: Batch, set: Ref, spec: SetSpec): void {
698
+ const slots: Unreported['slots'] = {};
699
+ for (const property of spec.properties) {
700
+ if (property.type !== 'SLOT') continue;
701
+ slots[property.name] = {
702
+ ...(property.description !== undefined ? { description: property.description } : {}),
703
+ ...(property.slotSettings ? { slotSettings: property.slotSettings as Record<string, number | boolean> } : {}),
704
+ };
705
+ }
706
+ if (Object.keys(slots).length > 0)
707
+ batch.add('node.set', { ids: [set], props: { pluginData: { [UNREPORTED_KEY]: JSON.stringify({ slots }) } } });
708
+ }
709
+
710
+ function buildOf(resolved: Resolved, spec: SetSpec): Build {
711
+ const defaults = new Map<string, string | boolean>();
712
+ for (const property of spec.properties)
713
+ if (property.type === 'BOOLEAN' || property.type === 'TEXT') defaults.set(property.name, property.default as string | boolean);
714
+ return { resolved, spec, defaults, wires: [], slots: [], exposes: [], later: [] };
715
+ }
716
+
717
+ // ---------------------------------------------------------------------------
718
+ // the steps
719
+ // ---------------------------------------------------------------------------
720
+
721
+ /** A layer this run wrote, by its FigCraft id: its receipt is taken once the run's writes are in. */
722
+ interface Wrote {
723
+ id: number;
724
+ layer: Layer;
725
+ fields?: string[];
726
+ /** A layer this run made: stamped with what it is first. */
727
+ is?: Identity;
728
+ }
729
+
730
+ /** Stamps what the run made and takes every receipt, from the file as FigCraft now reports it, and sends them. */
731
+ async function stampAll(target: Target, scene: Scene, wrote: Wrote[], label: string): Promise<void> {
732
+ const written: Written[] = [];
733
+ for (const { id, layer, fields, is } of wrote) {
734
+ const node = scene.byId(String(id));
735
+ if (!node) continue;
736
+ if (is) updateStamp(node, { is: { ...is, address: layer.address }, receipt: {} });
737
+ written.push({ node, layer, ...(fields ? { fields } : {}) });
738
+ }
739
+ await stampReceipts(written);
740
+ await target.flush(label);
741
+ }
742
+
743
+ const pageSelect = (batch: Batch, page: { id: string }): void => void batch.add('page.select', { id: Number(page.id) });
744
+
745
+ /**
746
+ * What one field's write takes with it in FigCraft beyond Figma's resets: a value written to a corner or a side lets
747
+ * the variables of all four go, so the corners (and `cornerRadius`) or the sides (and `strokeWeight`) the file declares
748
+ * are written again.
749
+ */
750
+ function figcraftResets(layer: Layer, fields: Set<string>): Set<string> {
751
+ for (const group of [
752
+ ['cornerRadius', ...CORNERS],
753
+ ['strokeWeight', 'strokeTopWeight', 'strokeRightWeight', 'strokeBottomWeight', 'strokeLeftWeight'],
754
+ ]) {
755
+ if (group.some((name) => fields.has(name))) for (const name of group) if (name in layer.fields) fields.add(name);
756
+ }
757
+ return fields;
758
+ }
759
+
760
+ export function figcraftWriter(to: Target) {
761
+ const target: Target = {
762
+ ...to,
763
+ batch: async (label, commands) => {
764
+ try {
765
+ return await to.batch(label, commands);
766
+ } catch (error) {
767
+ throw withPins(error, commands);
768
+ }
769
+ },
770
+ };
771
+ /** A component set that does not exist yet: every member, combined, named, given its properties and wired, in one batch. */
772
+ async function createSet(step: Extract<Step, { kind: 'CREATE_SET' }>, resolved: Resolved): Promise<Applied> {
773
+ const build = buildOf(resolved, step.component);
774
+ const batch = new Batch();
775
+ pageSelect(batch, resolved.page);
776
+ const made: Array<{ ref: Ref; layer: Layer; is?: Identity }> = [];
777
+ const members = step.members.map((member) => memberCommands(batch, build, member, made));
778
+ const set = batch.add('component.combineVariants', { ids: members });
779
+ // Combining names a member without `=` "Property 1=Default" (measured), so each takes its name again.
780
+ step.members.forEach((member, at) => {
781
+ batch.add('node.set', { ids: [members[at]], props: { name: member.name } });
782
+ });
783
+ batch.add('node.set', {
784
+ ids: [set],
785
+ props: {
786
+ name: step.component.name,
787
+ ...(step.component.description !== undefined ? { description: step.component.description } : {}),
788
+ },
789
+ });
790
+ for (const property of step.component.properties) propertyCommands(batch, build, set, property);
791
+ wireCommands(batch, build);
792
+ slotRecord(batch, set, step.component);
793
+ const results = await target.batch(`Build ${step.component.name}`, batch.commands);
794
+ const setId = idOf(results, set);
795
+
796
+ const scene = await target.scene();
797
+ const setNode = scene.byId(String(setId)) as unknown as ComponentSetNode;
798
+ updateStamp(setNode, { is: { component: step.component.name } });
799
+ placeMembers(setNode, step.members, step.component);
800
+ stampSet(setNode, step.component);
801
+ await stampAll(
802
+ target,
803
+ scene,
804
+ made.map(({ ref, layer, is }) => ({ id: idOf(results, ref), layer, is: is ?? {} })),
805
+ 'Toghen stamps',
806
+ );
807
+ return { setId: String(setId), created: step.members.map((member) => member.name), warnings: [] };
808
+ }
809
+
810
+ /** An artboard that does not exist yet: its frame and its layers, in one batch. */
811
+ async function createArtboard(step: Extract<Step, { kind: 'CREATE_ARTBOARD' }>, resolved: Resolved): Promise<AppliedArtboard> {
812
+ const build = buildOf(resolved, { name: step.member.name, properties: [] });
813
+ const batch = new Batch();
814
+ pageSelect(batch, resolved.page);
815
+ const made: Array<{ ref: Ref; layer: Layer; is?: Identity }> = [];
816
+ const frame = batch.add('frame.create', { name: step.member.tree.name ?? step.member.tree.address });
817
+ made.push({ ref: frame, layer: step.member.tree, is: { artboard: step.member.name } });
818
+ fieldCommands(batch, build, frame, step.member.tree, true);
819
+ for (const child of step.member.tree.children) createCommands(batch, build, child, frame, made);
820
+ const results = await target.batch(`Build ${step.member.name}`, batch.commands);
821
+ const wrote = made.map(({ ref, layer, is }) => ({ id: idOf(results, ref), layer, is: is ?? {} }));
822
+ // Then what fills the new instances' slots, in their slot layers, now that those have ids.
823
+ const filled: Array<{ ref: Ref; layer: Layer }> = [];
824
+ if (build.later.length > 0) {
825
+ const scene = await target.scene();
826
+ const fill = new Batch();
827
+ pageSelect(fill, resolved.page);
828
+ for (const { instance, layer } of build.later.splice(0)) {
829
+ const slot = slotNodesOf(scene.byId(String(idOf(results, instance)))!).find((candidate) => candidate.name === layer.slot);
830
+ if (!slot) throw new Error(`"${step.member.name}": the instance has no slot "${layer.slot}" for "${layer.address}"`);
831
+ createCommands(fill, build, layer, Number(slot.id), filled);
832
+ }
833
+ const placed = await target.batch(`Fill the slots of ${step.member.name}`, fill.commands);
834
+ wrote.push(...filled.map(({ ref, layer }) => ({ id: idOf(placed, ref), layer, is: {} })));
835
+ }
836
+ await stampAll(target, await target.scene(), wrote, 'Toghen stamps');
837
+ return { frameId: String(idOf(results, frame)), created: [...made, ...filled].map(({ layer }) => layer.address), warnings: [] };
838
+ }
839
+
840
+ /**
841
+ * The steps on a set or an artboard that exists, one batch each, in the plan's order. `artboard` is the frame of an
842
+ * artboard; a set's members are found by their ids.
843
+ */
844
+ async function updateSteps(rootId: string, steps: Step[], desired: Desired, resolved: Resolved, artboard: boolean): Promise<Updated> {
845
+ const result: Updated = { created: [], updated: [], deleted: [], warnings: [] };
846
+ const spec = desired.component;
847
+ const build = buildOf(resolved, spec);
848
+ const wrote: Wrote[] = [];
849
+ let scene = await target.scene();
850
+ const root = scene.byId(rootId);
851
+ if (!root) throw new Error(`${artboard ? 'the artboard' : 'the component set'} ${rootId} is no longer in the file`);
852
+ const page = (() => {
853
+ let up: BaseNode | null = root;
854
+ while (up && up.type !== 'PAGE') up = up.parent;
855
+ return up as PageNode;
856
+ })();
857
+ // Each of Toghen's layers by address, per member (one for an artboard), as the snapshot read them.
858
+ const indexes = new Map<string, Map<string, number>>();
859
+ const layersOf = async (memberId: string): Promise<Map<string, number>> => {
860
+ let layers = indexes.get(memberId);
861
+ if (!layers) {
862
+ layers = new Map();
863
+ for (const { node, address, copyOf } of await stampedTree(scene.byId(memberId) as unknown as ComponentNode, artboard)) {
864
+ if (address === undefined || copyOf) continue;
865
+ layers.set(address, Number(node.id));
866
+ // In an artboard, a layer that fills a slot names the instance's slot as its parent.
867
+ if (artboard && node.type === 'INSTANCE')
868
+ for (const slot of slotNodesOf(node)) layers.set(slotKey(address, slot.name), Number(slot.id));
869
+ }
870
+ indexes.set(memberId, layers);
871
+ }
872
+ return layers;
873
+ };
874
+ const layerAt = (member: string, address: string): Layer | undefined => {
875
+ const find = (layer: Layer): Layer | undefined => (layer.address === address ? layer : layer.children.map(find).find(Boolean));
876
+ const found = desired.members.find((candidate) => candidate.name === member);
877
+ return found && find(found.tree);
878
+ };
879
+ // Each batch shows the page first, where a new node is made; a step's refs count it.
880
+ const send = async (label: string, batch: Batch): Promise<unknown[]> =>
881
+ batch.commands.length === 1 ? [] : target.batch(label, batch.commands);
882
+ const stripAll = (ids: readonly string[] = []): void => {
883
+ for (const id of ids) {
884
+ const copy = scene.byId(id);
885
+ if (copy) strip(copy);
886
+ }
887
+ };
888
+ /** Where a layer goes among its parent's children: after the layer before it in the file, or first. */
889
+ const indexFor = (parent: number, after: number | undefined, named: boolean): number => {
890
+ const children = ((scene.byId(String(parent)) as unknown as { children?: SceneNode[] })?.children ?? []).map((child) =>
891
+ Number(child.id),
892
+ );
893
+ const at = after !== undefined ? children.indexOf(after) : -1;
894
+ return at >= 0 ? at + 1 : named ? children.length : 0;
895
+ };
896
+
897
+ let createdMembers = false;
898
+ for (const step of steps) {
899
+ const batch = new Batch();
900
+ pageSelect(batch, page);
901
+ switch (step.kind) {
902
+ case 'ADD_PROPERTY':
903
+ propertyCommands(batch, build, Number(rootId), step.property);
904
+ await send(`Add property ${step.property.name}`, batch);
905
+ break;
906
+ case 'EDIT_PROPERTY': {
907
+ const property = step.property;
908
+ const edit: Record<string, unknown> = {};
909
+ if (step.changes.includes('defaultValue') && property.type in PROPERTY_TYPE) {
910
+ edit.default =
911
+ property.type === 'INSTANCE_SWAP'
912
+ ? Number(resolved.component(property.default).id)
913
+ : (property as { default: string | boolean }).default;
914
+ }
915
+ if (step.changes.includes('preferredValues')) edit.preferred = preferredIds(resolved, property);
916
+ if (Object.keys(edit).length > 0) batch.add('component.editProperty', { id: Number(rootId), name: property.name, ...edit });
917
+ if (property.type === 'SLOT' && (step.changes.includes('description') || step.changes.includes('slotSettings')))
918
+ slotRecord(batch, Number(rootId), spec);
919
+ await send(`Edit property ${property.name}`, batch);
920
+ break;
921
+ }
922
+ case 'DELETE_PROPERTY':
923
+ batch.add('component.deleteProperty', { id: Number(rootId), name: step.name });
924
+ await send(`Delete property ${step.name}`, batch);
925
+ break;
926
+ case 'RENAME_AXIS': {
927
+ batch.add('component.editProperty', { id: Number(rootId), name: step.from, newName: step.to });
928
+ await send(`Rename axis ${step.from}`, batch);
929
+ for (const child of (root as unknown as ComponentSetNode).children) {
930
+ const stamped = child.type === 'COMPONENT' ? stampOn(child)?.is.member : undefined;
931
+ const renamed = stamped === undefined ? undefined : renameAxis(stamped, step.from, step.to);
932
+ if (renamed !== undefined) updateStamp(child, { is: { member: renamed } });
933
+ }
934
+ break;
935
+ }
936
+ case 'CREATE_MEMBER': {
937
+ // Every new member at the first of these steps, appended together, then wired: the set's properties are its own.
938
+ if (createdMembers) break;
939
+ createdMembers = true;
940
+ const made: Array<{ ref: Ref; layer: Layer; is?: Identity }> = [];
941
+ // A member that joins a set in FigCraft 0.10.0 gets none of its properties, and a layer of it cannot be wired
942
+ // then; one that holds them already keeps them (measured 23 October 2026). So each new member is given the
943
+ // set's properties and wired while it stands alone, then joins after the members there, away from the set's
944
+ // top-left corner, whose member FigCraft takes for the set's default.
945
+ const refs = steps
946
+ .filter((s): s is Extract<Step, { kind: 'CREATE_MEMBER' }> => s.kind === 'CREATE_MEMBER')
947
+ .map(({ member }) => {
948
+ const ref = memberCommands(batch, build, member, made);
949
+ for (const property of spec.properties) propertyCommands(batch, build, ref, property);
950
+ return ref;
951
+ });
952
+ wireCommands(batch, build);
953
+ batch.add('node.set', { ids: refs, props: { x: AWAY, y: AWAY } });
954
+ batch.add('node.reparent', {
955
+ ids: refs,
956
+ parent: Number(rootId),
957
+ index: ((root as unknown as { children?: unknown[] }).children ?? []).length,
958
+ });
959
+ const results = await send(`Add members to ${spec.name}`, batch);
960
+ wrote.push(...made.map(({ ref, layer, is }) => ({ id: idOf(results, ref), layer, is: is ?? {} })));
961
+ for (const { is } of made) if (is?.member !== undefined) result.created.push(is.member);
962
+ break;
963
+ }
964
+ case 'DELETE_MEMBER':
965
+ batch.add('edit.delete', { ids: [Number(step.memberId)] });
966
+ await send(`Delete ${step.name}`, batch);
967
+ stripAll(step.strip);
968
+ result.deleted.push(step.name);
969
+ break;
970
+ case 'CREATE_LAYER':
971
+ case 'REPLACE_LAYER': {
972
+ const layers = await layersOf(step.memberId);
973
+ const parent = layers.get(step.parent);
974
+ if (parent === undefined)
975
+ throw new Error(`"${step.member}": the parent "${step.parent}" of "${step.layer.address}" does not exist`);
976
+ const made: Array<{ ref: Ref; layer: Layer }> = [];
977
+ const ref = createCommands(batch, build, step.layer, parent, made);
978
+ if (step.layer.kind === 'SLOT' && !artboard) slotRecord(batch, Number(rootId), spec);
979
+ batch.add('node.reorder', {
980
+ id: ref,
981
+ index: indexFor(parent, step.after !== undefined ? layers.get(step.after) : undefined, step.after !== undefined),
982
+ });
983
+ if (step.kind === 'REPLACE_LAYER') batch.add('edit.delete', { ids: [Number(step.nodeId)] });
984
+ wireCommands(batch, build);
985
+ const results = await send(`${step.kind === 'CREATE_LAYER' ? 'Add' : 'Replace'} ${step.layer.address}`, batch);
986
+ if (step.kind === 'REPLACE_LAYER') stripAll(step.strip);
987
+ for (const { ref: made_, layer } of made) {
988
+ wrote.push({ id: idOf(results, made_), layer, is: {} });
989
+ layers.set(layer.address, idOf(results, made_));
990
+ }
991
+ if (artboard) result.created.push(step.layer.address);
992
+ else if (!result.updated.includes(step.member)) result.updated.push(step.member);
993
+ break;
994
+ }
995
+ case 'MOVE_LAYER': {
996
+ const layers = await layersOf(step.memberId);
997
+ const parent = layers.get(step.parent);
998
+ if (parent === undefined) throw new Error(`"${step.member}": cannot move "${step.address}"`);
999
+ batch.add('node.reparent', {
1000
+ ids: [Number(step.nodeId)],
1001
+ parent,
1002
+ index: indexFor(parent, step.after !== undefined ? layers.get(step.after) : undefined, step.after !== undefined),
1003
+ });
1004
+ await send(`Move ${step.address}`, batch);
1005
+ if (!artboard && !result.updated.includes(step.member)) result.updated.push(step.member);
1006
+ break;
1007
+ }
1008
+ case 'PATCH_LAYER':
1009
+ case 'SWAP_COMPONENT': {
1010
+ const layer = step.kind === 'PATCH_LAYER' ? step.layer : layerAt(step.member, step.address);
1011
+ if (!layer)
1012
+ throw new Error(
1013
+ `"${step.member}": the layer "${step.kind === 'PATCH_LAYER' ? step.layer.address : step.address}" is no longer in the file`,
1014
+ );
1015
+ const isRoot = step.nodeId === step.memberId;
1016
+ const only = step.kind === 'PATCH_LAYER' ? figcraftResets(layer, withResets(layer, step.fields)) : new Set(['component']);
1017
+ const id = Number(step.nodeId);
1018
+ if (step.kind === 'SWAP_COMPONENT') batch.add('instance.swap', { ids: [id], component: Number(componentOf(build, layer)) });
1019
+ // Its properties, then its fields: the sizing a variant switch may undo is among them (`withResets`).
1020
+ instanceCommands(batch, build, id, layer, only);
1021
+ fieldCommands(batch, build, id, layer, isRoot, only);
1022
+ // A layer a person renamed is found by its stamp, and gets the file's name back.
1023
+ batch.add('node.set', { ids: [id], props: { name: isRoot && !artboard ? step.member : (layer.name ?? layer.address) } });
1024
+ wireCommands(batch, build);
1025
+ await send(`${step.kind === 'PATCH_LAYER' ? 'Update' : 'Swap'} ${layer.address}`, batch);
1026
+ wrote.push({ id, layer, fields: step.kind === 'PATCH_LAYER' ? [...only, ...(step.restamp ?? [])] : ['component'] });
1027
+ const name = artboard ? layer.address : step.member;
1028
+ if (!result.updated.includes(name)) result.updated.push(name);
1029
+ break;
1030
+ }
1031
+ case 'DELETE_LAYER':
1032
+ batch.add('edit.delete', { ids: [Number(step.nodeId)] });
1033
+ await send(`Delete ${step.address}`, batch);
1034
+ stripAll(step.strip);
1035
+ if (artboard) result.deleted.push(step.address);
1036
+ else if (!result.updated.includes(step.member)) result.updated.push(step.member);
1037
+ break;
1038
+ default:
1039
+ break;
1040
+ }
1041
+ }
1042
+
1043
+ // Last: the layout, the set's receipt and every layer's, from what FigCraft reports once every write is in.
1044
+ await target.flush('Toghen stamps');
1045
+ scene = await target.scene();
1046
+ const now = scene.byId(rootId);
1047
+ if (!artboard && now) {
1048
+ const set = now as unknown as ComponentSetNode;
1049
+ if (steps.some((step) => step.kind === 'RELAYOUT'))
1050
+ placeMembers(set, desired.members as Member[], spec, new Set((await membersOf(set)).copies.map(({ copy }) => copy)));
1051
+ stampSet(set, spec);
1052
+ }
1053
+ await stampAll(target, scene, wrote, 'Toghen stamps');
1054
+ return result;
1055
+ }
1056
+
1057
+ return {
1058
+ createSet,
1059
+ createArtboard,
1060
+ update: (setId: string, steps: Step[], desired: Desired, resolved: Resolved) => updateSteps(setId, steps, desired, resolved, false),
1061
+ updateArtboard: (frameId: string, steps: Step[], desired: Desired, resolved: Resolved) =>
1062
+ updateSteps(frameId, steps, desired, resolved, true),
1063
+ refuses,
1064
+ };
1065
+ }
1066
+
1067
+ export type FigcraftWriter = ReturnType<typeof figcraftWriter>;