mcp-figma-toolkit 1.0.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,1311 @@
1
+ // Show UI (hidden) so we can use Web APIs (WebSocket in ui.html)
2
+ figma.showUI(__html__, { visible: false });
3
+
4
+ // ---------- Bridge ----------
5
+ figma.ui.onmessage = async (msg) => {
6
+ const { id, action, args } = msg || {};
7
+ try {
8
+ const result = await handleAction(action, args || {});
9
+ reply(id, Object.assign({ ok: true }, result || {}));
10
+ } catch (e) {
11
+ reply(id, { ok: false }, e instanceof Error ? e.message : String(e));
12
+ }
13
+ };
14
+ function reply(replyTo, result, error) {
15
+ figma.ui.postMessage({ replyTo, result, error });
16
+ }
17
+ const page = () => figma.currentPage;
18
+
19
+ // ---------- Utilities ----------
20
+ function hexToRGB(hex) {
21
+ const v = String(hex || "").replace("#", "").trim();
22
+ if (!/^[0-9a-fA-F]{6}$/.test(v)) throw new Error("Invalid hex color");
23
+ return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 };
24
+ }
25
+ function getNode(id) {
26
+ const n = figma.getNodeById(id);
27
+ if (!n) throw new Error("Node not found: " + id);
28
+ return n;
29
+ }
30
+ function assertFills(n) {
31
+ if (!("fills" in n)) throw new Error("Node does not support fills");
32
+ }
33
+ function base64ToUint8Array(b64) {
34
+ // Pure JS base64 decoder (atob not available in Figma plugin main context)
35
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
36
+ const lookup = new Uint8Array(256);
37
+ for (let i = 0; i < chars.length; i++) lookup[chars.charCodeAt(i)] = i;
38
+
39
+ // Remove padding and calculate output length
40
+ let padding = 0;
41
+ if (b64.endsWith("==")) padding = 2;
42
+ else if (b64.endsWith("=")) padding = 1;
43
+
44
+ const len = b64.length;
45
+ const bufferLength = Math.floor(len * 3 / 4) - padding;
46
+ const bytes = new Uint8Array(bufferLength);
47
+
48
+ let p = 0;
49
+ for (let i = 0; i < len; i += 4) {
50
+ const e1 = lookup[b64.charCodeAt(i)];
51
+ const e2 = lookup[b64.charCodeAt(i + 1)];
52
+ const e3 = lookup[b64.charCodeAt(i + 2)];
53
+ const e4 = lookup[b64.charCodeAt(i + 3)];
54
+
55
+ if (p < bufferLength) bytes[p++] = (e1 << 2) | (e2 >> 4);
56
+ if (p < bufferLength) bytes[p++] = ((e2 & 15) << 4) | (e3 >> 2);
57
+ if (p < bufferLength) bytes[p++] = ((e3 & 3) << 6) | e4;
58
+ }
59
+ return bytes;
60
+ }
61
+
62
+ function uint8ArrayToBase64(bytes) {
63
+ // Pure JS base64 encoder (btoa not available in Figma plugin main context)
64
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
65
+ let result = "";
66
+ const len = bytes.length;
67
+ for (let i = 0; i < len; i += 3) {
68
+ const b1 = bytes[i];
69
+ const b2 = i + 1 < len ? bytes[i + 1] : 0;
70
+ const b3 = i + 2 < len ? bytes[i + 2] : 0;
71
+ result += chars[b1 >> 2];
72
+ result += chars[((b1 & 3) << 4) | (b2 >> 4)];
73
+ result += i + 1 < len ? chars[((b2 & 15) << 2) | (b3 >> 6)] : "=";
74
+ result += i + 2 < len ? chars[b3 & 63] : "=";
75
+ }
76
+ return result;
77
+ }
78
+
79
+ // ---------- Actions dispatcher ----------
80
+ async function handleAction(action, input) {
81
+ switch (action) {
82
+ // Create
83
+ case "create_frame": return createFrame(input);
84
+ case "create_rectangle": return createRectangle(input);
85
+ case "create_ellipse": return createEllipse(input);
86
+ case "create_line": return createLine(input);
87
+ case "create_polygon": return createPolygon(input);
88
+ case "create_star": return createStar(input);
89
+ case "add_text": return addText(input);
90
+ case "place_image_base64": return placeImageBase64(input);
91
+ case "create_vector": return createVector(input);
92
+ case "place_image_url": return placeImageUrl(input);
93
+
94
+ // Selection / find / pages
95
+ case "find_nodes": return findNodes(input);
96
+ case "select_nodes": return selectNodes(input);
97
+ case "get_selection": return getSelection();
98
+ case "create_page": return createPage(input);
99
+ case "set_current_page": return setCurrentPage(input);
100
+
101
+ // Node management
102
+ case "rename_node": return renameNode(input);
103
+ case "delete_node": return deleteNode(input);
104
+ case "duplicate_node": return duplicateNode(input);
105
+ case "resize_node": return resizeNode(input);
106
+ case "rotate_node": return rotateNode(input);
107
+ case "set_position": return setPosition(input);
108
+ case "group_nodes": return groupNodes(input);
109
+ case "ungroup": return ungroup(input);
110
+
111
+ // Styling
112
+ case "set_fill": return setFill(input);
113
+ case "set_stroke": return setStroke(input);
114
+ case "set_corner_radius": return setCornerRadius(input);
115
+ case "set_opacity": return setOpacity(input);
116
+ case "set_blend_mode": return setBlendMode(input);
117
+ case "add_effect": return addEffect(input);
118
+ case "clear_effects": return clearEffects(input);
119
+ case "set_gradient_fill": return setGradientFill(input);
120
+ case "set_gradient_stroke": return setGradientStroke(input);
121
+ case "set_text_gradient": return setTextGradient(input);
122
+ case "layout_grid_add": return layoutGridAdd(input);
123
+ case "layout_grid_clear": return layoutGridClear(input);
124
+
125
+ // Auto Layout & Constraints
126
+ case "set_auto_layout": return setAutoLayout(input);
127
+ case "set_constraints": return setConstraints(input);
128
+
129
+ // Text
130
+ case "set_text_content": return setTextContent(input);
131
+ case "set_text_style": return setTextStyle(input);
132
+ case "set_text_color": return setTextColor(input);
133
+
134
+ // Components / booleans
135
+ case "create_component": return createComponent(input);
136
+ case "create_instance": return createInstance(input);
137
+ case "detach_instance": return detachInstance(input);
138
+ case "boolean_op": return booleanOp(input);
139
+
140
+ // Export / data / generic
141
+ case "export_node": return exportNode(input);
142
+ case "set_plugin_data": return setPluginData(input);
143
+ case "get_plugin_data": return getPluginData(input);
144
+ case "set_properties": return setProperties(input);
145
+
146
+ // Variables
147
+ case "create_variable_collection": return createVariableCollection(input);
148
+ case "create_variable": return createVariable(input);
149
+ case "get_local_variable_collections": return getLocalVariableCollections(input);
150
+ case "get_local_variables": return getLocalVariables(input);
151
+ case "set_variable_value": return setVariableValue(input);
152
+ case "bind_variable": return bindVariable(input);
153
+ case "unbind_variable": return unbindVariable(input);
154
+ case "delete_variable": return deleteVariable(input);
155
+ case "delete_variable_collection": return deleteVariableCollection(input);
156
+
157
+ // Styles
158
+ case "create_text_style": return createTextStyle(input);
159
+ case "create_effect_style": return createEffectStyle(input);
160
+ case "get_local_text_styles": return getLocalTextStyles(input);
161
+ case "get_local_effect_styles": return getLocalEffectStyles(input);
162
+ case "apply_text_style": return applyTextStyle(input);
163
+ case "apply_effect_style": return applyEffectStyle(input);
164
+ case "update_text_style": return updateTextStyle(input);
165
+ case "update_effect_style": return updateEffectStyle(input);
166
+ case "delete_style": return deleteStyle(input);
167
+
168
+ // Enhanced Components
169
+ case "create_component_from_node": return createComponentFromNode(input);
170
+ case "create_component_set": return createComponentSet(input);
171
+ case "add_component_property": return addComponentProperty(input);
172
+ case "set_instance_property": return setInstanceProperty(input);
173
+ case "get_component_properties": return getComponentProperties(input);
174
+
175
+ default:
176
+ throw new Error("Unknown action: " + action);
177
+ }
178
+ }
179
+
180
+ // ---------- Create ----------
181
+ function getParent(parentId) {
182
+ if (!parentId) return page();
183
+ const parent = getNode(parentId);
184
+ if (!("appendChild" in parent)) throw new Error("Parent cannot contain children");
185
+ return parent;
186
+ }
187
+
188
+ function createFrame({ name = "Frame", width = 800, height = 600, x = 0, y = 0, parentId }) {
189
+ const f = figma.createFrame();
190
+ f.name = name; f.resize(width, height); f.x = x; f.y = y;
191
+ getParent(parentId).appendChild(f);
192
+ return { nodeId: f.id, type: f.type, name: f.name, width, height };
193
+ }
194
+ function createRectangle({ width, height, x = 0, y = 0, cornerRadius, hex, parentId }) {
195
+ const r = figma.createRectangle(); r.resize(width, height);
196
+ if (typeof cornerRadius === "number") r.cornerRadius = cornerRadius;
197
+ if (hex) r.fills = [{ type: "SOLID", color: hexToRGB(hex) }];
198
+ r.x = x; r.y = y; getParent(parentId).appendChild(r);
199
+ return { nodeId: r.id, type: r.type };
200
+ }
201
+ function createEllipse({ width, height, x = 0, y = 0, hex, parentId }) {
202
+ const e = figma.createEllipse(); e.resize(width, height);
203
+ if (hex) e.fills = [{ type: "SOLID", color: hexToRGB(hex) }];
204
+ e.x = x; e.y = y; getParent(parentId).appendChild(e);
205
+ return { nodeId: e.id, type: e.type };
206
+ }
207
+ function createLine({ x = 0, y = 0, length, rotation = 0, strokeHex = "#111827", strokeWeight = 1, parentId }) {
208
+ const l = figma.createLine();
209
+ l.x = x; l.y = y; l.rotation = rotation;
210
+ l.strokes = [{ type: "SOLID", color: hexToRGB(strokeHex) }];
211
+ l.strokeWeight = strokeWeight;
212
+ // Figma line length controlled via vector network — easiest: resize in x.
213
+ l.resize(length, 0);
214
+ getParent(parentId).appendChild(l);
215
+ return { nodeId: l.id, type: l.type };
216
+ }
217
+ function createPolygon({ sides, width, height, x = 0, y = 0, hex, parentId }) {
218
+ const p = figma.createPolygon(); p.pointCount = sides; p.resize(width, height);
219
+ if (hex) p.fills = [{ type: "SOLID", color: hexToRGB(hex) }];
220
+ p.x = x; p.y = y; getParent(parentId).appendChild(p);
221
+ return { nodeId: p.id, type: p.type };
222
+ }
223
+ function createStar({ points, width, height, x = 0, y = 0, hex, parentId }) {
224
+ const s = figma.createStar(); s.pointCount = points; s.resize(width, height);
225
+ if (hex) s.fills = [{ type: "SOLID", color: hexToRGB(hex) }];
226
+ s.x = x; s.y = y; getParent(parentId).appendChild(s);
227
+ return { nodeId: s.id, type: s.type };
228
+ }
229
+ async function addText({ text, x = 0, y = 0, fontFamily = "Inter", fontStyle = "Regular", fontSize = 32, parentId }) {
230
+ await figma.loadFontAsync({ family: fontFamily, style: fontStyle });
231
+ const t = figma.createText();
232
+ t.characters = text; t.fontName = { family: fontFamily, style: fontStyle };
233
+ if (fontSize) t.fontSize = fontSize;
234
+ t.x = x; t.y = y; getParent(parentId).appendChild(t);
235
+ return { nodeId: t.id, type: t.type, text: t.characters };
236
+ }
237
+ function placeImageBase64({ width, height, x = 0, y = 0, base64, parentId }) {
238
+ const bytes = base64ToUint8Array(base64);
239
+ const image = figma.createImage(bytes);
240
+ const r = figma.createRectangle(); r.resize(width, height); r.x = x; r.y = y;
241
+ r.fills = [{ type: "IMAGE", imageHash: image.hash, scaleMode: "FILL" }];
242
+ getParent(parentId).appendChild(r);
243
+ return { nodeId: r.id, type: r.type };
244
+ }
245
+
246
+ // Convert SVG path to Figma's vectorPaths API format
247
+ // vectorPaths API supports: M, L, C, Q, Z (and H, V converted to L)
248
+ // vectorPaths API does NOT support: A (arc), S (smooth curve), T (smooth quadratic)
249
+ // This function converts relative to absolute AND converts S→C, T→Q
250
+ // Arc (A) commands throw an error - use svgString parameter instead for full SVG support
251
+ function normalizePathToAbsolute(pathData) {
252
+ const cmdRegex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
253
+ const numRegex = /-?[\d.]+(?:e[+-]?\d+)?/gi;
254
+
255
+ let result = "";
256
+ let curX = 0, curY = 0;
257
+ let startX = 0, startY = 0;
258
+ // Track last control point for S and T commands
259
+ let lastCubicX2 = 0, lastCubicY2 = 0;
260
+ let lastQuadX1 = 0, lastQuadY1 = 0;
261
+ let lastCmd = '';
262
+
263
+ let match;
264
+ while ((match = cmdRegex.exec(pathData)) !== null) {
265
+ const cmd = match[1];
266
+ const argsStr = match[2];
267
+ const nums = argsStr.match(numRegex) || [];
268
+ const args = nums.map(Number);
269
+
270
+ const isRelative = cmd === cmd.toLowerCase();
271
+ const absCmd = cmd.toUpperCase();
272
+
273
+ switch (absCmd) {
274
+ case 'M':
275
+ for (let i = 0; i < args.length; i += 2) {
276
+ const x = isRelative ? curX + args[i] : args[i];
277
+ const y = isRelative ? curY + args[i + 1] : args[i + 1];
278
+ result += (i === 0 ? 'M ' : 'L ') + x + ' ' + y + ' ';
279
+ curX = x; curY = y;
280
+ if (i === 0) { startX = x; startY = y; }
281
+ }
282
+ lastCmd = 'M';
283
+ break;
284
+
285
+ case 'L':
286
+ for (let i = 0; i < args.length; i += 2) {
287
+ const x = isRelative ? curX + args[i] : args[i];
288
+ const y = isRelative ? curY + args[i + 1] : args[i + 1];
289
+ result += 'L ' + x + ' ' + y + ' ';
290
+ curX = x; curY = y;
291
+ }
292
+ lastCmd = 'L';
293
+ break;
294
+
295
+ case 'H':
296
+ for (let i = 0; i < args.length; i++) {
297
+ const x = isRelative ? curX + args[i] : args[i];
298
+ result += 'L ' + x + ' ' + curY + ' ';
299
+ curX = x;
300
+ }
301
+ lastCmd = 'H';
302
+ break;
303
+
304
+ case 'V':
305
+ for (let i = 0; i < args.length; i++) {
306
+ const y = isRelative ? curY + args[i] : args[i];
307
+ result += 'L ' + curX + ' ' + y + ' ';
308
+ curY = y;
309
+ }
310
+ lastCmd = 'V';
311
+ break;
312
+
313
+ case 'C':
314
+ for (let i = 0; i < args.length; i += 6) {
315
+ const x1 = isRelative ? curX + args[i] : args[i];
316
+ const y1 = isRelative ? curY + args[i + 1] : args[i + 1];
317
+ const x2 = isRelative ? curX + args[i + 2] : args[i + 2];
318
+ const y2 = isRelative ? curY + args[i + 3] : args[i + 3];
319
+ const x = isRelative ? curX + args[i + 4] : args[i + 4];
320
+ const y = isRelative ? curY + args[i + 5] : args[i + 5];
321
+ result += 'C ' + x1 + ' ' + y1 + ' ' + x2 + ' ' + y2 + ' ' + x + ' ' + y + ' ';
322
+ lastCubicX2 = x2; lastCubicY2 = y2;
323
+ curX = x; curY = y;
324
+ }
325
+ lastCmd = 'C';
326
+ break;
327
+
328
+ case 'S': // Smooth curve → Convert to C
329
+ for (let i = 0; i < args.length; i += 4) {
330
+ // First control point is reflection of last cubic's second control point
331
+ let x1, y1;
332
+ if (lastCmd === 'C' || lastCmd === 'S') {
333
+ x1 = 2 * curX - lastCubicX2;
334
+ y1 = 2 * curY - lastCubicY2;
335
+ } else {
336
+ x1 = curX;
337
+ y1 = curY;
338
+ }
339
+ const x2 = isRelative ? curX + args[i] : args[i];
340
+ const y2 = isRelative ? curY + args[i + 1] : args[i + 1];
341
+ const x = isRelative ? curX + args[i + 2] : args[i + 2];
342
+ const y = isRelative ? curY + args[i + 3] : args[i + 3];
343
+ result += 'C ' + x1 + ' ' + y1 + ' ' + x2 + ' ' + y2 + ' ' + x + ' ' + y + ' ';
344
+ lastCubicX2 = x2; lastCubicY2 = y2;
345
+ curX = x; curY = y;
346
+ }
347
+ lastCmd = 'S';
348
+ break;
349
+
350
+ case 'Q':
351
+ for (let i = 0; i < args.length; i += 4) {
352
+ const x1 = isRelative ? curX + args[i] : args[i];
353
+ const y1 = isRelative ? curY + args[i + 1] : args[i + 1];
354
+ const x = isRelative ? curX + args[i + 2] : args[i + 2];
355
+ const y = isRelative ? curY + args[i + 3] : args[i + 3];
356
+ result += 'Q ' + x1 + ' ' + y1 + ' ' + x + ' ' + y + ' ';
357
+ lastQuadX1 = x1; lastQuadY1 = y1;
358
+ curX = x; curY = y;
359
+ }
360
+ lastCmd = 'Q';
361
+ break;
362
+
363
+ case 'T': // Smooth quadratic → Convert to Q
364
+ for (let i = 0; i < args.length; i += 2) {
365
+ let x1, y1;
366
+ if (lastCmd === 'Q' || lastCmd === 'T') {
367
+ x1 = 2 * curX - lastQuadX1;
368
+ y1 = 2 * curY - lastQuadY1;
369
+ } else {
370
+ x1 = curX;
371
+ y1 = curY;
372
+ }
373
+ const x = isRelative ? curX + args[i] : args[i];
374
+ const y = isRelative ? curY + args[i + 1] : args[i + 1];
375
+ result += 'Q ' + x1 + ' ' + y1 + ' ' + x + ' ' + y + ' ';
376
+ lastQuadX1 = x1; lastQuadY1 = y1;
377
+ curX = x; curY = y;
378
+ }
379
+ lastCmd = 'T';
380
+ break;
381
+
382
+ case 'A': // Arc - not supported by Figma's vectorPaths API
383
+ throw new Error("SVG Arc commands (A/a) are not supported in pathData mode. Use 'svgString' parameter instead for full SVG support.");
384
+
385
+ case 'Z':
386
+ result += 'Z ';
387
+ curX = startX; curY = startY;
388
+ lastCmd = 'Z';
389
+ break;
390
+ }
391
+ }
392
+
393
+ return result.trim();
394
+ }
395
+
396
+ function createVector({ pathData, width, height, x = 0, y = 0, fillHex, strokeHex, strokeWeight = 1, name = "Vector", parentId, svgString }) {
397
+ // If full SVG string provided, use createNodeFromSvg (handles arcs, circles, etc.)
398
+ if (svgString) {
399
+ const frame = figma.createNodeFromSvg(svgString);
400
+ frame.name = name;
401
+ frame.x = x;
402
+ frame.y = y;
403
+ if (width && height) {
404
+ frame.resize(width, height);
405
+ }
406
+ getParent(parentId).appendChild(frame);
407
+ return { nodeId: frame.id, type: frame.type, name: frame.name };
408
+ }
409
+
410
+ // Validate pathData is provided
411
+ if (!pathData) {
412
+ throw new Error("Either 'svgString' or 'pathData' must be provided");
413
+ }
414
+
415
+ // Otherwise use pathData with vectorPaths API
416
+ const vector = figma.createVector();
417
+ vector.name = name;
418
+
419
+ // Convert relative commands to absolute (Figma vectorPaths only supports absolute)
420
+ const normalizedPath = normalizePathToAbsolute(pathData);
421
+
422
+ vector.vectorPaths = [{
423
+ windingRule: "NONZERO",
424
+ data: normalizedPath
425
+ }];
426
+
427
+ // Resize to desired dimensions
428
+ vector.resize(width, height);
429
+ vector.x = x;
430
+ vector.y = y;
431
+
432
+ // Apply fill if specified
433
+ if (fillHex) {
434
+ vector.fills = [{ type: "SOLID", color: hexToRGB(fillHex) }];
435
+ } else {
436
+ vector.fills = [];
437
+ }
438
+
439
+ // Apply stroke if specified
440
+ if (strokeHex) {
441
+ vector.strokes = [{ type: "SOLID", color: hexToRGB(strokeHex) }];
442
+ vector.strokeWeight = strokeWeight;
443
+ vector.strokeCap = "ROUND";
444
+ vector.strokeJoin = "ROUND";
445
+ }
446
+
447
+ getParent(parentId).appendChild(vector);
448
+ return { nodeId: vector.id, type: vector.type, name: vector.name };
449
+ }
450
+
451
+ function placeImageUrl({ width, height, x = 0, y = 0, base64, cornerRadius = 0, name = "Image", parentId }) {
452
+ // base64 is passed from server after fetching the URL
453
+ const bytes = base64ToUint8Array(base64);
454
+ const image = figma.createImage(bytes);
455
+ const r = figma.createRectangle();
456
+ r.name = name;
457
+ r.resize(width, height);
458
+ r.x = x;
459
+ r.y = y;
460
+ if (cornerRadius > 0) r.cornerRadius = cornerRadius;
461
+ r.fills = [{ type: "IMAGE", imageHash: image.hash, scaleMode: "FILL" }];
462
+ getParent(parentId).appendChild(r);
463
+ return { nodeId: r.id, type: r.type, name: r.name };
464
+ }
465
+
466
+ // ---------- Selection / find / pages ----------
467
+ function findNodes({ type, nameContains, within }) {
468
+ let scope = within ? getNode(within) : page();
469
+ if (!("findAll" in scope)) throw new Error("Invalid 'within' scope");
470
+ const nodes = scope.findAll(n => {
471
+ const typeOk = type ? n.type === type : true;
472
+ const nameOk = nameContains ? (("name" in n) && String(n.name).toLowerCase().includes(nameContains.toLowerCase())) : true;
473
+ return typeOk && nameOk;
474
+ });
475
+ return nodes.map(n => ({ id: n.id, type: n.type, name: "name" in n ? n.name : undefined }));
476
+ }
477
+ function selectNodes({ nodeIds }) {
478
+ const nodes = nodeIds.map(getNode).filter(n => !!n);
479
+ figma.currentPage.selection = nodes;
480
+ return { selected: nodes.map(n => n.id) };
481
+ }
482
+ function getSelection() {
483
+ return figma.currentPage.selection.map(n => ({ id: n.id, type: n.type, name: "name" in n ? n.name : undefined }));
484
+ }
485
+ function createPage({ name = "Page", makeCurrent = true }) {
486
+ const p = figma.createPage(); p.name = name;
487
+ if (makeCurrent) figma.currentPage = p;
488
+ return { pageId: p.id, name: p.name };
489
+ }
490
+ function setCurrentPage({ pageId }) {
491
+ const p = getNode(pageId);
492
+ if (p.type !== "PAGE") throw new Error("Not a page");
493
+ figma.currentPage = p;
494
+ return { pageId: p.id };
495
+ }
496
+
497
+ // ---------- Node management ----------
498
+ function renameNode({ nodeId, name }) { const n = getNode(nodeId); if ("name" in n) n.name = name; return { nodeId }; }
499
+ function deleteNode({ nodeId }) { const n = getNode(nodeId); n.remove(); return { removed: nodeId }; }
500
+ function duplicateNode({ nodeId, x, y }) {
501
+ const n = getNode(nodeId); const copy = n.clone();
502
+ if (typeof x === "number") copy.x = x;
503
+ if (typeof y === "number") copy.y = y;
504
+ n.parent && n.parent.appendChild(copy);
505
+ return { nodeId: copy.id };
506
+ }
507
+ function resizeNode({ nodeId, width, height }) { const n = getNode(nodeId); if (!("resize" in n)) throw new Error("Node cannot be resized"); n.resize(width, height); return { nodeId }; }
508
+ function rotateNode({ nodeId, rotation }) { const n = getNode(nodeId); if (!("rotation" in n)) throw new Error("No rotation on node"); n.rotation = rotation; return { nodeId }; }
509
+ function setPosition({ nodeId, x, y }) { const n = getNode(nodeId); if (!("x" in n && "y" in n)) throw new Error("Node not positionable"); n.x = x; n.y = y; return { nodeId }; }
510
+ function groupNodes({ nodeIds, name = "Group" }) {
511
+ const nodes = nodeIds.map(getNode).filter(n => !!n && "visible" in n);
512
+ if (nodes.length < 2) throw new Error("Need 2+ nodes");
513
+ const parent = nodes[0].parent || page();
514
+ const g = figma.group(nodes, parent); g.name = name; return { nodeId: g.id, type: g.type };
515
+ }
516
+ function ungroup({ groupId }) {
517
+ const g = getNode(groupId);
518
+ if (g.type !== "GROUP") throw new Error("Not a group");
519
+ const parent = g.parent || page();
520
+ const children = [];
521
+ for (let i = 0; i < g.children.length; i++) children.push(g.children[i]);
522
+ for (const c of children) parent.appendChild(c);
523
+ g.remove();
524
+ return { released: children.map(c => c.id) };
525
+ }
526
+
527
+ // ---------- Styling ----------
528
+ function setFill({ nodeId, hex, opacity }) {
529
+ const n = getNode(nodeId); assertFills(n);
530
+ const fill = { type: "SOLID", color: hexToRGB(hex) };
531
+ if (typeof opacity === "number") fill.opacity = Math.max(0, Math.min(1, opacity));
532
+ n.fills = [fill];
533
+ return { nodeId };
534
+ }
535
+ function setStroke({ nodeId, hex, opacity, strokeWeight, strokeAlign, dashPattern, cap, join }) {
536
+ const n = getNode(nodeId);
537
+ if (!("strokes" in n)) throw new Error("Node does not support strokes");
538
+ const s = { type: "SOLID", color: hexToRGB(hex) };
539
+ if (typeof opacity === "number") s.opacity = Math.max(0, Math.min(1, opacity));
540
+ n.strokes = [s];
541
+ if (strokeWeight != null) n.strokeWeight = strokeWeight;
542
+ if (strokeAlign) n.strokeAlign = strokeAlign;
543
+ if (dashPattern) n.dashPattern = dashPattern;
544
+ if (cap) n.strokeCap = cap;
545
+ if (join) n.strokeJoin = join;
546
+ return { nodeId };
547
+ }
548
+ function setCornerRadius({ nodeId, radius, topLeft, topRight, bottomRight, bottomLeft }) {
549
+ const n = getNode(nodeId);
550
+ if ("cornerRadius" in n && typeof radius === "number") n.cornerRadius = radius;
551
+ if ("topLeftRadius" in n) {
552
+ if (typeof topLeft === "number") n.topLeftRadius = topLeft;
553
+ if (typeof topRight === "number") n.topRightRadius = topRight;
554
+ if (typeof bottomRight === "number") n.bottomRightRadius = bottomRight;
555
+ if (typeof bottomLeft === "number") n.bottomLeftRadius = bottomLeft;
556
+ }
557
+ return { nodeId };
558
+ }
559
+ function setOpacity({ nodeId, opacity }) { const n = getNode(nodeId); if (!("opacity" in n)) throw new Error("No opacity on node"); n.opacity = Math.max(0, Math.min(1, opacity)); return { nodeId }; }
560
+ function setBlendMode({ nodeId, mode }) { const n = getNode(nodeId); if (!("blendMode" in n)) throw new Error("No blend mode"); n.blendMode = mode; return { nodeId }; }
561
+ function addEffect({ nodeId, type, radius = 8, spread = 0, hex = "#000000", opacity = 0.25, offsetX = 0, offsetY = 2 }) {
562
+ const n = getNode(nodeId);
563
+ if (!("effects" in n)) throw new Error("Node does not support effects");
564
+ const newEff = (() => {
565
+ if (type === "LAYER_BLUR" || type === "BACKGROUND_BLUR") {
566
+ return { type, radius, visible: true };
567
+ }
568
+ const rgb = hexToRGB(hex);
569
+ const color = { r: rgb.r, g: rgb.g, b: rgb.b, a: opacity };
570
+ return {
571
+ type,
572
+ radius,
573
+ spread,
574
+ color,
575
+ offset: { x: offsetX, y: offsetY },
576
+ visible: true,
577
+ blendMode: "NORMAL"
578
+ };
579
+ })();
580
+ const currentEffects = [];
581
+ for (let i = 0; i < n.effects.length; i++) currentEffects.push(n.effects[i]);
582
+ currentEffects.push(newEff);
583
+ n.effects = currentEffects;
584
+ return { nodeId, effects: n.effects.length };
585
+ }
586
+ function clearEffects({ nodeId }) { const n = getNode(nodeId); if (!("effects" in n)) throw new Error("Node does not support effects"); n.effects = []; return { nodeId }; }
587
+
588
+ // ---------- Gradient tools ----------
589
+ function angleToTransform(angle = 135) {
590
+ // Convert angle to Figma's gradient transform matrix
591
+ // Figma uses a 2x3 matrix: [[a, c, tx], [b, d, ty]]
592
+ const radians = (angle * Math.PI) / 180;
593
+ const cos = Math.cos(radians);
594
+ const sin = Math.sin(radians);
595
+ // Adjust for Figma's coordinate system (0,0 is top-left, gradient goes from 0 to 1)
596
+ return [
597
+ [cos, sin, 0.5 - cos * 0.5 - sin * 0.5],
598
+ [-sin, cos, 0.5 + sin * 0.5 - cos * 0.5]
599
+ ];
600
+ }
601
+
602
+ function hexToRGBA(hex, opacity = 1) {
603
+ const rgb = hexToRGB(hex);
604
+ return { r: rgb.r, g: rgb.g, b: rgb.b, a: opacity };
605
+ }
606
+
607
+ function setGradientFill({ nodeId, startHex, endHex, angle = 135, startOpacity = 1, endOpacity = 1 }) {
608
+ const n = getNode(nodeId);
609
+ assertFills(n);
610
+ const gradientFill = {
611
+ type: "GRADIENT_LINEAR",
612
+ gradientTransform: angleToTransform(angle),
613
+ gradientStops: [
614
+ { position: 0, color: hexToRGBA(startHex, startOpacity) },
615
+ { position: 1, color: hexToRGBA(endHex, endOpacity) }
616
+ ]
617
+ };
618
+ n.fills = [gradientFill];
619
+ return { nodeId };
620
+ }
621
+
622
+ function setGradientStroke({ nodeId, startHex, endHex, strokeWeight = 1, angle = 135, strokeAlign = "CENTER" }) {
623
+ const n = getNode(nodeId);
624
+ if (!("strokes" in n)) throw new Error("Node does not support strokes");
625
+ const gradientStroke = {
626
+ type: "GRADIENT_LINEAR",
627
+ gradientTransform: angleToTransform(angle),
628
+ gradientStops: [
629
+ { position: 0, color: hexToRGBA(startHex, 1) },
630
+ { position: 1, color: hexToRGBA(endHex, 1) }
631
+ ]
632
+ };
633
+ n.strokes = [gradientStroke];
634
+ n.strokeWeight = strokeWeight;
635
+ n.strokeAlign = strokeAlign;
636
+ return { nodeId };
637
+ }
638
+
639
+ function setTextGradient({ nodeId, startHex, endHex, angle = 135 }) {
640
+ const t = getNode(nodeId);
641
+ if (t.type !== "TEXT") throw new Error("Not a text node");
642
+ const gradientFill = {
643
+ type: "GRADIENT_LINEAR",
644
+ gradientTransform: angleToTransform(angle),
645
+ gradientStops: [
646
+ { position: 0, color: hexToRGBA(startHex, 1) },
647
+ { position: 1, color: hexToRGBA(endHex, 1) }
648
+ ]
649
+ };
650
+ t.fills = [gradientFill];
651
+ return { nodeId };
652
+ }
653
+
654
+ function layoutGridAdd({ nodeId, pattern = "COLUMNS", count = 12, gutterSize = 20, sectionSize = 80, hex = "#E5E7EB", opacity = 0.5 }) {
655
+ const n = getNode(nodeId);
656
+ if (!("layoutGrids" in n)) throw new Error("Node does not support layoutGrids");
657
+ const rgb = hexToRGB(hex);
658
+ const g = { pattern, count, gutterSize, sectionSize, color: { r: rgb.r, g: rgb.g, b: rgb.b, a: opacity } };
659
+ const currentGrids = [];
660
+ for (let i = 0; i < n.layoutGrids.length; i++) currentGrids.push(n.layoutGrids[i]);
661
+ currentGrids.push(g);
662
+ n.layoutGrids = currentGrids;
663
+ return { nodeId, grids: n.layoutGrids.length };
664
+ }
665
+ function layoutGridClear({ nodeId }) { const n = getNode(nodeId); if (!("layoutGrids" in n)) throw new Error("Node does not support layoutGrids"); n.layoutGrids = []; return { nodeId }; }
666
+
667
+ // ---------- Auto Layout & Constraints ----------
668
+ function setAutoLayout(input) {
669
+ const nodeId = input.nodeId;
670
+ const props = Object.assign({}, input);
671
+ delete props.nodeId;
672
+ const f = getNode(nodeId);
673
+ if (f.type !== "FRAME") throw new Error("Auto Layout only on frames");
674
+ const map = {
675
+ layoutMode: "layoutMode",
676
+ primaryAxisSizingMode: "primaryAxisSizingMode",
677
+ counterAxisSizingMode: "counterAxisSizingMode",
678
+ itemSpacing: "itemSpacing",
679
+ paddingTop: "paddingTop",
680
+ paddingRight: "paddingRight",
681
+ paddingBottom: "paddingBottom",
682
+ paddingLeft: "paddingLeft",
683
+ primaryAxisAlignItems: "primaryAxisAlignItems",
684
+ counterAxisAlignItems: "counterAxisAlignItems",
685
+ layoutWrap: "layoutWrap",
686
+ counterAxisSpacing: "counterAxisSpacing",
687
+ layoutPositioning: "layoutPositioning"
688
+ };
689
+ for (const k in map) if (k in props) f[map[k]] = props[k];
690
+ return { nodeId: f.id };
691
+ }
692
+ function setConstraints({ nodeId, horizontal, vertical }) {
693
+ const n = getNode(nodeId);
694
+ if (!("constraints" in n)) throw new Error("No constraints on node");
695
+ n.constraints = {
696
+ horizontal: horizontal || n.constraints.horizontal,
697
+ vertical: vertical || n.constraints.vertical
698
+ };
699
+ return { nodeId };
700
+ }
701
+
702
+ // ---------- Text ----------
703
+ async function setTextContent({ nodeId, text }) {
704
+ const t = getNode(nodeId);
705
+ if (t.type !== "TEXT") throw new Error("Not a text node");
706
+ const font = t.fontName;
707
+ if (font && typeof font !== "symbol") await figma.loadFontAsync(font);
708
+ t.characters = text;
709
+ return { nodeId };
710
+ }
711
+ async function setTextStyle({ nodeId, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlignHorizontal, textAutoResize }) {
712
+ const t = getNode(nodeId);
713
+ if (t.type !== "TEXT") throw new Error("Not a text node");
714
+ const fam = fontFamily || (typeof t.fontName !== "symbol" ? t.fontName.family : "Inter");
715
+ const sty = fontStyle || (typeof t.fontName !== "symbol" ? t.fontName.style : "Regular");
716
+ await figma.loadFontAsync({ family: fam, style: sty });
717
+ t.fontName = { family: fam, style: sty };
718
+ if (fontSize != null) t.fontSize = fontSize;
719
+ if (lineHeight != null) t.lineHeight = { unit: "PIXELS", value: lineHeight };
720
+ if (letterSpacing != null) t.letterSpacing = { unit: "PIXELS", value: letterSpacing };
721
+ if (textAlignHorizontal) t.textAlignHorizontal = textAlignHorizontal;
722
+ if (textAutoResize) t.textAutoResize = textAutoResize;
723
+ return { nodeId };
724
+ }
725
+ function setTextColor({ nodeId, hex, opacity }) {
726
+ const t = getNode(nodeId);
727
+ if (t.type !== "TEXT") throw new Error("Not a text node");
728
+ const fill = { type: "SOLID", color: hexToRGB(hex) };
729
+ if (typeof opacity === "number") fill.opacity = Math.max(0, Math.min(1, opacity));
730
+ t.fills = [fill];
731
+ return { nodeId };
732
+ }
733
+
734
+ // ---------- Components & Boolean ----------
735
+ function createComponent({ name = "Component", fromNodeIds }) {
736
+ const c = figma.createComponent(); c.name = name;
737
+ page().appendChild(c);
738
+ if (Array.isArray(fromNodeIds) && fromNodeIds.length) {
739
+ const nodes = fromNodeIds.map(getNode);
740
+ for (const n of nodes) c.appendChild(n);
741
+ }
742
+ return { nodeId: c.id, type: c.type };
743
+ }
744
+ function createInstance({ componentId, x = 0, y = 0 }) {
745
+ const c = getNode(componentId);
746
+ if (c.type !== "COMPONENT") throw new Error("Not a component");
747
+ const inst = c.createInstance(); inst.x = x; inst.y = y; page().appendChild(inst);
748
+ return { nodeId: inst.id, type: inst.type };
749
+ }
750
+ function detachInstance({ nodeId }) {
751
+ const n = getNode(nodeId);
752
+ if ("detachInstance" in n) {
753
+ const d = n.detachInstance();
754
+ return { nodeId: d.id, type: d.type };
755
+ }
756
+ throw new Error("Node is not an instance");
757
+ }
758
+ function booleanOp({ op, nodeIds, name = "Boolean" }) {
759
+ const nodes = nodeIds.map(getNode);
760
+ const parent = nodes[0].parent || page();
761
+ let res;
762
+ switch (op) {
763
+ case "UNION": res = figma.union(nodes, parent); break;
764
+ case "SUBTRACT": res = figma.subtract(nodes, parent); break;
765
+ case "INTERSECT": res = figma.intersect(nodes, parent); break;
766
+ case "EXCLUDE": res = figma.exclude(nodes, parent); break;
767
+ }
768
+ res.name = name;
769
+ return { nodeId: res.id, type: res.type };
770
+ }
771
+
772
+ // ---------- Export / plugin data / generic ----------
773
+ async function exportNode({ nodeId, format = "PNG", scale = 1 }) {
774
+ const n = getNode(nodeId);
775
+ const bytes = await n.exportAsync({ format, constraint: { type: "SCALE", value: scale } });
776
+ const base64 = uint8ArrayToBase64(bytes);
777
+ return { format, base64 };
778
+ }
779
+ function setPluginData({ nodeId, key, value }) {
780
+ const n = getNode(nodeId);
781
+ n.setPluginData(key, JSON.stringify(value));
782
+ return { nodeId };
783
+ }
784
+ function getPluginData({ nodeId, key }) {
785
+ const n = getNode(nodeId);
786
+ const raw = n.getPluginData(key);
787
+ try {
788
+ return { value: JSON.parse(raw) };
789
+ } catch (e) {
790
+ return { value: raw };
791
+ }
792
+ }
793
+ function setProperties({ nodeId, props }) {
794
+ const n = getNode(nodeId);
795
+ // Whitelisted scalar props (expand as needed)
796
+ const allowed = [
797
+ "x","y","rotation","opacity","visible","locked",
798
+ "layoutAlign","layoutGrow",
799
+ "fills","strokes","strokeWeight","strokeAlign","dashPattern","blendMode",
800
+ "itemSpacing","paddingTop","paddingRight","paddingBottom","paddingLeft",
801
+ "primaryAxisAlignItems","counterAxisAlignItems","layoutMode",
802
+ "primaryAxisSizingMode","counterAxisSizingMode","layoutWrap","counterAxisSpacing",
803
+ "textAlignHorizontal","textAlignVertical"
804
+ ];
805
+ for (const k of Object.keys(props || {})) {
806
+ if (allowed.includes(k)) {
807
+ try { n[k] = props[k]; } catch (_) {}
808
+ }
809
+ }
810
+ return { nodeId };
811
+ }
812
+
813
+ // ========== VARIABLES ==========
814
+
815
+ function createVariableCollection({ name, modes = ["Default"] }) {
816
+ const collection = figma.variables.createVariableCollection(name);
817
+
818
+ // Ensure collection is visible (not hidden from publishing)
819
+ collection.hiddenFromPublishing = false;
820
+
821
+ // Rename the default mode and add additional modes
822
+ const modeIds = [];
823
+ for (let i = 0; i < modes.length; i++) {
824
+ if (i === 0) {
825
+ // Rename the default mode that's automatically created
826
+ collection.renameMode(collection.modes[0].modeId, modes[i]);
827
+ modeIds.push(collection.modes[0].modeId);
828
+ } else {
829
+ // Add new modes
830
+ const modeId = collection.addMode(modes[i]);
831
+ modeIds.push(modeId);
832
+ }
833
+ }
834
+ return {
835
+ collectionId: collection.id,
836
+ name: collection.name,
837
+ modes: collection.modes.map(m => ({ modeId: m.modeId, name: m.name }))
838
+ };
839
+ }
840
+
841
+ function createVariable({ collectionId, name, resolvedType, values, scopes }) {
842
+ // resolvedType: "COLOR" | "FLOAT" | "STRING" | "BOOLEAN"
843
+ const collection = figma.variables.getVariableCollectionById(collectionId);
844
+ if (!collection) throw new Error("Variable collection not found: " + collectionId);
845
+
846
+ const variable = figma.variables.createVariable(name, collection, resolvedType);
847
+
848
+ // Ensure variable is not hidden from publishing (affects visibility)
849
+ variable.hiddenFromPublishing = false;
850
+
851
+ // Set scopes to make variable visible in UI picker
852
+ // If not provided, default to ALL_SCOPES for visibility
853
+ if (scopes && scopes.length > 0) {
854
+ variable.scopes = scopes;
855
+ } else {
856
+ // Default scopes based on type
857
+ if (resolvedType === "COLOR") {
858
+ variable.scopes = ["ALL_FILLS", "STROKE_COLOR", "EFFECT_COLOR"];
859
+ } else if (resolvedType === "FLOAT") {
860
+ variable.scopes = ["ALL_SCOPES"];
861
+ } else if (resolvedType === "STRING") {
862
+ variable.scopes = ["ALL_SCOPES"];
863
+ }
864
+ // BOOLEAN variables don't support scopes
865
+ }
866
+
867
+ // Set values for each mode if provided
868
+ if (values) {
869
+ for (const modeId in values) {
870
+ let value = values[modeId];
871
+ // Convert hex to RGB for COLOR type
872
+ if (resolvedType === "COLOR" && typeof value === "string") {
873
+ value = hexToRGB(value);
874
+ }
875
+ variable.setValueForMode(modeId, value);
876
+ }
877
+ }
878
+
879
+ return {
880
+ variableId: variable.id,
881
+ name: variable.name,
882
+ resolvedType: variable.resolvedType,
883
+ collectionId: collection.id,
884
+ scopes: variable.scopes
885
+ };
886
+ }
887
+
888
+ function getLocalVariableCollections() {
889
+ const collections = figma.variables.getLocalVariableCollections();
890
+ return collections.map(c => ({
891
+ collectionId: c.id,
892
+ name: c.name,
893
+ modes: c.modes.map(m => ({ modeId: m.modeId, name: m.name })),
894
+ variableIds: c.variableIds
895
+ }));
896
+ }
897
+
898
+ function getLocalVariables({ collectionId } = {}) {
899
+ const variables = figma.variables.getLocalVariables();
900
+ const filtered = collectionId
901
+ ? variables.filter(v => v.variableCollectionId === collectionId)
902
+ : variables;
903
+
904
+ return filtered.map(v => {
905
+ const valuesByMode = {};
906
+ const collection = figma.variables.getVariableCollectionById(v.variableCollectionId);
907
+ if (collection) {
908
+ for (const mode of collection.modes) {
909
+ let val = v.valuesByMode[mode.modeId];
910
+ // Convert RGB back to hex for COLOR type
911
+ if (v.resolvedType === "COLOR" && val && typeof val === "object" && "r" in val) {
912
+ val = rgbToHex(val);
913
+ }
914
+ valuesByMode[mode.modeId] = val;
915
+ }
916
+ }
917
+ return {
918
+ variableId: v.id,
919
+ name: v.name,
920
+ resolvedType: v.resolvedType,
921
+ collectionId: v.variableCollectionId,
922
+ valuesByMode
923
+ };
924
+ });
925
+ }
926
+
927
+ function setVariableValue({ variableId, modeId, value }) {
928
+ const variable = figma.variables.getVariableById(variableId);
929
+ if (!variable) throw new Error("Variable not found: " + variableId);
930
+
931
+ let processedValue = value;
932
+ if (variable.resolvedType === "COLOR" && typeof value === "string") {
933
+ processedValue = hexToRGB(value);
934
+ }
935
+
936
+ variable.setValueForMode(modeId, processedValue);
937
+ return { variableId, modeId, value: processedValue };
938
+ }
939
+
940
+ function bindVariable({ nodeId, field, variableId }) {
941
+ // field examples: "fill", "stroke", "width", "height", "itemSpacing", etc.
942
+ const node = getNode(nodeId);
943
+ const variable = figma.variables.getVariableById(variableId);
944
+ if (!variable) throw new Error("Variable not found: " + variableId);
945
+
946
+ // Handle different field types
947
+ if (field === "fill" || field === "fills") {
948
+ // For fill, bind variable to the first solid paint
949
+ if (!("fills" in node)) throw new Error("Node does not support fills");
950
+ const solidPaint = figma.variables.setBoundVariableForPaint(
951
+ { type: "SOLID", color: { r: 0, g: 0, b: 0 } },
952
+ "color",
953
+ variable
954
+ );
955
+ node.fills = [solidPaint];
956
+ } else if (field === "stroke" || field === "strokes") {
957
+ if (!("strokes" in node)) throw new Error("Node does not support strokes");
958
+ const solidPaint = figma.variables.setBoundVariableForPaint(
959
+ { type: "SOLID", color: { r: 0, g: 0, b: 0 } },
960
+ "color",
961
+ variable
962
+ );
963
+ node.strokes = [solidPaint];
964
+ } else {
965
+ // For other scalar properties like width, height, itemSpacing, padding, etc.
966
+ node.setBoundVariable(field, variable);
967
+ }
968
+
969
+ return { nodeId, field, variableId };
970
+ }
971
+
972
+ function unbindVariable({ nodeId, field }) {
973
+ const node = getNode(nodeId);
974
+
975
+ if (field === "fill" || field === "fills") {
976
+ // For fills, we need to replace with an unbound solid paint
977
+ if (!("fills" in node)) throw new Error("Node does not support fills");
978
+ const currentFills = node.fills;
979
+ if (currentFills.length > 0 && currentFills[0].type === "SOLID") {
980
+ // Keep the current color but remove the variable binding
981
+ node.fills = [{ type: "SOLID", color: currentFills[0].color }];
982
+ }
983
+ } else if (field === "stroke" || field === "strokes") {
984
+ // For strokes, same approach
985
+ if (!("strokes" in node)) throw new Error("Node does not support strokes");
986
+ const currentStrokes = node.strokes;
987
+ if (currentStrokes.length > 0 && currentStrokes[0].type === "SOLID") {
988
+ node.strokes = [{ type: "SOLID", color: currentStrokes[0].color }];
989
+ }
990
+ } else {
991
+ // For scalar properties, use setBoundVariable with null
992
+ node.setBoundVariable(field, null);
993
+ }
994
+
995
+ return { nodeId, field };
996
+ }
997
+
998
+ function deleteVariable({ variableId }) {
999
+ const variable = figma.variables.getVariableById(variableId);
1000
+ if (!variable) throw new Error("Variable not found: " + variableId);
1001
+ variable.remove();
1002
+ return { deleted: variableId };
1003
+ }
1004
+
1005
+ function deleteVariableCollection({ collectionId }) {
1006
+ const collection = figma.variables.getVariableCollectionById(collectionId);
1007
+ if (!collection) throw new Error("Variable collection not found: " + collectionId);
1008
+ collection.remove();
1009
+ return { deleted: collectionId };
1010
+ }
1011
+
1012
+ // Helper to convert RGB to hex
1013
+ function rgbToHex(rgb) {
1014
+ const toHex = (n) => {
1015
+ const hex = Math.round(n * 255).toString(16);
1016
+ return hex.length === 1 ? "0" + hex : hex;
1017
+ };
1018
+ return "#" + toHex(rgb.r) + toHex(rgb.g) + toHex(rgb.b);
1019
+ }
1020
+
1021
+ // ========== STYLES ==========
1022
+
1023
+ async function createTextStyle({ name, fontFamily = "Inter", fontStyle = "Regular", fontSize = 16, lineHeight, letterSpacing, textCase, textDecoration }) {
1024
+ await figma.loadFontAsync({ family: fontFamily, style: fontStyle });
1025
+
1026
+ const style = figma.createTextStyle();
1027
+ style.name = name;
1028
+ style.fontName = { family: fontFamily, style: fontStyle };
1029
+ style.fontSize = fontSize;
1030
+
1031
+ if (lineHeight != null) {
1032
+ style.lineHeight = typeof lineHeight === "number"
1033
+ ? { unit: "PIXELS", value: lineHeight }
1034
+ : lineHeight;
1035
+ }
1036
+ if (letterSpacing != null) {
1037
+ style.letterSpacing = typeof letterSpacing === "number"
1038
+ ? { unit: "PIXELS", value: letterSpacing }
1039
+ : letterSpacing;
1040
+ }
1041
+ if (textCase) style.textCase = textCase;
1042
+ if (textDecoration) style.textDecoration = textDecoration;
1043
+
1044
+ return {
1045
+ styleId: style.id,
1046
+ name: style.name,
1047
+ fontFamily: style.fontName.family,
1048
+ fontStyle: style.fontName.style,
1049
+ fontSize: style.fontSize
1050
+ };
1051
+ }
1052
+
1053
+ function createEffectStyle({ name, effects }) {
1054
+ const style = figma.createEffectStyle();
1055
+ style.name = name;
1056
+
1057
+ // Convert effects array to Figma format
1058
+ if (effects && Array.isArray(effects)) {
1059
+ style.effects = effects.map(eff => {
1060
+ if (eff.type === "DROP_SHADOW" || eff.type === "INNER_SHADOW") {
1061
+ const rgb = eff.hex ? hexToRGB(eff.hex) : { r: 0, g: 0, b: 0 };
1062
+ return {
1063
+ type: eff.type,
1064
+ radius: eff.radius || 8,
1065
+ spread: eff.spread || 0,
1066
+ color: { r: rgb.r, g: rgb.g, b: rgb.b, a: eff.opacity || 0.25 },
1067
+ offset: { x: eff.offsetX || 0, y: eff.offsetY || 2 },
1068
+ visible: true,
1069
+ blendMode: "NORMAL"
1070
+ };
1071
+ } else if (eff.type === "LAYER_BLUR" || eff.type === "BACKGROUND_BLUR") {
1072
+ return {
1073
+ type: eff.type,
1074
+ radius: eff.radius || 8,
1075
+ visible: true
1076
+ };
1077
+ }
1078
+ return eff;
1079
+ });
1080
+ }
1081
+
1082
+ return {
1083
+ styleId: style.id,
1084
+ name: style.name,
1085
+ effectCount: style.effects.length
1086
+ };
1087
+ }
1088
+
1089
+ function getLocalTextStyles() {
1090
+ const styles = figma.getLocalTextStyles();
1091
+ return styles.map(s => ({
1092
+ styleId: s.id,
1093
+ name: s.name,
1094
+ fontFamily: s.fontName.family,
1095
+ fontStyle: s.fontName.style,
1096
+ fontSize: s.fontSize,
1097
+ lineHeight: s.lineHeight,
1098
+ letterSpacing: s.letterSpacing
1099
+ }));
1100
+ }
1101
+
1102
+ function getLocalEffectStyles() {
1103
+ const styles = figma.getLocalEffectStyles();
1104
+ return styles.map(s => ({
1105
+ styleId: s.id,
1106
+ name: s.name,
1107
+ effects: s.effects.map(e => ({
1108
+ type: e.type,
1109
+ radius: e.radius,
1110
+ spread: "spread" in e ? e.spread : undefined,
1111
+ offsetX: "offset" in e ? e.offset.x : undefined,
1112
+ offsetY: "offset" in e ? e.offset.y : undefined,
1113
+ opacity: "color" in e ? e.color.a : undefined,
1114
+ hex: "color" in e ? rgbToHex(e.color) : undefined
1115
+ }))
1116
+ }));
1117
+ }
1118
+
1119
+ async function applyTextStyle({ nodeId, styleId }) {
1120
+ const node = getNode(nodeId);
1121
+ if (node.type !== "TEXT") throw new Error("Not a text node");
1122
+
1123
+ const style = figma.getStyleById(styleId);
1124
+ if (!style || style.type !== "TEXT") throw new Error("Text style not found: " + styleId);
1125
+
1126
+ // Load font before applying
1127
+ await figma.loadFontAsync(style.fontName);
1128
+ node.textStyleId = styleId;
1129
+
1130
+ return { nodeId, styleId };
1131
+ }
1132
+
1133
+ function applyEffectStyle({ nodeId, styleId }) {
1134
+ const node = getNode(nodeId);
1135
+ if (!("effectStyleId" in node)) throw new Error("Node does not support effect styles");
1136
+
1137
+ const style = figma.getStyleById(styleId);
1138
+ if (!style || style.type !== "EFFECT") throw new Error("Effect style not found: " + styleId);
1139
+
1140
+ node.effectStyleId = styleId;
1141
+ return { nodeId, styleId };
1142
+ }
1143
+
1144
+ async function updateTextStyle({ styleId, name, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing }) {
1145
+ const style = figma.getStyleById(styleId);
1146
+ if (!style || style.type !== "TEXT") throw new Error("Text style not found: " + styleId);
1147
+
1148
+ if (name) style.name = name;
1149
+ if (fontFamily || fontStyle) {
1150
+ const fam = fontFamily || style.fontName.family;
1151
+ const sty = fontStyle || style.fontName.style;
1152
+ await figma.loadFontAsync({ family: fam, style: sty });
1153
+ style.fontName = { family: fam, style: sty };
1154
+ }
1155
+ if (fontSize != null) style.fontSize = fontSize;
1156
+ if (lineHeight != null) {
1157
+ style.lineHeight = typeof lineHeight === "number"
1158
+ ? { unit: "PIXELS", value: lineHeight }
1159
+ : lineHeight;
1160
+ }
1161
+ if (letterSpacing != null) {
1162
+ style.letterSpacing = typeof letterSpacing === "number"
1163
+ ? { unit: "PIXELS", value: letterSpacing }
1164
+ : letterSpacing;
1165
+ }
1166
+
1167
+ return { styleId, name: style.name };
1168
+ }
1169
+
1170
+ function updateEffectStyle({ styleId, name, effects }) {
1171
+ const style = figma.getStyleById(styleId);
1172
+ if (!style || style.type !== "EFFECT") throw new Error("Effect style not found: " + styleId);
1173
+
1174
+ if (name) style.name = name;
1175
+ if (effects && Array.isArray(effects)) {
1176
+ style.effects = effects.map(eff => {
1177
+ if (eff.type === "DROP_SHADOW" || eff.type === "INNER_SHADOW") {
1178
+ const rgb = eff.hex ? hexToRGB(eff.hex) : { r: 0, g: 0, b: 0 };
1179
+ return {
1180
+ type: eff.type,
1181
+ radius: eff.radius || 8,
1182
+ spread: eff.spread || 0,
1183
+ color: { r: rgb.r, g: rgb.g, b: rgb.b, a: eff.opacity || 0.25 },
1184
+ offset: { x: eff.offsetX || 0, y: eff.offsetY || 2 },
1185
+ visible: true,
1186
+ blendMode: "NORMAL"
1187
+ };
1188
+ } else if (eff.type === "LAYER_BLUR" || eff.type === "BACKGROUND_BLUR") {
1189
+ return {
1190
+ type: eff.type,
1191
+ radius: eff.radius || 8,
1192
+ visible: true
1193
+ };
1194
+ }
1195
+ return eff;
1196
+ });
1197
+ }
1198
+
1199
+ return { styleId, name: style.name, effectCount: style.effects.length };
1200
+ }
1201
+
1202
+ function deleteStyle({ styleId }) {
1203
+ const style = figma.getStyleById(styleId);
1204
+ if (!style) throw new Error("Style not found: " + styleId);
1205
+ style.remove();
1206
+ return { deleted: styleId };
1207
+ }
1208
+
1209
+ // ========== ENHANCED COMPONENTS ==========
1210
+
1211
+ function createComponentFromNode({ nodeId, name }) {
1212
+ const node = getNode(nodeId);
1213
+ if (!("type" in node)) throw new Error("Invalid node");
1214
+
1215
+ // Create component and move node's children/properties into it
1216
+ const component = figma.createComponentFromNode(node);
1217
+ if (name) component.name = name;
1218
+
1219
+ return {
1220
+ componentId: component.id,
1221
+ name: component.name,
1222
+ type: component.type
1223
+ };
1224
+ }
1225
+
1226
+ function createComponentSet({ componentIds, name = "Component Set" }) {
1227
+ // Get all components
1228
+ const components = componentIds.map(id => {
1229
+ const node = getNode(id);
1230
+ if (node.type !== "COMPONENT") throw new Error("Node is not a component: " + id);
1231
+ return node;
1232
+ });
1233
+
1234
+ if (components.length < 1) throw new Error("Need at least 1 component");
1235
+
1236
+ // Combine into component set
1237
+ const componentSet = figma.combineAsVariants(components, page());
1238
+ componentSet.name = name;
1239
+
1240
+ return {
1241
+ componentSetId: componentSet.id,
1242
+ name: componentSet.name,
1243
+ type: componentSet.type,
1244
+ variantCount: componentSet.children.length
1245
+ };
1246
+ }
1247
+
1248
+ function addComponentProperty({ componentId, propertyName, propertyType, defaultValue, preferredValues }) {
1249
+ const component = getNode(componentId);
1250
+ if (component.type !== "COMPONENT" && component.type !== "COMPONENT_SET") {
1251
+ throw new Error("Node is not a component or component set");
1252
+ }
1253
+
1254
+ // propertyType: "BOOLEAN" | "TEXT" | "INSTANCE_SWAP" | "VARIANT"
1255
+ const propDef = {
1256
+ type: propertyType,
1257
+ defaultValue: defaultValue
1258
+ };
1259
+
1260
+ if (preferredValues) {
1261
+ propDef.preferredValues = preferredValues;
1262
+ }
1263
+
1264
+ component.addComponentProperty(propertyName, propDef.type, propDef.defaultValue);
1265
+
1266
+ return {
1267
+ componentId,
1268
+ propertyName,
1269
+ propertyType
1270
+ };
1271
+ }
1272
+
1273
+ function setInstanceProperty({ instanceId, propertyName, value }) {
1274
+ const instance = getNode(instanceId);
1275
+ if (instance.type !== "INSTANCE") throw new Error("Node is not an instance");
1276
+
1277
+ // Get component properties
1278
+ const props = instance.componentProperties;
1279
+ if (!props || !(propertyName in props)) {
1280
+ throw new Error("Property not found: " + propertyName);
1281
+ }
1282
+
1283
+ instance.setProperties({ [propertyName]: value });
1284
+
1285
+ return { instanceId, propertyName, value };
1286
+ }
1287
+
1288
+ function getComponentProperties({ componentId }) {
1289
+ const component = getNode(componentId);
1290
+ if (component.type !== "COMPONENT" && component.type !== "COMPONENT_SET" && component.type !== "INSTANCE") {
1291
+ throw new Error("Node is not a component, component set, or instance");
1292
+ }
1293
+
1294
+ const definitions = component.componentPropertyDefinitions || {};
1295
+ const values = component.componentProperties || {};
1296
+
1297
+ return {
1298
+ componentId,
1299
+ definitions: Object.entries(definitions).map(([name, def]) => ({
1300
+ name,
1301
+ type: def.type,
1302
+ defaultValue: def.defaultValue,
1303
+ variantOptions: def.variantOptions
1304
+ })),
1305
+ values: Object.entries(values).map(([name, val]) => ({
1306
+ name,
1307
+ value: val.value,
1308
+ type: val.type
1309
+ }))
1310
+ };
1311
+ }