figrelay 0.0.0-stage → 0.8.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.
@@ -0,0 +1,1356 @@
1
+ // Figma Relay — plugin main thread.
2
+ // Receives {id, command, params} from the Go bridge (relayed through ui.html),
3
+ // executes it against the Figma Plugin API, and replies {id, result|error}.
4
+
5
+ figma.showUI(__html__, { width: 280, height: 104 });
6
+
7
+ // Tell the UI which file this plugin window runs in; the UI forwards it to
8
+ // the bridge on every (re)connect so calls can be routed per file when
9
+ // several files run the plugin at once. figma.fileKey is undefined for
10
+ // non-published dev plugins, so the file name is the primary identifier.
11
+ figma.ui.postMessage({
12
+ register: { key: figma.fileKey || undefined, name: figma.root.name },
13
+ });
14
+
15
+ // ---------- helpers ----------
16
+
17
+ function hexToRGB(hex) {
18
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex);
19
+ if (!m) throw new Error("invalid hex color: " + hex + " (expected #RRGGBB)");
20
+ const v = parseInt(m[1], 16);
21
+ return { r: ((v >> 16) & 255) / 255, g: ((v >> 8) & 255) / 255, b: (v & 255) / 255 };
22
+ }
23
+
24
+ async function node(id) {
25
+ const n = await figma.getNodeByIdAsync(id);
26
+ if (!n) throw new Error("node not found: " + id);
27
+ return n;
28
+ }
29
+
30
+ // Pages are addressed by id or name. With documentAccess "dynamic-page" a
31
+ // page's children are unreachable until it is loaded, so load it here.
32
+ async function resolvePage(ref) {
33
+ const want = String(ref);
34
+ const lower = want.toLowerCase();
35
+ const pages = figma.root.children;
36
+ const p =
37
+ pages.find((x) => x.id === want) ||
38
+ pages.find((x) => x.name.toLowerCase() === lower) ||
39
+ pages.find((x) => x.name.toLowerCase().indexOf(lower) !== -1);
40
+ if (!p) throw new Error("page not found: " + ref + " (see list_pages)");
41
+ await p.loadAsync();
42
+ return p;
43
+ }
44
+
45
+ // Figma stacks newly combined variants on top of each other and never grows
46
+ // the set to fit them, so the extras are invisible and clipped. Lay them out.
47
+ function arrangeVariants(set) {
48
+ if (set.layoutMode && set.layoutMode !== "NONE") return;
49
+ const gap = 32;
50
+ let y = 0;
51
+ let w = 0;
52
+ for (const v of set.children) {
53
+ v.x = 0;
54
+ v.y = y;
55
+ y += v.height + gap;
56
+ w = Math.max(w, v.width);
57
+ }
58
+ if (y > 0) set.resize(Math.max(w, 1), Math.max(y - gap, 1));
59
+ }
60
+
61
+ // Mutating commands accept one node_id or many node_ids so a bulk edit costs
62
+ // one call instead of one per node.
63
+ async function targets(params) {
64
+ const ids = params.node_ids && params.node_ids.length
65
+ ? params.node_ids
66
+ : params.node_id ? [params.node_id] : [];
67
+ if (!ids.length) throw new Error("node_id or node_ids is required");
68
+ const out = [];
69
+ for (const id of ids) out.push(await node(id));
70
+ return out;
71
+ }
72
+
73
+ // Result of a mutating command. Echoing a full summary per node drowns the
74
+ // caller's context on a 50-node batch, so default to ids and let the caller
75
+ // ask for the rest with verbose.
76
+ function done(params, nodes, extra) {
77
+ const list = Array.isArray(nodes) ? nodes : [nodes];
78
+ const out = params.verbose
79
+ ? { nodes: list.map(summarize) }
80
+ : { ok: true, ids: list.map((n) => n.id) };
81
+ return Object.assign(out, extra || {});
82
+ }
83
+
84
+ // Mixed values (figma.mixed) are Symbols, which postMessage cannot clone.
85
+ function val(x) {
86
+ return x === figma.mixed ? "mixed" : x;
87
+ }
88
+
89
+ function rgbToHex(c) {
90
+ const h = (v) => ("0" + Math.round(v * 255).toString(16)).slice(-2);
91
+ return "#" + h(c.r) + h(c.g) + h(c.b);
92
+ }
93
+
94
+ // A full PaintObject is ~20 lines of JSON (17-digit float channels, complete
95
+ // gradientTransform); compact callers only ever need the color.
96
+ function paintBrief(p) {
97
+ if (p.visible === false) return "hidden";
98
+ if (p.type !== "SOLID") return p.type;
99
+ const hex = rgbToHex(p.color);
100
+ return p.opacity === undefined || p.opacity === 1 ? hex : hex + "@" + p.opacity;
101
+ }
102
+
103
+ // First visible solid fill as hex, for find_nodes' fill filter.
104
+ function solidFillHex(n) {
105
+ if (!("fills" in n) || !Array.isArray(n.fills)) return null;
106
+ for (const p of n.fills) {
107
+ if (p.type === "SOLID" && p.visible !== false) return rgbToHex(p.color);
108
+ }
109
+ return null;
110
+ }
111
+
112
+ function summarize(n) {
113
+ const s = { id: n.id, name: n.name, type: n.type };
114
+ if (n.parent) s.parentId = n.parent.id;
115
+ if ("x" in n) { s.x = n.x; s.y = n.y; }
116
+ if ("width" in n) { s.width = n.width; s.height = n.height; }
117
+ if (n.type === "TEXT") s.text = n.characters;
118
+ return s;
119
+ }
120
+
121
+ // The file key needed to build share links. Published plugins can read it from
122
+ // figma.fileKey, but development plugins (imported from a manifest) cannot, so
123
+ // we fall back to a key the user pastes once — their file URL — persisted in
124
+ // the file via pluginData so it survives reopening.
125
+ const FILE_KEY_DATA = "figma-mcp-file-key";
126
+ let fileKey = figma.fileKey || "";
127
+ try {
128
+ if (!fileKey) fileKey = figma.root.getPluginData(FILE_KEY_DATA) || "";
129
+ } catch (e) {}
130
+
131
+ function setFileKey(key) {
132
+ fileKey = key || "";
133
+ try {
134
+ figma.root.setPluginData(FILE_KEY_DATA, fileKey);
135
+ } catch (e) {}
136
+ }
137
+
138
+ // nodeLink builds a shareable figma.com URL that jumps straight to a node (the
139
+ // same link as Figma's "Copy link to selection"). Returns null when no file
140
+ // key is known yet, so callers simply omit the link.
141
+ function nodeLink(id) {
142
+ if (!fileKey) return null;
143
+ return (
144
+ "https://www.figma.com/design/" +
145
+ fileKey +
146
+ "/" +
147
+ encodeURIComponent(figma.root.name) +
148
+ "?node-id=" +
149
+ id.replace(/:/g, "-")
150
+ );
151
+ }
152
+
153
+ // compact drops defaults (visible, full opacity, empty fills) and renders
154
+ // paints as hex: depth 1 on a 27-child frame is ~15k tokens otherwise.
155
+ function serialize(n, depth, compact) {
156
+ const s = summarize(n);
157
+ if (compact) {
158
+ for (const k of ["x", "y", "width", "height"]) {
159
+ if (typeof s[k] === "number") s[k] = Math.round(s[k] * 100) / 100;
160
+ }
161
+ }
162
+ if ("visible" in n && (!compact || !n.visible)) s.visible = n.visible;
163
+ if ("opacity" in n && (!compact || n.opacity !== 1)) s.opacity = n.opacity;
164
+ if ("fills" in n && Array.isArray(n.fills) && (!compact || n.fills.length)) {
165
+ s.fills = compact ? n.fills.map(paintBrief) : n.fills;
166
+ }
167
+ if ("strokes" in n && n.strokes.length) {
168
+ s.strokes = compact ? n.strokes.map(paintBrief) : n.strokes;
169
+ if (!compact || val(n.strokeWeight) !== 1) s.strokeWeight = val(n.strokeWeight);
170
+ }
171
+ if ("effects" in n && n.effects.length) {
172
+ s.effects = compact ? n.effects.map((e) => e.type) : n.effects;
173
+ }
174
+ if ("cornerRadius" in n && n.cornerRadius !== 0) s.cornerRadius = val(n.cornerRadius);
175
+ if (n.type === "TEXT") {
176
+ s.fontSize = val(n.fontSize);
177
+ s.fontName = val(n.fontName);
178
+ }
179
+ if ("layoutMode" in n && n.layoutMode !== "NONE") {
180
+ s.layoutMode = n.layoutMode;
181
+ s.itemSpacing = n.itemSpacing;
182
+ s.padding = {
183
+ top: n.paddingTop, right: n.paddingRight,
184
+ bottom: n.paddingBottom, left: n.paddingLeft,
185
+ };
186
+ s.primaryAxisAlignItems = n.primaryAxisAlignItems;
187
+ s.counterAxisAlignItems = n.counterAxisAlignItems;
188
+ }
189
+ if ("children" in n && (!compact || n.children.length)) {
190
+ s.childCount = n.children.length;
191
+ if (depth > 0) s.children = n.children.map((c) => serialize(c, depth - 1, compact));
192
+ }
193
+ return s;
194
+ }
195
+
196
+ async function parentOf(params) {
197
+ if (params.parent_id) {
198
+ const p = await node(params.parent_id);
199
+ if (!("appendChild" in p)) throw new Error("node " + params.parent_id + " cannot have children");
200
+ return p;
201
+ }
202
+ return figma.currentPage;
203
+ }
204
+
205
+ // Builds a GradientPaint; the transform rotates the gradient around the
206
+ // node's center by `angle` degrees (0 = left-to-right).
207
+ function buildGradient(g) {
208
+ const stops = (g.stops || []).map((s) => {
209
+ const c = hexToRGB(s.color);
210
+ return {
211
+ position: s.position,
212
+ color: { r: c.r, g: c.g, b: c.b, a: s.opacity === undefined || s.opacity === null ? 1 : s.opacity },
213
+ };
214
+ });
215
+ if (stops.length < 2) throw new Error("gradient needs at least 2 stops");
216
+ const rad = ((g.angle || 0) * Math.PI) / 180;
217
+ const cos = Math.cos(rad);
218
+ const sin = Math.sin(rad);
219
+ return {
220
+ type: (g.type || "LINEAR").toUpperCase() === "RADIAL" ? "GRADIENT_RADIAL" : "GRADIENT_LINEAR",
221
+ gradientTransform: [
222
+ [cos, sin, 0.5 - 0.5 * cos - 0.5 * sin],
223
+ [-sin, cos, 0.5 + 0.5 * sin - 0.5 * cos],
224
+ ],
225
+ gradientStops: stops,
226
+ };
227
+ }
228
+
229
+ // COLOR variables take {r,g,b,a}; accept hex strings for convenience.
230
+ function variableValue(v, value) {
231
+ if (v.resolvedType === "COLOR" && typeof value === "string") {
232
+ const c = hexToRGB(value);
233
+ return { r: c.r, g: c.g, b: c.b, a: 1 };
234
+ }
235
+ return value;
236
+ }
237
+
238
+ function buildEffects(list) {
239
+ return (list || []).map((e) => {
240
+ const type = e.type;
241
+ if (type === "LAYER_BLUR" || type === "BACKGROUND_BLUR") {
242
+ return { type, radius: e.radius || 0, visible: true };
243
+ }
244
+ if (type !== "DROP_SHADOW" && type !== "INNER_SHADOW") {
245
+ throw new Error("unsupported effect type: " + type);
246
+ }
247
+ const color = hexToRGB(e.color || "#000000");
248
+ color.a = e.color_opacity === undefined ? 0.25 : e.color_opacity;
249
+ return {
250
+ type,
251
+ color,
252
+ offset: { x: e.offset_x || 0, y: e.offset_y || 0 },
253
+ radius: e.radius || 0,
254
+ spread: e.spread || 0,
255
+ visible: true,
256
+ blendMode: "NORMAL",
257
+ };
258
+ });
259
+ }
260
+
261
+ function applyShapeParams(n, params) {
262
+ n.x = params.x;
263
+ n.y = params.y;
264
+ if (params.width > 0 && params.height > 0) n.resize(params.width, params.height);
265
+ if (params.name) n.name = params.name;
266
+ if (params.fill_color && params.fill_color !== "none") {
267
+ n.fills = [{ type: "SOLID", color: hexToRGB(params.fill_color) }];
268
+ }
269
+ }
270
+
271
+ // Tracking the nodes a script creates needs a wrapper around figma, but the
272
+ // host object's properties are non-configurable and non-writable, so a Proxy
273
+ // get trap that returns anything else (even a bound copy) breaks the Proxy
274
+ // invariant and every call throws "proxy: inconsistent get". Scripts get the
275
+ // real figma until there is a wrapper that survives that.
276
+
277
+ // Frames from the script read "at <anonymous> (<input>:6:22)". new Function
278
+ // prepends two header lines and we add the async wrapper, so the caller's
279
+ // line 1 is file line 4. Verified against the sandbox; a different engine
280
+ // would only cost us the hint, since no match means no line is reported.
281
+ function scriptLine(e) {
282
+ const m = /<input>:(\d+):\d+/.exec((e && e.stack) || "");
283
+ if (!m) return 0;
284
+ const n = parseInt(m[1], 10) - 3;
285
+ return n > 0 ? n : 0;
286
+ }
287
+
288
+ function scriptFailure(e) {
289
+ let msg = String((e && e.message) || e);
290
+ // Plan limits read like API errors; say what they actually mean.
291
+ if (/Limited to \d+ mode/i.test(msg)) {
292
+ msg += " (Figma plan limit on modes per collection — split the tokens across separate collections)";
293
+ }
294
+ const line = scriptLine(e);
295
+ if (line) msg += " [script line " + line + "]";
296
+ return msg;
297
+ }
298
+
299
+ // A run_script that times out on the server keeps running here: Figma gives
300
+ // no way to abort it. Keyed by token, a retry joins the original run instead
301
+ // of doing its work a second time.
302
+ const scriptRuns = new Map();
303
+ const SCRIPT_MEMO = 20;
304
+
305
+ function rememberRun(token, promise) {
306
+ scriptRuns.set(token, promise);
307
+ while (scriptRuns.size > SCRIPT_MEMO) scriptRuns.delete(scriptRuns.keys().next().value);
308
+ }
309
+
310
+ // Optional extras find_nodes can attach per node, so callers can filter on
311
+ // style without a get_design_context round-trip per hit.
312
+ const FIELD_GETTERS = {
313
+ fills: (n) => ("fills" in n && Array.isArray(n.fills) ? n.fills.map(paintBrief) : undefined),
314
+ strokes: (n) => ("strokes" in n && Array.isArray(n.strokes) && n.strokes.length ? n.strokes.map(paintBrief) : undefined),
315
+ fontSize: (n) => (n.type === "TEXT" ? val(n.fontSize) : undefined),
316
+ fontName: (n) => (n.type === "TEXT" ? val(n.fontName) : undefined),
317
+ cornerRadius: (n) => ("cornerRadius" in n ? val(n.cornerRadius) : undefined),
318
+ effects: (n) => ("effects" in n && n.effects.length ? n.effects.map((e) => e.type) : undefined),
319
+ opacity: (n) => ("opacity" in n ? n.opacity : undefined),
320
+ characters: (n) => (n.type === "TEXT" ? n.characters : undefined),
321
+ };
322
+
323
+ // The body of run_script, split out so a token can hold onto its promise.
324
+ async function runScriptBody(params) {
325
+ const helpers = {
326
+ node, summarize, serialize, hexToRGB, rgbToHex, paintBrief, solidFillHex,
327
+ // The Plugin API has no createAutoLayout; scripts written against other
328
+ // Figma tooling reach for it and fail on "not a function".
329
+ createAutoLayout(direction, props) {
330
+ const f = figma.createFrame();
331
+ f.layoutMode = String(direction || "VERTICAL").toUpperCase() === "HORIZONTAL" ? "HORIZONTAL" : "VERTICAL";
332
+ f.primaryAxisSizingMode = "AUTO";
333
+ f.counterAxisSizingMode = "AUTO";
334
+ f.fills = [];
335
+ if (props) for (const k in props) f[k] = props[k];
336
+ return f;
337
+ },
338
+ };
339
+
340
+ let fn;
341
+ try {
342
+ fn = new Function("figma", "helpers", '"use strict"; return (async () => {\n' + params.code + "\n})();");
343
+ } catch (e) {
344
+ throw new Error(
345
+ "the Figma plugin sandbox rejected dynamic code (" + String((e && e.message) || e) +
346
+ "); use the atomic tools instead",
347
+ );
348
+ }
349
+
350
+ let result;
351
+ try {
352
+ result = await fn(figma, helpers);
353
+ } catch (e) {
354
+ throw new Error(scriptFailure(e));
355
+ }
356
+ if (result === undefined) return { ok: true };
357
+ // The reply is structured-cloned to the UI; a live node would kill the
358
+ // connection, so force plain data here and fail with a clear message.
359
+ try {
360
+ return JSON.parse(JSON.stringify(result));
361
+ } catch (e) {
362
+ throw new Error("script must return plain data (ids, numbers, strings, arrays), not Figma objects");
363
+ }
364
+ }
365
+
366
+ // ---------- prototyping ----------
367
+
368
+ const TRIGGERS = ["ON_CLICK", "ON_HOVER", "ON_PRESS", "ON_DRAG"];
369
+ const NAVIGATIONS = ["NAVIGATE", "SWAP", "OVERLAY", "BACK", "CLOSE"];
370
+ // Directional transitions carry a direction; the rest only easing/duration.
371
+ const DIRECTIONAL_TRANSITIONS = ["MOVE_IN", "MOVE_OUT", "PUSH", "SLIDE_IN", "SLIDE_OUT"];
372
+ const SIMPLE_TRANSITIONS = ["DISSOLVE", "SMART_ANIMATE", "SCROLL_ANIMATE"];
373
+
374
+ function buildTransition(item) {
375
+ const type = (item.transition || "").toUpperCase();
376
+ if (!type || type === "NONE" || type === "INSTANT") return null;
377
+ const directional = DIRECTIONAL_TRANSITIONS.indexOf(type) !== -1;
378
+ if (!directional && SIMPLE_TRANSITIONS.indexOf(type) === -1) {
379
+ throw new Error(
380
+ "unsupported transition: " + type + " (use " +
381
+ SIMPLE_TRANSITIONS.concat(DIRECTIONAL_TRANSITIONS).join(", ") + ")",
382
+ );
383
+ }
384
+ const t = {
385
+ type,
386
+ easing: { type: (item.easing || "EASE_OUT").toUpperCase() },
387
+ duration: item.duration === undefined || item.duration === null ? 0.3 : item.duration,
388
+ };
389
+ if (directional) {
390
+ t.direction = (item.direction || "LEFT").toUpperCase();
391
+ t.matchLayers = false;
392
+ }
393
+ return t;
394
+ }
395
+
396
+ async function buildReaction(item) {
397
+ const trigger = (item.trigger || "ON_CLICK").toUpperCase();
398
+ if (TRIGGERS.indexOf(trigger) === -1) {
399
+ throw new Error("unsupported trigger: " + trigger + " (use " + TRIGGERS.join(", ") + ")");
400
+ }
401
+ const navigation = (item.navigation || "NAVIGATE").toUpperCase();
402
+ if (NAVIGATIONS.indexOf(navigation) === -1) {
403
+ throw new Error("unsupported navigation: " + navigation + " (use " + NAVIGATIONS.join(", ") + ")");
404
+ }
405
+ const needsDestination = navigation !== "BACK" && navigation !== "CLOSE";
406
+ if (needsDestination && !item.destination_id) {
407
+ throw new Error("destination_id is required for " + navigation + "; only BACK and CLOSE take none");
408
+ }
409
+ // BACK and CLOSE are action types of their own, not navigations of a NODE
410
+ // action; sending them as a navigation fails validation.
411
+ if (!needsDestination) {
412
+ return { trigger: { type: trigger }, actions: [{ type: navigation }] };
413
+ }
414
+ // Resolve the destination here so a stale id fails by name, not as an
415
+ // opaque setReactionsAsync rejection.
416
+ await node(item.destination_id);
417
+ return {
418
+ trigger: { type: trigger },
419
+ actions: [{
420
+ type: "NODE",
421
+ destinationId: item.destination_id,
422
+ navigation,
423
+ transition: buildTransition(item),
424
+ preserveScrollPosition: false,
425
+ }],
426
+ };
427
+ }
428
+
429
+ // The page owning a node; flowStartingPoints lives on the page, which is not
430
+ // necessarily the open one.
431
+ function pageOf(n) {
432
+ while (n && n.type !== "PAGE") n = n.parent;
433
+ if (!n) throw new Error("node is not on a page");
434
+ return n;
435
+ }
436
+
437
+ // ---------- command handlers (names match MCP tool names) ----------
438
+
439
+ const handlers = {
440
+ async get_metadata() {
441
+ return {
442
+ file: figma.root.name,
443
+ currentPage: { id: figma.currentPage.id, name: figma.currentPage.name },
444
+ children: figma.currentPage.children.map(summarize),
445
+ };
446
+ },
447
+
448
+ async list_pages() {
449
+ return {
450
+ currentPage: figma.currentPage.name,
451
+ pages: figma.root.children.map((p) => ({
452
+ id: p.id,
453
+ name: p.name,
454
+ current: p.id === figma.currentPage.id,
455
+ })),
456
+ };
457
+ },
458
+
459
+ async set_current_page(params) {
460
+ const p = await resolvePage(params.page);
461
+ await figma.setCurrentPageAsync(p);
462
+ return { id: p.id, name: p.name, childCount: p.children.length };
463
+ },
464
+
465
+ async get_selection() {
466
+ return figma.currentPage.selection.map((n) => {
467
+ const s = summarize(n);
468
+ const link = nodeLink(n.id);
469
+ if (link) s.link = link;
470
+ return s;
471
+ });
472
+ },
473
+
474
+ async get_design_context(params) {
475
+ const depth = params.depth === undefined ? 2 : params.depth;
476
+ return serialize(await node(params.node_id), depth, !!params.compact);
477
+ },
478
+
479
+ async create_frame(params) {
480
+ const f = figma.createFrame();
481
+ // Figma gives new frames an opaque white fill, which silently covers
482
+ // whatever sits behind a nested frame. Transparent unless asked.
483
+ if (!params.fill_color || params.fill_color === "none") f.fills = [];
484
+ applyShapeParams(f, params);
485
+ (await parentOf(params)).appendChild(f);
486
+ return summarize(f);
487
+ },
488
+
489
+ async create_rectangle(params) {
490
+ const r = figma.createRectangle();
491
+ applyShapeParams(r, params);
492
+ (await parentOf(params)).appendChild(r);
493
+ return summarize(r);
494
+ },
495
+
496
+ async create_ellipse(params) {
497
+ const e = figma.createEllipse();
498
+ applyShapeParams(e, params);
499
+ (await parentOf(params)).appendChild(e);
500
+ return summarize(e);
501
+ },
502
+
503
+ async create_line(params) {
504
+ const l = figma.createLine();
505
+ l.x = params.x;
506
+ l.y = params.y;
507
+ l.resize(params.length > 0 ? params.length : 100, 0);
508
+ l.strokes = [{ type: "SOLID", color: hexToRGB(params.stroke_color || "#000000") }];
509
+ if (params.stroke_weight) l.strokeWeight = params.stroke_weight;
510
+ if (params.rotation !== undefined && params.rotation !== null) l.rotation = params.rotation;
511
+ if (params.name) l.name = params.name;
512
+ (await parentOf(params)).appendChild(l);
513
+ return summarize(l);
514
+ },
515
+
516
+ async import_image(params) {
517
+ const image = figma.createImage(figma.base64Decode(params.data));
518
+ const paint = { type: "IMAGE", imageHash: image.hash, scaleMode: params.scale_mode || "FILL" };
519
+ let n;
520
+ if (params.node_id) {
521
+ n = await node(params.node_id);
522
+ if (!("fills" in n)) throw new Error("node " + params.node_id + " has no fills");
523
+ n.fills = [paint];
524
+ } else {
525
+ const size = await image.getSizeAsync();
526
+ n = figma.createRectangle();
527
+ n.x = params.x || 0;
528
+ n.y = params.y || 0;
529
+ n.resize(params.width > 0 ? params.width : size.width, params.height > 0 ? params.height : size.height);
530
+ if (params.name) n.name = params.name;
531
+ n.fills = [paint];
532
+ (await parentOf(params)).appendChild(n);
533
+ }
534
+ return summarize(n);
535
+ },
536
+
537
+ async create_text(params) {
538
+ const font = { family: params.font_family || "Inter", style: params.font_style || "Regular" };
539
+ await figma.loadFontAsync(font);
540
+ const t = figma.createText();
541
+ t.fontName = font;
542
+ t.characters = params.text;
543
+ t.x = params.x;
544
+ t.y = params.y;
545
+ if (params.font_size) t.fontSize = params.font_size;
546
+ if (params.fill_color) t.fills = [{ type: "SOLID", color: hexToRGB(params.fill_color) }];
547
+ if (params.line_height) t.lineHeight = { value: params.line_height, unit: "PIXELS" };
548
+ if (params.letter_spacing !== undefined && params.letter_spacing !== null) {
549
+ t.letterSpacing = { value: params.letter_spacing, unit: "PIXELS" };
550
+ }
551
+ if (params.text_align) t.textAlignHorizontal = params.text_align;
552
+ if (params.max_width > 0) {
553
+ t.textAutoResize = "HEIGHT";
554
+ t.resize(params.max_width, t.height);
555
+ }
556
+ if (params.name) t.name = params.name;
557
+ (await parentOf(params)).appendChild(t);
558
+ return summarize(t);
559
+ },
560
+
561
+ async set_characters(params) {
562
+ const nodes = await targets(params);
563
+ for (const t of nodes) {
564
+ if (t.type !== "TEXT") throw new Error("node " + t.id + " is " + t.type + ", not TEXT");
565
+ if (t.characters.length > 0) {
566
+ const fonts = t.getRangeAllFontNames(0, t.characters.length);
567
+ await Promise.all(fonts.map(figma.loadFontAsync));
568
+ } else {
569
+ await figma.loadFontAsync(t.fontName);
570
+ }
571
+ t.characters = params.text;
572
+ }
573
+ return done(params, nodes);
574
+ },
575
+
576
+ async set_fills(params) {
577
+ const nodes = await targets(params);
578
+ const clear = params.color === "none" || params.color === null;
579
+ let paint = null;
580
+ if (!clear) {
581
+ if (params.gradient) {
582
+ paint = buildGradient(params.gradient);
583
+ } else if (params.color) {
584
+ paint = { type: "SOLID", color: hexToRGB(params.color) };
585
+ } else {
586
+ throw new Error('set_fills needs color, gradient, or color: "none" to clear');
587
+ }
588
+ if (params.opacity !== undefined && params.opacity !== null) paint.opacity = params.opacity;
589
+ }
590
+ for (const n of nodes) {
591
+ if (!("fills" in n)) throw new Error("node " + n.id + " has no fills");
592
+ n.fills = clear ? [] : [paint];
593
+ }
594
+ return done(params, nodes);
595
+ },
596
+
597
+ async move_nodes(params) {
598
+ const out = [];
599
+ for (const item of params.items) {
600
+ const n = await node(item.node_id);
601
+ if (!("x" in n)) throw new Error("node " + item.node_id + " cannot be moved");
602
+ n.x = item.x;
603
+ n.y = item.y;
604
+ out.push(n);
605
+ }
606
+ return done(params, out);
607
+ },
608
+
609
+ async resize_nodes(params) {
610
+ const out = [];
611
+ for (const item of params.items) {
612
+ const n = await node(item.node_id);
613
+ if (!("resize" in n)) throw new Error("node " + item.node_id + " cannot be resized");
614
+ n.resize(item.width, item.height);
615
+ out.push(n);
616
+ }
617
+ return done(params, out);
618
+ },
619
+
620
+ async remove_nodes(params) {
621
+ const deleted = [];
622
+ for (const id of params.node_ids) {
623
+ const n = await node(id);
624
+ deleted.push({ id: n.id, name: n.name, type: n.type });
625
+ n.remove();
626
+ }
627
+ return { deleted };
628
+ },
629
+
630
+ async set_auto_layout(params) {
631
+ const n = await node(params.node_id);
632
+ if (!("layoutMode" in n)) throw new Error("node " + params.node_id + " does not support auto-layout");
633
+ n.layoutMode = params.layout_mode;
634
+ if (params.layout_mode === "NONE") return done(params, n);
635
+ if (params.item_spacing !== undefined) n.itemSpacing = params.item_spacing;
636
+ if (params.padding !== undefined) {
637
+ n.paddingTop = n.paddingRight = n.paddingBottom = n.paddingLeft = params.padding;
638
+ }
639
+ if (params.padding_top !== undefined) n.paddingTop = params.padding_top;
640
+ if (params.padding_right !== undefined) n.paddingRight = params.padding_right;
641
+ if (params.padding_bottom !== undefined) n.paddingBottom = params.padding_bottom;
642
+ if (params.padding_left !== undefined) n.paddingLeft = params.padding_left;
643
+ if (params.primary_axis_align) n.primaryAxisAlignItems = params.primary_axis_align;
644
+ if (params.counter_axis_align) n.counterAxisAlignItems = params.counter_axis_align;
645
+ if (params.primary_axis_sizing) n.primaryAxisSizingMode = params.primary_axis_sizing;
646
+ if (params.counter_axis_sizing) n.counterAxisSizingMode = params.counter_axis_sizing;
647
+ return done(params, n);
648
+ },
649
+
650
+ async set_corner_radius(params) {
651
+ const nodes = await targets(params);
652
+ for (const n of nodes) {
653
+ if (params.radius !== undefined) {
654
+ if (!("cornerRadius" in n)) throw new Error("node " + n.id + " has no corner radius");
655
+ n.cornerRadius = params.radius;
656
+ }
657
+ if (!("topLeftRadius" in n)) {
658
+ if (params.radius === undefined) throw new Error("node " + n.id + " has no per-corner radius");
659
+ continue;
660
+ }
661
+ if (params.top_left !== undefined) n.topLeftRadius = params.top_left;
662
+ if (params.top_right !== undefined) n.topRightRadius = params.top_right;
663
+ if (params.bottom_right !== undefined) n.bottomRightRadius = params.bottom_right;
664
+ if (params.bottom_left !== undefined) n.bottomLeftRadius = params.bottom_left;
665
+ }
666
+ return done(params, nodes);
667
+ },
668
+
669
+ async set_strokes(params) {
670
+ const nodes = await targets(params);
671
+ for (const n of nodes) {
672
+ if (!("strokes" in n)) throw new Error("node " + n.id + " has no strokes");
673
+ if (params.color && params.color !== "none") {
674
+ const paint = { type: "SOLID", color: hexToRGB(params.color) };
675
+ if (params.opacity !== undefined && params.opacity !== null) paint.opacity = params.opacity;
676
+ n.strokes = [paint];
677
+ } else {
678
+ n.strokes = [];
679
+ }
680
+ if (params.weight !== undefined && "strokeWeight" in n) n.strokeWeight = params.weight;
681
+ if (params.align && "strokeAlign" in n) n.strokeAlign = params.align;
682
+ }
683
+ return done(params, nodes);
684
+ },
685
+
686
+ async set_effects(params) {
687
+ const nodes = await targets(params);
688
+ const effects = buildEffects(params.effects);
689
+ for (const n of nodes) {
690
+ if (!("effects" in n)) throw new Error("node " + n.id + " has no effects");
691
+ n.effects = effects;
692
+ }
693
+ return done(params, nodes);
694
+ },
695
+
696
+ async rename_nodes(params) {
697
+ const renamed = [];
698
+ for (const item of params.items) {
699
+ const n = await node(item.node_id);
700
+ n.name = item.name;
701
+ renamed.push(n);
702
+ }
703
+ return done(params, renamed);
704
+ },
705
+
706
+ // Restyle text in place. create_text sets the font once; variants (Bold vs
707
+ // Regular of the same label) need this.
708
+ async set_text_properties(params) {
709
+ const nodes = await targets(params);
710
+ for (const n of nodes) {
711
+ if (n.type !== "TEXT") throw new Error("node " + n.id + " is " + n.type + ", not TEXT");
712
+ // Every font already in the node must be loaded before any edit, and
713
+ // the new one before it can be applied.
714
+ const current = n.characters.length
715
+ ? n.getRangeAllFontNames(0, n.characters.length)
716
+ : [n.fontName];
717
+ await Promise.all(current.filter((f) => f !== figma.mixed).map(figma.loadFontAsync));
718
+ if (params.font_family || params.font_style) {
719
+ const base = current[0] === figma.mixed ? { family: "Inter", style: "Regular" } : current[0];
720
+ const font = {
721
+ family: params.font_family || base.family,
722
+ style: params.font_style || base.style,
723
+ };
724
+ await figma.loadFontAsync(font);
725
+ n.fontName = font;
726
+ }
727
+ if (params.font_size) n.fontSize = params.font_size;
728
+ if (params.line_height !== undefined && params.line_height !== null) {
729
+ n.lineHeight = { value: params.line_height, unit: "PIXELS" };
730
+ }
731
+ if (params.letter_spacing !== undefined && params.letter_spacing !== null) {
732
+ n.letterSpacing = { value: params.letter_spacing, unit: "PIXELS" };
733
+ }
734
+ if (params.text_align) n.textAlignHorizontal = params.text_align;
735
+ if (params.fill_color) n.fills = [{ type: "SOLID", color: hexToRGB(params.fill_color) }];
736
+ }
737
+ return done(params, nodes);
738
+ },
739
+
740
+ async download_assets(params) {
741
+ const out = [];
742
+ // One bad item (stale node id, un-exportable type) must not sink the
743
+ // whole batch — report it in place and keep exporting the rest.
744
+ for (const item of params.items) {
745
+ try {
746
+ const target = await node(item.node_id);
747
+ if (!("exportAsync" in target)) throw new Error("node " + item.node_id + " cannot be exported");
748
+ const format = (item.format || "PNG").toUpperCase();
749
+ let settings;
750
+ if (format === "SVG") {
751
+ settings = { format: "SVG" };
752
+ } else if (format === "PNG" || format === "JPG") {
753
+ let scale = item.scale || 1;
754
+ scale = Math.max(0.5, Math.min(scale, 4));
755
+ settings = { format, constraint: { type: "SCALE", value: scale } };
756
+ } else {
757
+ throw new Error("unsupported format: " + format + " (use PNG, JPG or SVG)");
758
+ }
759
+ const bytes = await target.exportAsync(settings);
760
+ out.push({
761
+ data: figma.base64Encode(bytes),
762
+ format,
763
+ name: target.name,
764
+ width: "width" in target ? target.width : 0,
765
+ height: "height" in target ? target.height : 0,
766
+ });
767
+ } catch (e) {
768
+ out.push({ error: String((e && e.message) || e) });
769
+ }
770
+ }
771
+ return out;
772
+ },
773
+
774
+ async set_selection(params) {
775
+ const nodes = await Promise.all(params.node_ids.map(node));
776
+ figma.currentPage.selection = nodes;
777
+ figma.viewport.scrollAndZoomIntoView(nodes);
778
+ return nodes.map(summarize);
779
+ },
780
+
781
+ async find_nodes(params) {
782
+ let scope;
783
+ if (params.node_id) scope = await node(params.node_id);
784
+ else if (params.page) scope = await resolvePage(params.page);
785
+ else scope = figma.currentPage;
786
+ if (!("findAll" in scope)) throw new Error("node " + scope.id + " has no children to search");
787
+ const name = params.name ? params.name.toLowerCase() : null;
788
+ const text = params.text ? params.text.toLowerCase() : null;
789
+ const types = params.types && params.types.length ? params.types : null;
790
+ // Style filters let callers target "every white card" directly instead of
791
+ // guessing a node's role from its size.
792
+ const fill = params.fill ? ("#" + params.fill.replace(/^#/, "")).toLowerCase() : null;
793
+ const fontSize = params.font_size;
794
+ const fontStyle = params.font_style ? params.font_style.toLowerCase() : null;
795
+ const matches = scope.findAll((n) => {
796
+ if (types && types.indexOf(n.type) === -1) return false;
797
+ if (name && n.name.toLowerCase().indexOf(name) === -1) return false;
798
+ if (text && (n.type !== "TEXT" || n.characters.toLowerCase().indexOf(text) === -1)) return false;
799
+ if (fill) {
800
+ const hex = solidFillHex(n);
801
+ if (!hex || hex.toLowerCase() !== fill) return false;
802
+ }
803
+ if (fontSize !== undefined && fontSize !== null) {
804
+ if (n.type !== "TEXT" || val(n.fontSize) !== fontSize) return false;
805
+ }
806
+ if (fontStyle) {
807
+ if (n.type !== "TEXT") return false;
808
+ const f = val(n.fontName);
809
+ if (f === "mixed" || f.style.toLowerCase() !== fontStyle) return false;
810
+ }
811
+ return true;
812
+ });
813
+ const max = params.max_results || 50;
814
+ const fields = params.fields && params.fields.length ? params.fields : null;
815
+ // Name the scope: with no node_id or page this searches whatever page the
816
+ // user happens to have open, which silently changes under a long session.
817
+ const page = (function (n) {
818
+ while (n && n.type !== "PAGE") n = n.parent;
819
+ return n ? n.name : undefined;
820
+ })(scope);
821
+ return {
822
+ scope: scope.type === "PAGE" ? "page " + scope.name : scope.name + " (page " + page + ")",
823
+ total: matches.length,
824
+ truncated: matches.length > max,
825
+ nodes: matches.slice(0, max).map((n) => {
826
+ const s = summarize(n);
827
+ for (const f of fields || []) {
828
+ const v = FIELD_GETTERS[f] ? FIELD_GETTERS[f](n) : undefined;
829
+ if (v !== undefined) s[f] = v;
830
+ }
831
+ return s;
832
+ }),
833
+ };
834
+ },
835
+
836
+ async group_nodes(params) {
837
+ const nodes = await Promise.all(params.node_ids.map(node));
838
+ const group = figma.group(nodes, nodes[0].parent);
839
+ if (params.name) group.name = params.name;
840
+ return summarize(group);
841
+ },
842
+
843
+ async ungroup_nodes(params) {
844
+ const released = [];
845
+ for (const id of params.node_ids) {
846
+ const n = await node(id);
847
+ if (n.type !== "GROUP" && n.type !== "FRAME") {
848
+ throw new Error("node " + id + " is " + n.type + ", not a GROUP or FRAME");
849
+ }
850
+ released.push(...figma.ungroup(n).map(summarize));
851
+ }
852
+ return { released };
853
+ },
854
+
855
+ async append_children(params) {
856
+ const parent = await node(params.parent_id);
857
+ if (!("appendChild" in parent)) throw new Error("node " + params.parent_id + " cannot have children");
858
+ const moved = [];
859
+ const warnings = [];
860
+ let index = params.index;
861
+ for (const id of params.node_ids) {
862
+ const n = await node(id);
863
+ // Figma silently pulls a variant out of its set and renames it
864
+ // "<Set>/<Variant>" — easy to miss until the variants stop working.
865
+ if (n.type === "COMPONENT" && n.parent && n.parent.type === "COMPONENT_SET") {
866
+ warnings.push(
867
+ "node " + n.id + " (" + n.name + ") left component set " + n.parent.name +
868
+ "; Figma renames detached variants to <Set>/<Variant>",
869
+ );
870
+ }
871
+ if (index === undefined || index === null) {
872
+ parent.appendChild(n);
873
+ } else {
874
+ parent.insertChild(index++, n);
875
+ }
876
+ moved.push(n);
877
+ }
878
+ return done(params, moved, warnings.length ? { warnings } : null);
879
+ },
880
+
881
+ async clone_node(params) {
882
+ const n = await node(params.node_id);
883
+ if (!("clone" in n)) throw new Error("node " + params.node_id + " cannot be cloned");
884
+ const copy = n.clone();
885
+ if (params.parent_id) {
886
+ const p = await node(params.parent_id);
887
+ if (!("appendChild" in p)) throw new Error("node " + params.parent_id + " cannot have children");
888
+ p.appendChild(copy);
889
+ }
890
+ if (params.x !== undefined) copy.x = params.x;
891
+ if (params.y !== undefined) copy.y = params.y;
892
+ if (params.name) copy.name = params.name;
893
+ return summarize(copy);
894
+ },
895
+
896
+ async list_available_fonts(params) {
897
+ const fonts = await figma.listAvailableFontsAsync();
898
+ const filter = params.family ? params.family.toLowerCase() : null;
899
+ const byFamily = {};
900
+ const order = [];
901
+ for (const f of fonts) {
902
+ const fam = f.fontName.family;
903
+ if (filter && fam.toLowerCase().indexOf(filter) === -1) continue;
904
+ if (!byFamily[fam]) {
905
+ byFamily[fam] = [];
906
+ order.push(fam);
907
+ }
908
+ byFamily[fam].push(f.fontName.style);
909
+ }
910
+ const max = params.max_families || 50;
911
+ return {
912
+ total: order.length,
913
+ truncated: order.length > max,
914
+ families: order.slice(0, max).map((fam) => ({ family: fam, styles: byFamily[fam] })),
915
+ };
916
+ },
917
+
918
+ // ---------- components ----------
919
+
920
+ async create_component(params) {
921
+ const n = await node(params.node_id);
922
+ if (n.type !== "FRAME") throw new Error("node " + params.node_id + " is " + n.type + ", not a FRAME");
923
+ return summarize(figma.createComponentFromNode(n));
924
+ },
925
+
926
+ async get_local_components() {
927
+ await figma.loadAllPagesAsync();
928
+ const comps = figma.root.findAllWithCriteria({ types: ["COMPONENT", "COMPONENT_SET"] });
929
+ return comps.map((c) => ({
930
+ id: c.id,
931
+ name: c.name,
932
+ type: c.type,
933
+ description: c.description || "",
934
+ page: c.parent && c.parent.type === "PAGE" ? c.parent.name : undefined,
935
+ }));
936
+ },
937
+
938
+ async create_instance(params) {
939
+ const c = await node(params.component_id);
940
+ if (c.type !== "COMPONENT") throw new Error("node " + params.component_id + " is " + c.type + ", not a COMPONENT");
941
+ const inst = c.createInstance();
942
+ if (params.x !== undefined) inst.x = params.x;
943
+ if (params.y !== undefined) inst.y = params.y;
944
+ (await parentOf(params)).appendChild(inst);
945
+ return summarize(inst);
946
+ },
947
+
948
+ async combine_as_variants(params) {
949
+ const nodes = await Promise.all(params.node_ids.map(node));
950
+ for (const n of nodes) {
951
+ if (n.type !== "COMPONENT") throw new Error("node " + n.id + " is " + n.type + ", not a COMPONENT");
952
+ }
953
+ const parent = params.parent_id ? await node(params.parent_id) : nodes[0].parent;
954
+ const set = figma.combineAsVariants(nodes, parent);
955
+ if (params.name) set.name = params.name;
956
+ arrangeVariants(set);
957
+ return summarize(set);
958
+ },
959
+
960
+ // Component properties live on the component (or set); binding the property
961
+ // to a child layer is what makes it editable from the instance panel, so do
962
+ // both in one call.
963
+ async add_component_property(params) {
964
+ const c = await node(params.component_id);
965
+ if (c.type !== "COMPONENT" && c.type !== "COMPONENT_SET") {
966
+ throw new Error("node " + c.id + " is " + c.type + ", not a COMPONENT or COMPONENT_SET");
967
+ }
968
+ const type = (params.type || "TEXT").toUpperCase();
969
+ // Figma requires a default of the matching kind; pick the empty one
970
+ // rather than failing a call that only omitted it.
971
+ let def = params.default_value;
972
+ if (def === undefined || def === null) def = type === "BOOLEAN" ? false : "";
973
+ const key = c.addComponentProperty(params.name, type, def);
974
+ if (params.bind_node_id) {
975
+ const t = await node(params.bind_node_id);
976
+ const field = type === "TEXT" ? "characters" : type === "BOOLEAN" ? "visible" : "mainComponent";
977
+ const refs = {};
978
+ for (const k in t.componentPropertyReferences || {}) refs[k] = t.componentPropertyReferences[k];
979
+ refs[field] = key;
980
+ t.componentPropertyReferences = refs;
981
+ }
982
+ return { key, name: params.name, type, boundTo: params.bind_node_id || null };
983
+ },
984
+
985
+ async set_instance_properties(params) {
986
+ const inst = await node(params.instance_id);
987
+ if (inst.type !== "INSTANCE") throw new Error("node " + params.instance_id + " is " + inst.type + ", not an INSTANCE");
988
+ const known = Object.keys(inst.componentProperties || {});
989
+ for (const k in params.properties) {
990
+ if (known.indexOf(k) === -1) {
991
+ throw new Error("unknown property " + k + "; this instance accepts: " + (known.join(", ") || "(none)"));
992
+ }
993
+ }
994
+ inst.setProperties(params.properties);
995
+ return { id: inst.id, properties: inst.componentProperties };
996
+ },
997
+
998
+ async swap_component(params) {
999
+ const inst = await node(params.instance_id);
1000
+ if (inst.type !== "INSTANCE") throw new Error("node " + params.instance_id + " is " + inst.type + ", not an INSTANCE");
1001
+ const c = await node(params.component_id);
1002
+ if (c.type !== "COMPONENT") throw new Error("node " + params.component_id + " is " + c.type + ", not a COMPONENT");
1003
+ inst.swapComponent(c);
1004
+ return summarize(inst);
1005
+ },
1006
+
1007
+ async detach_instance(params) {
1008
+ const inst = await node(params.instance_id);
1009
+ if (inst.type !== "INSTANCE") throw new Error("node " + params.instance_id + " is " + inst.type + ", not an INSTANCE");
1010
+ return summarize(inst.detachInstance());
1011
+ },
1012
+
1013
+ // ---------- styles ----------
1014
+
1015
+ async get_local_styles() {
1016
+ const [paint, text, effect, grid] = await Promise.all([
1017
+ figma.getLocalPaintStylesAsync(),
1018
+ figma.getLocalTextStylesAsync(),
1019
+ figma.getLocalEffectStylesAsync(),
1020
+ figma.getLocalGridStylesAsync(),
1021
+ ]);
1022
+ const brief = (s) => ({ id: s.id, name: s.name });
1023
+ return {
1024
+ paint: paint.map(brief),
1025
+ text: text.map(brief),
1026
+ effect: effect.map(brief),
1027
+ grid: grid.map(brief),
1028
+ };
1029
+ },
1030
+
1031
+ async create_paint_style(params) {
1032
+ const s = figma.createPaintStyle();
1033
+ s.name = params.name;
1034
+ const paint = { type: "SOLID", color: hexToRGB(params.color) };
1035
+ if (params.opacity !== undefined && params.opacity !== null) paint.opacity = params.opacity;
1036
+ s.paints = [paint];
1037
+ return { id: s.id, name: s.name };
1038
+ },
1039
+
1040
+ async create_text_style(params) {
1041
+ const font = { family: params.font_family || "Inter", style: params.font_style || "Regular" };
1042
+ await figma.loadFontAsync(font);
1043
+ const s = figma.createTextStyle();
1044
+ s.name = params.name;
1045
+ s.fontName = font;
1046
+ if (params.font_size) s.fontSize = params.font_size;
1047
+ return { id: s.id, name: s.name };
1048
+ },
1049
+
1050
+ async create_effect_style(params) {
1051
+ const s = figma.createEffectStyle();
1052
+ s.name = params.name;
1053
+ s.effects = buildEffects(params.effects);
1054
+ return { id: s.id, name: s.name };
1055
+ },
1056
+
1057
+ async apply_style(params) {
1058
+ const nodes = await targets(params);
1059
+ const style = await figma.getStyleByIdAsync(params.style_id);
1060
+ if (!style) throw new Error("style not found: " + params.style_id);
1061
+ if (style.type === "TEXT") await figma.loadFontAsync(style.fontName);
1062
+ for (const n of nodes) {
1063
+ if (style.type === "PAINT") {
1064
+ if (params.target === "stroke") {
1065
+ await n.setStrokeStyleIdAsync(style.id);
1066
+ } else {
1067
+ await n.setFillStyleIdAsync(style.id);
1068
+ }
1069
+ } else if (style.type === "TEXT") {
1070
+ if (n.type !== "TEXT") throw new Error("node " + n.id + " is " + n.type + ", not TEXT");
1071
+ await n.setTextStyleIdAsync(style.id);
1072
+ } else if (style.type === "EFFECT") {
1073
+ await n.setEffectStyleIdAsync(style.id);
1074
+ } else if (style.type === "GRID") {
1075
+ await n.setGridStyleIdAsync(style.id);
1076
+ } else {
1077
+ throw new Error("unsupported style type: " + style.type);
1078
+ }
1079
+ }
1080
+ return done(params, nodes);
1081
+ },
1082
+
1083
+ // ---------- variables (design tokens) ----------
1084
+
1085
+ async get_variable_defs() {
1086
+ const [collections, variables] = await Promise.all([
1087
+ figma.variables.getLocalVariableCollectionsAsync(),
1088
+ figma.variables.getLocalVariablesAsync(),
1089
+ ]);
1090
+ return {
1091
+ collections: collections.map((c) => ({
1092
+ id: c.id,
1093
+ name: c.name,
1094
+ defaultModeId: c.defaultModeId,
1095
+ modes: c.modes.map((m) => ({ modeId: m.modeId, name: m.name })),
1096
+ })),
1097
+ variables: variables.map((v) => ({
1098
+ id: v.id,
1099
+ name: v.name,
1100
+ resolvedType: v.resolvedType,
1101
+ collectionId: v.variableCollectionId,
1102
+ valuesByMode: v.valuesByMode,
1103
+ })),
1104
+ };
1105
+ },
1106
+
1107
+ async create_variable_collection(params) {
1108
+ const c = figma.variables.createVariableCollection(params.name);
1109
+ if (params.mode_name) c.renameMode(c.defaultModeId, params.mode_name);
1110
+ return {
1111
+ id: c.id,
1112
+ name: c.name,
1113
+ defaultModeId: c.defaultModeId,
1114
+ modes: c.modes.map((m) => ({ modeId: m.modeId, name: m.name })),
1115
+ };
1116
+ },
1117
+
1118
+ async add_variable_mode(params) {
1119
+ const c = await figma.variables.getVariableCollectionByIdAsync(params.collection_id);
1120
+ if (!c) throw new Error("variable collection not found: " + params.collection_id);
1121
+ const modeId = c.addMode(params.name);
1122
+ return { collectionId: c.id, modeId, name: params.name };
1123
+ },
1124
+
1125
+ async create_variable(params) {
1126
+ const c = await figma.variables.getVariableCollectionByIdAsync(params.collection_id);
1127
+ if (!c) throw new Error("variable collection not found: " + params.collection_id);
1128
+ const v = figma.variables.createVariable(params.name, c, params.type);
1129
+ if (params.value !== undefined && params.value !== null) {
1130
+ v.setValueForMode(c.defaultModeId, variableValue(v, params.value));
1131
+ }
1132
+ return { id: v.id, name: v.name, resolvedType: v.resolvedType, collectionId: c.id };
1133
+ },
1134
+
1135
+ async set_variable_value(params) {
1136
+ const v = await figma.variables.getVariableByIdAsync(params.variable_id);
1137
+ if (!v) throw new Error("variable not found: " + params.variable_id);
1138
+ const c = await figma.variables.getVariableCollectionByIdAsync(v.variableCollectionId);
1139
+ const modeId = params.mode_id || c.defaultModeId;
1140
+ v.setValueForMode(modeId, variableValue(v, params.value));
1141
+ return { id: v.id, name: v.name, modeId, valuesByMode: v.valuesByMode };
1142
+ },
1143
+
1144
+ async set_bound_variable(params) {
1145
+ const nodes = await targets(params);
1146
+ const v = await figma.variables.getVariableByIdAsync(params.variable_id);
1147
+ if (!v) throw new Error("variable not found: " + params.variable_id);
1148
+ const field = params.field;
1149
+ for (const n of nodes) {
1150
+ if (field === "fills" || field === "strokes") {
1151
+ if (!(field in n)) throw new Error("node " + n.id + " has no " + field);
1152
+ const paints = n[field].length ? n[field] : [{ type: "SOLID", color: { r: 0, g: 0, b: 0 } }];
1153
+ n[field] = [figma.variables.setBoundVariableForPaint(paints[0], "color", v)].concat(paints.slice(1));
1154
+ } else {
1155
+ n.setBoundVariable(field, v);
1156
+ }
1157
+ }
1158
+ return done(params, nodes);
1159
+ },
1160
+
1161
+ // ---------- prototyping ----------
1162
+
1163
+ // setReactionsAsync replaces a node's reactions wholesale, so items for the
1164
+ // same node are grouped and written once instead of overwriting each other.
1165
+ async set_prototype_links(params) {
1166
+ const items = params.items || [];
1167
+ if (!items.length) throw new Error("items is required");
1168
+ const order = [];
1169
+ const groups = {};
1170
+ for (const item of items) {
1171
+ if (!item.node_id) throw new Error("node_id is required for every item");
1172
+ if (!groups[item.node_id]) {
1173
+ groups[item.node_id] = [];
1174
+ order.push(item.node_id);
1175
+ }
1176
+ groups[item.node_id].push(await buildReaction(item));
1177
+ }
1178
+ const out = [];
1179
+ for (const id of order) {
1180
+ const n = await node(id);
1181
+ if (!("setReactionsAsync" in n)) {
1182
+ throw new Error("node " + id + " is " + n.type + " and cannot carry prototype reactions");
1183
+ }
1184
+ await n.setReactionsAsync(groups[id]);
1185
+ out.push(n);
1186
+ }
1187
+ return done(params, out);
1188
+ },
1189
+
1190
+ async get_prototype_links(params) {
1191
+ const scope = params.node_id ? await node(params.node_id) : figma.currentPage;
1192
+ const withReactions = [];
1193
+ const has = (n) => "reactions" in n && n.reactions && n.reactions.length;
1194
+ if (has(scope)) withReactions.push(scope);
1195
+ if ("findAll" in scope) withReactions.push(...scope.findAll(has));
1196
+ const names = {};
1197
+ const nameOf = async (id) => {
1198
+ if (!(id in names)) {
1199
+ const d = await figma.getNodeByIdAsync(id);
1200
+ names[id] = d ? d.name : null;
1201
+ }
1202
+ return names[id];
1203
+ };
1204
+ const links = [];
1205
+ for (const n of withReactions) {
1206
+ for (const r of n.reactions) {
1207
+ for (const a of r.actions || []) {
1208
+ const link = { id: n.id, name: n.name, trigger: r.trigger ? r.trigger.type : null, action: a.type };
1209
+ if (a.navigation) link.navigation = a.navigation;
1210
+ if (a.url) link.url = a.url;
1211
+ if (a.destinationId) {
1212
+ link.destinationId = a.destinationId;
1213
+ const dn = await nameOf(a.destinationId);
1214
+ if (dn) link.destinationName = dn;
1215
+ }
1216
+ if (a.transition) link.transition = a.transition.type;
1217
+ links.push(link);
1218
+ }
1219
+ }
1220
+ }
1221
+ return {
1222
+ scope: scope.type === "PAGE" ? "page " + scope.name : scope.name,
1223
+ total: links.length,
1224
+ links,
1225
+ };
1226
+ },
1227
+
1228
+ // flowStartingPoints is a page-level array, so read-modify-write it.
1229
+ async set_flow_starting_point(params) {
1230
+ const n = await node(params.node_id);
1231
+ const page = pageOf(n);
1232
+ const kept = page.flowStartingPoints.filter((p) => p.nodeId !== n.id);
1233
+ const changed = params.remove
1234
+ ? kept.length !== page.flowStartingPoints.length
1235
+ : true;
1236
+ page.flowStartingPoints = params.remove
1237
+ ? kept
1238
+ : kept.concat([{ nodeId: n.id, name: params.name || n.name }]);
1239
+ return {
1240
+ ok: true,
1241
+ id: n.id,
1242
+ page: page.name,
1243
+ changed,
1244
+ flowStartingPoints: page.flowStartingPoints.map((p) => ({ nodeId: p.nodeId, name: p.name })),
1245
+ };
1246
+ },
1247
+
1248
+ // Escape hatch for bulk work: one call instead of hundreds of atomic ones.
1249
+ // Figma's plugin sandbox may refuse dynamic code entirely; when it does, say
1250
+ // so plainly rather than failing with a bare ReferenceError.
1251
+ async run_script(params) {
1252
+ if (typeof params.code !== "string" || !params.code.trim()) throw new Error("code is required");
1253
+ const token = typeof params.token === "string" && params.token ? params.token : null;
1254
+ if (token && scriptRuns.has(token)) return scriptRuns.get(token);
1255
+ const run = runScriptBody(params);
1256
+ if (token) rememberRun(token, run);
1257
+ return run;
1258
+ },
1259
+
1260
+ async get_screenshot(params) {
1261
+ let target;
1262
+ if (params.node_id) {
1263
+ target = await node(params.node_id);
1264
+ } else if (figma.currentPage.selection.length === 1) {
1265
+ target = figma.currentPage.selection[0];
1266
+ } else {
1267
+ target = figma.currentPage;
1268
+ }
1269
+ if (!("exportAsync" in target)) throw new Error("node " + target.id + " cannot be exported");
1270
+ // With dynamic-page access a node can be reachable before its page is
1271
+ // loaded, and exporting then yields a blank image with no error.
1272
+ let owner = target;
1273
+ while (owner && owner.type !== "PAGE") owner = owner.parent;
1274
+ if (owner && typeof owner.loadAsync === "function") await owner.loadAsync();
1275
+ const requested = Math.max(0.5, Math.min(params.scale || 1, 4));
1276
+ let scale = requested;
1277
+ // Cap the output size: huge pages at high scale produce multi-MB base64
1278
+ // payloads that are slow to ship and waste the AI's context.
1279
+ const cap = params.max_dimension > 0 ? Math.min(params.max_dimension, 4096) : 2000;
1280
+ const maxDim = Math.max("width" in target ? target.width : 0, "height" in target ? target.height : 0);
1281
+ const clamped = maxDim * scale > cap;
1282
+ if (clamped) scale = cap / maxDim;
1283
+ const bytes = await target.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: scale } });
1284
+ return {
1285
+ data: figma.base64Encode(bytes),
1286
+ width: ("width" in target ? target.width : 0) * scale,
1287
+ height: ("height" in target ? target.height : 0) * scale,
1288
+ scale,
1289
+ clamped,
1290
+ sourceHeight: "height" in target ? target.height : 0,
1291
+ };
1292
+ },
1293
+ };
1294
+
1295
+ // ---------- live selection panel ----------
1296
+ // Mirror the current selection into the plugin window so the user can grab a
1297
+ // frame's name and shareable link without going through the AI. Refreshes on
1298
+ // every selection change and resizes the window to fit the list.
1299
+ // selectionGen guards against stale posts: SVG export is async, so a newer
1300
+ // selection change may land while an older one is still exporting.
1301
+ let selectionGen = 0;
1302
+
1303
+ async function postSelection() {
1304
+ const gen = ++selectionGen;
1305
+ const sel = figma.currentPage.selection;
1306
+ const items = [];
1307
+ for (const n of sel) {
1308
+ const item = { name: n.name, type: n.type, link: nodeLink(n.id) };
1309
+ // Frames are copied as links; every other element (vectors, instances, …)
1310
+ // is copied as SVG, so pre-export it now to keep the UI copy synchronous
1311
+ // (clipboard writes must happen inside the click gesture). Skip huge
1312
+ // selections to avoid stalling on a big export the user won't copy.
1313
+ if (n.type !== "FRAME" && sel.length <= 50 && typeof n.exportAsync === "function") {
1314
+ try {
1315
+ item.svg = await n.exportAsync({ format: "SVG_STRING" });
1316
+ } catch (e) {
1317
+ item.svgError = String((e && e.message) || e);
1318
+ }
1319
+ if (gen !== selectionGen) return; // superseded by a newer selection
1320
+ }
1321
+ items.push(item);
1322
+ }
1323
+ const needsKey = !fileKey && items.some((it) => it.type === "FRAME");
1324
+ figma.ui.postMessage({ selection: items, hasKey: !!fileKey });
1325
+ // Room for: base card + filter bar (30) + optional Copy-all header (22) +
1326
+ // the paste-URL prompt when a frame needs a key (64) + up to 6 visible rows
1327
+ // (the list scrolls beyond that).
1328
+ const rows = Math.min(items.length, 6);
1329
+ const h = items.length
1330
+ ? 134 + (items.length > 1 ? 22 : 0) + (needsKey ? 64 : 0) + rows * 30
1331
+ : 104;
1332
+ figma.ui.resize(280, h);
1333
+ }
1334
+ figma.on("selectionchange", postSelection);
1335
+ postSelection();
1336
+
1337
+ // ---------- dispatch ----------
1338
+
1339
+ figma.ui.onmessage = async (msg) => {
1340
+ if (!msg) return;
1341
+ // Control message from the UI: the user pasted the file URL to enable links.
1342
+ if (typeof msg.setFileKey === "string") {
1343
+ setFileKey(msg.setFileKey);
1344
+ postSelection();
1345
+ return;
1346
+ }
1347
+ if (!msg.id || !msg.command) return;
1348
+ const h = handlers[msg.command];
1349
+ try {
1350
+ if (!h) throw new Error("unknown command: " + msg.command);
1351
+ const result = await h(msg.params || {});
1352
+ figma.ui.postMessage({ id: msg.id, result: result === undefined ? null : result });
1353
+ } catch (e) {
1354
+ figma.ui.postMessage({ id: msg.id, error: String((e && e.message) || e) });
1355
+ }
1356
+ };