figma-local 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,4198 @@
1
+ /**
2
+ * Figma CDP Client
3
+ *
4
+ * Connects directly to Figma via Chrome DevTools Protocol.
5
+ * No external dependencies required.
6
+ */
7
+
8
+ import WebSocket from 'ws';
9
+ import { getCdpPort } from './figma-patch.js';
10
+
11
+ export class FigmaClient {
12
+ constructor() {
13
+ this.ws = null;
14
+ this.msgId = 0;
15
+ this.callbacks = new Map();
16
+ this.pageTitle = null;
17
+ this.pageUrl = null;
18
+ this.fileType = null; // 'design', 'file', or 'unknown'
19
+ this.executionContextId = null; // For Figma v39+ sandboxed context
20
+ }
21
+
22
+ /**
23
+ * List all available Figma pages
24
+ */
25
+ static async listPages() {
26
+ const port = getCdpPort();
27
+ const response = await fetch(`http://localhost:${port}/json`);
28
+ const pages = await response.json();
29
+ return pages
30
+ .filter(p => p.url && p.url.includes('figma.com'))
31
+ .map(p => ({ title: p.title, id: p.id, url: p.url, wsUrl: p.webSocketDebuggerUrl }));
32
+ }
33
+
34
+ /**
35
+ * Check if Figma is running with debug port
36
+ */
37
+ static async isConnected() {
38
+ try {
39
+ const port = getCdpPort();
40
+ const response = await fetch(`http://localhost:${port}/json`);
41
+ const pages = await response.json();
42
+ return pages.some(p => p.url && p.url.includes('figma.com'));
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Connect to a Figma design file
50
+ */
51
+ async connect(pageTitle = null) {
52
+ const port = getCdpPort();
53
+ const response = await fetch(`http://localhost:${port}/json`);
54
+ const pages = await response.json();
55
+
56
+ // Find design/file pages (not feed, home, etc.)
57
+ // Use regex with trailing slash to avoid matching /files/ (feed/home pages)
58
+ const isDesignPage = (p) =>
59
+ p.url && /figma\.com\/(design|file)\//.test(p.url);
60
+
61
+ let page;
62
+ if (pageTitle) {
63
+ page = pages.find(p => p.title.includes(pageTitle) && isDesignPage(p));
64
+ } else {
65
+ page = pages.find(isDesignPage);
66
+ }
67
+
68
+ if (!page) {
69
+ throw new Error('No Figma design file open. Please open a design file in Figma Desktop.');
70
+ }
71
+
72
+ this.pageTitle = page.title;
73
+ this.pageUrl = page.url;
74
+
75
+ // Detect file type from URL
76
+ const typeMatch = page.url.match(/figma\.com\/(design|file)\//);
77
+ this.fileType = typeMatch ? typeMatch[1] : 'unknown';
78
+
79
+ return new Promise((resolve, reject) => {
80
+ this.ws = new WebSocket(page.webSocketDebuggerUrl);
81
+ const executionContexts = [];
82
+
83
+ this.ws.on('open', async () => {
84
+ try {
85
+ // Enable Runtime to discover execution contexts (needed for Figma v39+)
86
+ await this.send('Runtime.enable');
87
+
88
+ // Give time for context events to arrive
89
+ await new Promise(r => setTimeout(r, 500));
90
+
91
+ // First try default context (works on older Figma versions)
92
+ const defaultCheck = await this.send('Runtime.evaluate', {
93
+ expression: 'typeof figma !== "undefined"',
94
+ returnByValue: true
95
+ });
96
+
97
+ if (defaultCheck.result?.result?.value === true) {
98
+ // figma is in default context (older Figma)
99
+ this.executionContextId = null;
100
+ resolve(this);
101
+ return;
102
+ }
103
+
104
+ // Figma v39+: search all execution contexts for figma
105
+ for (const ctx of executionContexts) {
106
+ try {
107
+ const check = await this.send('Runtime.evaluate', {
108
+ expression: 'typeof figma !== "undefined"',
109
+ contextId: ctx.id,
110
+ returnByValue: true
111
+ });
112
+
113
+ if (check.result?.result?.value === true) {
114
+ this.executionContextId = ctx.id;
115
+ resolve(this);
116
+ return;
117
+ }
118
+ } catch {
119
+ // Context may have been destroyed, skip
120
+ }
121
+ }
122
+
123
+ reject(new Error('Could not find Figma execution context. Make sure a design file is open.'));
124
+ } catch (err) {
125
+ reject(err);
126
+ }
127
+ });
128
+
129
+ this.ws.on('message', (data) => {
130
+ const msg = JSON.parse(data);
131
+
132
+ // Collect execution contexts as they're created
133
+ if (msg.method === 'Runtime.executionContextCreated') {
134
+ executionContexts.push(msg.params.context);
135
+ }
136
+
137
+ if (msg.id && this.callbacks.has(msg.id)) {
138
+ this.callbacks.get(msg.id)(msg);
139
+ this.callbacks.delete(msg.id);
140
+ }
141
+ });
142
+
143
+ this.ws.on('error', reject);
144
+
145
+ setTimeout(() => reject(new Error('Connection timeout')), 15000);
146
+ });
147
+ }
148
+
149
+ send(method, params = {}) {
150
+ return new Promise((resolve) => {
151
+ const id = ++this.msgId;
152
+ this.callbacks.set(id, resolve);
153
+ this.ws.send(JSON.stringify({ id, method, params }));
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Evaluate JavaScript in the Figma context
159
+ */
160
+ async eval(expression) {
161
+ if (!this.ws) {
162
+ throw new Error('Not connected to Figma');
163
+ }
164
+
165
+ const params = {
166
+ expression,
167
+ returnByValue: true,
168
+ awaitPromise: true
169
+ };
170
+
171
+ // Use specific execution context if found (Figma v39+)
172
+ if (this.executionContextId) {
173
+ params.contextId = this.executionContextId;
174
+ }
175
+
176
+ const result = await this.send('Runtime.evaluate', params);
177
+
178
+ if (result.result?.exceptionDetails) {
179
+ const error = result.result.exceptionDetails;
180
+ // Get the actual error message - Figma puts detailed errors in exception.value
181
+ const errorValue = error.exception?.value || error.exception?.description || error.text || 'Evaluation error';
182
+ throw new Error(errorValue);
183
+ }
184
+
185
+ return result.result?.result?.value;
186
+ }
187
+
188
+ /**
189
+ * Get current page info
190
+ */
191
+ async getPageInfo() {
192
+ return await this.eval(`
193
+ (function() {
194
+ return {
195
+ name: figma.currentPage.name,
196
+ id: figma.currentPage.id,
197
+ childCount: figma.currentPage.children.length,
198
+ fileKey: figma.fileKey
199
+ };
200
+ })()
201
+ `);
202
+ }
203
+
204
+ /**
205
+ * Get canvas bounds (for smart positioning)
206
+ */
207
+ async getCanvasBounds() {
208
+ return await this.eval(`
209
+ (function() {
210
+ const children = figma.currentPage.children;
211
+ if (children.length === 0) return { minX: 0, minY: 0, maxX: 0, maxY: 0, isEmpty: true };
212
+
213
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
214
+ children.forEach(n => {
215
+ if (n.x < minX) minX = n.x;
216
+ if (n.y < minY) minY = n.y;
217
+ if (n.x + n.width > maxX) maxX = n.x + n.width;
218
+ if (n.y + n.height > maxY) maxY = n.y + n.height;
219
+ });
220
+ return { minX, minY, maxX, maxY, isEmpty: false };
221
+ })()
222
+ `);
223
+ }
224
+
225
+ /**
226
+ * List all nodes on current page
227
+ */
228
+ async listNodes(limit = 50) {
229
+ return await this.eval(`
230
+ figma.currentPage.children.slice(0, ${limit}).map(function(n) {
231
+ return {
232
+ id: n.id,
233
+ type: n.type,
234
+ name: n.name || '',
235
+ x: Math.round(n.x),
236
+ y: Math.round(n.y),
237
+ width: Math.round(n.width),
238
+ height: Math.round(n.height)
239
+ };
240
+ })
241
+ `);
242
+ }
243
+
244
+ /**
245
+ * Get all local variables
246
+ */
247
+ async getVariables(type = null) {
248
+ const typeFilter = type ? `'${type}'` : 'null';
249
+ return await this.eval(`
250
+ (function() {
251
+ const vars = figma.variables.getLocalVariables(${typeFilter});
252
+ return vars.map(v => ({
253
+ id: v.id,
254
+ name: v.name,
255
+ resolvedType: v.resolvedType
256
+ }));
257
+ })()
258
+ `);
259
+ }
260
+
261
+ /**
262
+ * Get all variable collections
263
+ */
264
+ async getCollections() {
265
+ return await this.eval(`
266
+ (function() {
267
+ const cols = figma.variables.getLocalVariableCollections();
268
+ return cols.map(c => ({
269
+ id: c.id,
270
+ name: c.name,
271
+ modes: c.modes,
272
+ variableIds: c.variableIds
273
+ }));
274
+ })()
275
+ `);
276
+ }
277
+
278
+ /**
279
+ * Render JSX-like syntax to Figma
280
+ */
281
+ async render(jsx) {
282
+ // Parse JSX and generate Figma code (async for icon fetching)
283
+ const code = await this.parseJSX(jsx);
284
+ return await this.eval(code);
285
+ }
286
+
287
+ /**
288
+ * Parse multiple JSX strings into a SINGLE eval call (10x faster)
289
+ * Returns code that creates all frames and returns array of { id, name }
290
+ */
291
+ parseJSXBatch(jsxArray, options = {}) {
292
+ const gap = options.gap || 40;
293
+ const vertical = options.vertical || false;
294
+
295
+ // Parse each JSX to get props and children
296
+ const parsed = jsxArray.map(jsx => {
297
+ const openMatch = jsx.match(/<Frame\s+([^>]*)>/);
298
+ if (!openMatch) throw new Error('Invalid JSX: must start with <Frame>');
299
+ const propsStr = openMatch[1];
300
+ const startIdx = openMatch.index + openMatch[0].length;
301
+ const children = this.extractContent(jsx.slice(startIdx), 'Frame');
302
+ const props = this.parseProps(propsStr);
303
+ const childElements = this.parseChildren(children);
304
+ return { props, children: childElements };
305
+ });
306
+
307
+ // Collect all fonts needed
308
+ const allFonts = new Set();
309
+ let anyUsesVars = false;
310
+
311
+ parsed.forEach(({ props, children }) => {
312
+ const bg = props.bg || props.fill || '#ffffff';
313
+ const stroke = props.stroke || null;
314
+ if (this.isVarRef(bg)) anyUsesVars = true;
315
+ if (stroke && this.isVarRef(stroke)) anyUsesVars = true;
316
+
317
+ const collectFonts = (items) => {
318
+ items.forEach(item => {
319
+ if (item._type === 'text') {
320
+ const weight = item.weight || 'regular';
321
+ const style = weight === 'bold' ? 'Bold' : weight === 'medium' ? 'Medium' : weight === 'semibold' ? 'Semi Bold' : 'Regular';
322
+ allFonts.add(style);
323
+ if (item.color && this.isVarRef(item.color)) anyUsesVars = true;
324
+ } else if (item._type === 'frame') {
325
+ if (item.bg && this.isVarRef(item.bg)) anyUsesVars = true;
326
+ if (item.stroke && this.isVarRef(item.stroke)) anyUsesVars = true;
327
+ if (item._children) collectFonts(item._children);
328
+ }
329
+ });
330
+ };
331
+ collectFonts(children);
332
+ });
333
+
334
+ // Font caching: only load fonts not yet loaded in this session
335
+ const fontStyles = Array.from(allFonts);
336
+ const fontLoads = fontStyles.length > 0
337
+ ? `
338
+ if (!globalThis.__loadedFonts) globalThis.__loadedFonts = new Set();
339
+ const fontsToLoad = ${JSON.stringify(fontStyles)}.filter(s => !globalThis.__loadedFonts.has(s));
340
+ if (fontsToLoad.length > 0) {
341
+ await Promise.all(fontsToLoad.map(s => figma.loadFontAsync({family:'Inter',style:s})));
342
+ fontsToLoad.forEach(s => globalThis.__loadedFonts.add(s));
343
+ }
344
+ `
345
+ : `
346
+ if (!globalThis.__loadedFonts) globalThis.__loadedFonts = new Set();
347
+ if (!globalThis.__loadedFonts.has('Regular')) {
348
+ await figma.loadFontAsync({family:'Inter',style:'Regular'});
349
+ globalThis.__loadedFonts.add('Regular');
350
+ }
351
+ `;
352
+
353
+ // Variable caching: reuse loaded vars across calls
354
+ const varLoadCode = anyUsesVars ? `
355
+ if (!globalThis.__varsCache || Date.now() - (globalThis.__varsCacheTime || 0) > 30000) {
356
+ const collections = await figma.variables.getLocalVariableCollectionsAsync();
357
+ globalThis.__varsCache = {};
358
+ for (const col of collections) {
359
+ if (!col.name.startsWith('shadcn')) continue;
360
+ for (const id of col.variableIds) {
361
+ const v = await figma.variables.getVariableByIdAsync(id);
362
+ if (v) globalThis.__varsCache[v.name] = v;
363
+ }
364
+ }
365
+ globalThis.__varsCacheTime = Date.now();
366
+ }
367
+ const vars = globalThis.__varsCache;
368
+ const boundFill = (variable) => figma.variables.setBoundVariableForPaint(
369
+ { type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }, 'color', variable
370
+ );
371
+ ` : '';
372
+
373
+ // Generate code for each frame
374
+ const framesCodes = parsed.map(({ props, children }, frameIdx) => {
375
+ const name = props.name || 'Frame';
376
+ const hasExplicitWidth = props.w !== undefined || props.width !== undefined;
377
+ const width = props.w || props.width || 320;
378
+ const hasExplicitHeight = props.h !== undefined || props.height !== undefined;
379
+ const height = props.h || props.height || 200;
380
+ const bg = props.bg || props.fill || '#ffffff';
381
+ const stroke = props.stroke || null;
382
+ const rounded = props.rounded || props.radius || 0;
383
+ const flex = props.flex || 'col';
384
+ const itemGap = props.gap || 0;
385
+ const p = props.p || props.padding || 0;
386
+ const px = props.px || p;
387
+ const py = props.py || p;
388
+ const align = props.items || props.align || 'MIN';
389
+ const justify = props.justify || 'MIN';
390
+ const wrap = props.wrap === true || props.wrap === 'true';
391
+ const wrapGap = Number(props.wrapGap || props.counterAxisSpacing || 0);
392
+ const hug = props.hug || '';
393
+ const hugWidth = hug === 'both' || hug === 'w' || hug === 'width';
394
+ const hugHeight = hug === 'both' || hug === 'h' || hug === 'height';
395
+ const clip = props.clip === 'true' || props.clip === true;
396
+
397
+ const alignMap = { start: 'MIN', center: 'CENTER', end: 'MAX', stretch: 'STRETCH' };
398
+ const alignVal = alignMap[align] || 'MIN';
399
+ const justifyVal = alignMap[justify] || 'MIN';
400
+
401
+ const fillCode = this.generateFillCode(bg, `f${frameIdx}`);
402
+ const strokeCode = stroke ? this.generateStrokeCode(stroke, `f${frameIdx}`) : { code: '' };
403
+
404
+ // Generate child code
405
+ let childCounter = 0;
406
+ const generateChildCode = (items, parentVar, parentFlex) => {
407
+ return items.map(item => {
408
+ const idx = `${frameIdx}_${childCounter++}`;
409
+ if (item._type === 'text') {
410
+ const weight = item.weight || 'regular';
411
+ const style = weight === 'bold' ? 'Bold' : weight === 'medium' ? 'Medium' : weight === 'semibold' ? 'Semi Bold' : 'Regular';
412
+ const size = item.size || 14;
413
+ const color = item.color || '#000000';
414
+ const fillWidth = item.w === 'fill';
415
+ const textAlign = item.align || 'left';
416
+ const textAlignVal = textAlign === 'center' ? 'CENTER' : textAlign === 'right' ? 'RIGHT' : 'LEFT';
417
+ const textFillCode = this.generateFillCode(color, `el${idx}`);
418
+ return `
419
+ const el${idx} = figma.createText();
420
+ el${idx}.fontName = {family:'Inter',style:'${style}'};
421
+ el${idx}.characters = ${JSON.stringify(item.content || '')};
422
+ el${idx}.fontSize = ${size};
423
+ ${textFillCode.code}
424
+ el${idx}.textAlignHorizontal = '${textAlignVal}';
425
+ ${parentVar}.appendChild(el${idx});
426
+ ${fillWidth ? `el${idx}.layoutSizingHorizontal = 'FILL';` : ''}`;
427
+ } else if (item._type === 'frame') {
428
+ const fName = item.name || 'Frame';
429
+ const fillW = item.w === 'fill';
430
+ const fillH = item.h === 'fill';
431
+ const hasExplicitW = (item.w !== undefined || item.width !== undefined) && !fillW;
432
+ const hasExplicitH = (item.h !== undefined || item.height !== undefined) && !fillH;
433
+ const fWidth = hasExplicitW ? (item.w || item.width) : 100;
434
+ const fHeight = hasExplicitH ? (item.h || item.height) : 40;
435
+ // Frames without explicit bg should be transparent (not white)
436
+ const fBg = item.bg || item.fill || null;
437
+ const fStroke = item.stroke || null;
438
+ const fRounded = item.rounded || item.radius || 0;
439
+ const fFlex = item.flex || 'row'; // Default to row (always auto-layout like single render)
440
+ const fGap = item.gap || 0;
441
+ const fP = item.p !== undefined ? item.p : 0;
442
+ const fPx = item.px !== undefined ? item.px : fP;
443
+ const fPy = item.py !== undefined ? item.py : fP;
444
+ const fPt = item.pt !== undefined ? Number(item.pt) : Number(fPy);
445
+ const fPr = item.pr !== undefined ? Number(item.pr) : Number(fPx);
446
+ const fPb = item.pb !== undefined ? Number(item.pb) : Number(fPy);
447
+ const fPl = item.pl !== undefined ? Number(item.pl) : Number(fPx);
448
+ const fGrow = item.grow || 0;
449
+ const fJustify = item.justify || 'center';
450
+ const fItems = item.items || 'center';
451
+ const justifyMap = { start: 'MIN', center: 'CENTER', end: 'MAX', between: 'SPACE_BETWEEN' };
452
+ const fJustifyVal = justifyMap[fJustify] || 'CENTER';
453
+ const fItemsVal = alignMap[fItems] || 'CENTER';
454
+ const fFillCode = fBg ? this.generateFillCode(fBg, `el${idx}`) : { code: `el${idx}.fills = [];`, usesVars: false };
455
+ const fStrokeCode = fStroke ? this.generateStrokeCode(fStroke, `el${idx}`) : { code: '' };
456
+ const nestedChildren = item._children ? generateChildCode(item._children, `el${idx}`, fFlex) : '';
457
+ return `
458
+ const el${idx} = figma.createFrame();
459
+ el${idx}.name = ${JSON.stringify(fName)};
460
+ el${idx}.layoutMode = '${fFlex === 'row' ? 'HORIZONTAL' : 'VERTICAL'}';
461
+ el${idx}.primaryAxisSizingMode = '${hasExplicitW ? 'FIXED' : 'AUTO'}';
462
+ el${idx}.counterAxisSizingMode = '${hasExplicitH ? 'FIXED' : 'AUTO'}';
463
+ ${hasExplicitW || hasExplicitH ? `el${idx}.resize(${fWidth}, ${fHeight});` : ''}
464
+ el${idx}.cornerRadius = ${fRounded};
465
+ ${fFillCode.code}
466
+ ${fStrokeCode.code}
467
+ el${idx}.itemSpacing = ${fGap};
468
+ el${idx}.paddingTop = ${fPt};
469
+ el${idx}.paddingBottom = ${fPb};
470
+ el${idx}.paddingLeft = ${fPl};
471
+ el${idx}.paddingRight = ${fPr};
472
+ el${idx}.primaryAxisAlignItems = '${fJustifyVal}';
473
+ el${idx}.counterAxisAlignItems = '${fItemsVal}';
474
+ ${parentVar}.appendChild(el${idx});
475
+ ${fillW ? `el${idx}.layoutSizingHorizontal = 'FILL';` : ''}
476
+ ${fillH ? `el${idx}.layoutSizingVertical = 'FILL';` : ''}
477
+ ${fGrow && parentFlex === 'row' ? `el${idx}.layoutSizingHorizontal = 'FILL';` : ''}
478
+ ${fGrow && parentFlex === 'col' ? `el${idx}.layoutSizingVertical = 'FILL';` : ''}
479
+ ${nestedChildren}`;
480
+ }
481
+ return '';
482
+ }).join('\n');
483
+ };
484
+
485
+ const childCode = generateChildCode(children, `f${frameIdx}`, flex);
486
+
487
+ return `
488
+ const f${frameIdx} = figma.createFrame();
489
+ f${frameIdx}.name = ${JSON.stringify(name)};
490
+ f${frameIdx}.resize(${width}, ${height});
491
+ f${frameIdx}.x = posX;
492
+ f${frameIdx}.y = posY;
493
+ f${frameIdx}.cornerRadius = ${rounded};
494
+ ${fillCode.code}
495
+ ${strokeCode.code}
496
+ f${frameIdx}.layoutMode = '${flex === 'row' ? 'HORIZONTAL' : 'VERTICAL'}';
497
+ ${wrap && flex === 'row' ? `f${frameIdx}.layoutWrap = 'WRAP';` : ''}
498
+ f${frameIdx}.itemSpacing = ${itemGap};
499
+ f${frameIdx}.paddingTop = f${frameIdx}.paddingBottom = ${py};
500
+ f${frameIdx}.paddingLeft = f${frameIdx}.paddingRight = ${px};
501
+ f${frameIdx}.primaryAxisAlignItems = '${justifyVal}';
502
+ f${frameIdx}.counterAxisAlignItems = '${alignVal}';
503
+ f${frameIdx}.primaryAxisSizingMode = '${flex === 'col' ? (hugHeight || !hasExplicitHeight ? 'AUTO' : 'FIXED') : (hugWidth || !hasExplicitWidth ? 'AUTO' : 'FIXED')}';
504
+ f${frameIdx}.counterAxisSizingMode = '${flex === 'col' ? (hugWidth || !hasExplicitWidth ? 'AUTO' : 'FIXED') : (hugHeight || !hasExplicitHeight ? 'AUTO' : 'FIXED')}';
505
+ ${wrap && flex === 'row' && wrapGap > 0 ? `f${frameIdx}.counterAxisSpacing = ${wrapGap};` : ''}
506
+ f${frameIdx}.clipsContent = ${clip};
507
+ ${childCode}
508
+ results.push({ id: f${frameIdx}.id, name: f${frameIdx}.name, width: f${frameIdx}.width, height: f${frameIdx}.height });
509
+ ${vertical ? `posY += f${frameIdx}.height + ${gap};` : `posX += f${frameIdx}.width + ${gap};`}
510
+ `;
511
+ }).join('\n');
512
+
513
+ return `
514
+ (async function() {
515
+ ${fontLoads}
516
+ ${varLoadCode}
517
+
518
+ // Calculate start position
519
+ let posX = 0, posY = 100;
520
+ const children = figma.currentPage.children;
521
+ if (children.length > 0) {
522
+ let maxRight = 0;
523
+ children.forEach(n => {
524
+ const right = n.x + (n.width || 0);
525
+ if (right > maxRight) maxRight = right;
526
+ });
527
+ posX = Math.round(maxRight + 100);
528
+ }
529
+
530
+ const results = [];
531
+ ${framesCodes}
532
+ return results;
533
+ })()
534
+ `;
535
+ }
536
+
537
+ /**
538
+ * Parse JSX-like syntax to Figma Plugin API code
539
+ */
540
+ async parseJSX(jsx) {
541
+ // Find opening Frame tag
542
+ const openMatch = jsx.match(/<Frame\s+([^>]*)>/);
543
+ if (!openMatch) {
544
+ throw new Error('Invalid JSX: must start with <Frame>');
545
+ }
546
+
547
+ const propsStr = openMatch[1];
548
+ const startIdx = openMatch.index + openMatch[0].length;
549
+
550
+ // Find matching closing tag by counting open/close tags
551
+ const children = this.extractContent(jsx.slice(startIdx), 'Frame');
552
+
553
+ // Parse props
554
+ const props = this.parseProps(propsStr);
555
+
556
+ // Parse children
557
+ const childElements = this.parseChildren(children);
558
+
559
+ // Warn if children content exists but nothing was parsed
560
+ const trimmedChildren = children.trim();
561
+ if (trimmedChildren && childElements.length === 0) {
562
+ console.warn('[render] Warning: Frame has content but no elements were parsed.');
563
+ console.warn('[render] Content:', trimmedChildren.slice(0, 200) + (trimmedChildren.length > 200 ? '...' : ''));
564
+ console.warn('[render] Supported elements: <Frame>, <Text>, <Rectangle>, <Rect>, <Image>, <Icon>');
565
+ }
566
+
567
+ // Pre-fetch any icon SVGs before code generation
568
+ const iconSvgMap = await this.prefetchIconSvgs(childElements);
569
+
570
+ // Generate code
571
+ return this.generateCode(props, childElements, iconSvgMap);
572
+ }
573
+
574
+ /**
575
+ * Extract content between matching open/close tags
576
+ */
577
+ extractContent(str, tagName) {
578
+ let depth = 1;
579
+ let i = 0;
580
+ const closeTag = `</${tagName}>`;
581
+
582
+ while (i < str.length && depth > 0) {
583
+ const remaining = str.slice(i);
584
+
585
+ if (remaining.startsWith(closeTag)) {
586
+ depth--;
587
+ if (depth === 0) {
588
+ return str.slice(0, i);
589
+ }
590
+ i += closeTag.length;
591
+ } else if (remaining.startsWith(`<${tagName} `) || remaining.startsWith(`<${tagName}>`)) {
592
+ // Check if this is a self-closing tag (e.g. <Frame ... />)
593
+ const selfCloseCheck = remaining.match(new RegExp(`^<${tagName}(?:\\s[^>]*?)?\\s*\\/>`));
594
+ if (selfCloseCheck) {
595
+ // Self-closing: skip entirely, don't change depth
596
+ i += selfCloseCheck[0].length;
597
+ } else {
598
+ depth++;
599
+ i++;
600
+ }
601
+ } else {
602
+ i++;
603
+ }
604
+ }
605
+
606
+ return str;
607
+ }
608
+
609
+ /**
610
+ * Collect all icon names from parsed children tree
611
+ */
612
+ collectIconNames(items) {
613
+ const names = new Set();
614
+ for (const item of items) {
615
+ if (item._type === 'icon' && item.name && item.name.includes(':')) {
616
+ names.add(item.name);
617
+ }
618
+ if (item._children) {
619
+ for (const n of this.collectIconNames(item._children)) {
620
+ names.add(n);
621
+ }
622
+ }
623
+ }
624
+ return names;
625
+ }
626
+
627
+ /**
628
+ * Pre-fetch SVGs for all icons in the tree from Iconify API
629
+ * Returns map: { "lucide:chevron-left": "<svg...>" }
630
+ */
631
+ async prefetchIconSvgs(children) {
632
+ const iconNames = this.collectIconNames(children);
633
+ if (iconNames.size === 0) return {};
634
+
635
+ const svgMap = {};
636
+ const fetches = [...iconNames].map(async (iconName) => {
637
+ try {
638
+ const [prefix, name] = iconName.split(':');
639
+ const response = await fetch(`https://api.iconify.design/${prefix}/${name}.svg?width=24&height=24`);
640
+ if (response.ok) {
641
+ svgMap[iconName] = await response.text();
642
+ }
643
+ } catch (e) {
644
+ // Silently fall back to placeholder
645
+ }
646
+ });
647
+ await Promise.all(fetches);
648
+ return svgMap;
649
+ }
650
+
651
+ parseProps(propsStr) {
652
+ const props = {};
653
+
654
+ // Match name="value" or name={value}
655
+ const regex = /(\w+)=(?:"([^"]*)"|{([^}]*)})/g;
656
+ let match;
657
+
658
+ while ((match = regex.exec(propsStr)) !== null) {
659
+ const key = match[1];
660
+ const value = match[2] !== undefined ? match[2] : match[3];
661
+ props[key] = value;
662
+ }
663
+
664
+ return props;
665
+ }
666
+
667
+ parseChildren(childrenStr) {
668
+ const children = [];
669
+ const frameRanges = [];
670
+
671
+ // First: find all open/close Frame elements (recursive, handles nesting)
672
+ const frameOpenRegex = /<Frame(?:\s+([^>]*?))?>/g;
673
+ let match;
674
+
675
+ while ((match = frameOpenRegex.exec(childrenStr)) !== null) {
676
+ // Skip self-closing frames (regex matches /> because > is part of />)
677
+ if (match[0].endsWith('/>')) continue;
678
+
679
+ const frameProps = this.parseProps(match[1] || '');
680
+ frameProps._type = 'frame';
681
+ frameProps._index = match.index;
682
+
683
+ // Get content between opening and matching closing tag
684
+ const afterOpen = childrenStr.slice(match.index + match[0].length);
685
+ const innerContent = this.extractContent(afterOpen, 'Frame');
686
+
687
+ // Calculate full frame length
688
+ const fullLength = match[0].length + innerContent.length + '</Frame>'.length;
689
+
690
+ // Recursively parse children of nested frame
691
+ frameProps._children = this.parseChildren(innerContent);
692
+ children.push(frameProps);
693
+
694
+ // Mark this range as consumed
695
+ frameRanges.push({ start: match.index, end: match.index + fullLength });
696
+
697
+ // Move regex past this frame to avoid re-matching nested frames
698
+ frameOpenRegex.lastIndex = match.index + fullLength;
699
+ }
700
+
701
+ // Then: parse self-closing Frame elements NOT inside open/close frames
702
+ const frameSelfCloseRegex = /<Frame(?:\s+([^>]*?))?\s*\/>/g;
703
+
704
+ while ((match = frameSelfCloseRegex.exec(childrenStr)) !== null) {
705
+ // Skip if inside an already-consumed open/close frame
706
+ const insideFrame = frameRanges.some(r => match.index >= r.start && match.index < r.end);
707
+ if (insideFrame) continue;
708
+
709
+ const frameProps = this.parseProps(match[1] || '');
710
+ frameProps._type = 'frame';
711
+ frameProps._index = match.index;
712
+ frameProps._children = [];
713
+ children.push(frameProps);
714
+ frameRanges.push({ start: match.index, end: match.index + match[0].length });
715
+ }
716
+
717
+ // Parse Slot elements (with children) - must be before Text parsing
718
+ // Slots can have children (default content)
719
+ const slotOpenRegex = /<Slot(?:\s+([^>]*?))?>/g;
720
+ while ((match = slotOpenRegex.exec(childrenStr)) !== null) {
721
+ const idx = match.index;
722
+ const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
723
+ if (!insideFrame) {
724
+ const slotProps = this.parseProps(match[1] || '');
725
+ slotProps._type = 'slot';
726
+ slotProps._index = idx;
727
+
728
+ // Get content between opening and matching closing tag
729
+ const afterOpen = childrenStr.slice(match.index + match[0].length);
730
+ const innerContent = this.extractContent(afterOpen, 'Slot');
731
+ const fullLength = match[0].length + innerContent.length + '</Slot>'.length;
732
+
733
+ // Recursively parse children of slot (default content)
734
+ slotProps._children = this.parseChildren(innerContent);
735
+ children.push(slotProps);
736
+
737
+ // Mark this range as consumed (so text/other elements inside are skipped)
738
+ frameRanges.push({ start: idx, end: idx + fullLength });
739
+ slotOpenRegex.lastIndex = idx + fullLength;
740
+ }
741
+ }
742
+
743
+ // Parse self-closing Slot elements
744
+ const slotSelfCloseRegex = /<Slot(?:\s+([^/]*?))?\s*\/>/g;
745
+ while ((match = slotSelfCloseRegex.exec(childrenStr)) !== null) {
746
+ const idx = match.index;
747
+ const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
748
+ if (!insideFrame) {
749
+ const slotProps = this.parseProps(match[1] || '');
750
+ slotProps._type = 'slot';
751
+ slotProps._index = idx;
752
+ slotProps._children = [];
753
+ children.push(slotProps);
754
+ // Mark as consumed
755
+ frameRanges.push({ start: idx, end: idx + match[0].length });
756
+ }
757
+ }
758
+
759
+ // Parse Text elements, but skip those inside nested Frames/Slots
760
+ // Use (?:\s+([^>]*?))? to allow Text with or without attributes
761
+ const textRegex = /<Text(?:\s+([^>]*?))?>([^<]*)<\/Text>/g;
762
+ while ((match = textRegex.exec(childrenStr)) !== null) {
763
+ const idx = match.index;
764
+ // Check if this text is inside a nested frame
765
+ const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
766
+ if (!insideFrame) {
767
+ const textProps = this.parseProps(match[1] || '');
768
+ textProps._type = 'text';
769
+ textProps.content = match[2];
770
+ textProps._index = idx;
771
+ children.push(textProps);
772
+ }
773
+ }
774
+
775
+ // Parse Rectangle elements (self-closing)
776
+ // Use (?:\s+([^/]*?))? to allow Rect with or without attributes
777
+ const rectRegex = /<(?:Rectangle|Rect)(?:\s+([^/]*?))?\s*\/>/g;
778
+ while ((match = rectRegex.exec(childrenStr)) !== null) {
779
+ const idx = match.index;
780
+ const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
781
+ if (!insideFrame) {
782
+ const rectProps = this.parseProps(match[1] || '');
783
+ rectProps._type = 'rect';
784
+ rectProps._index = idx;
785
+ children.push(rectProps);
786
+ }
787
+ }
788
+
789
+ // Parse Image elements (self-closing) - creates placeholder rectangle
790
+ const imageRegex = /<Image\s+([^/]*)\s*\/>/g;
791
+ while ((match = imageRegex.exec(childrenStr)) !== null) {
792
+ const idx = match.index;
793
+ const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
794
+ if (!insideFrame) {
795
+ const imgProps = this.parseProps(match[1]);
796
+ imgProps._type = 'image';
797
+ imgProps._index = idx;
798
+ children.push(imgProps);
799
+ }
800
+ }
801
+
802
+ // Parse Icon elements (self-closing) - creates placeholder
803
+ const iconRegex = /<Icon\s+([^/]*)\s*\/>/g;
804
+ while ((match = iconRegex.exec(childrenStr)) !== null) {
805
+ const idx = match.index;
806
+ const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
807
+ if (!insideFrame) {
808
+ const iconProps = this.parseProps(match[1]);
809
+ iconProps._type = 'icon';
810
+ iconProps._index = idx;
811
+ children.push(iconProps);
812
+ }
813
+ }
814
+
815
+ // Parse Instance elements (self-closing) - creates component instance
816
+ const instanceRegex = /<Instance\s+([^/]*)\s*\/>/g;
817
+ while ((match = instanceRegex.exec(childrenStr)) !== null) {
818
+ const idx = match.index;
819
+ const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
820
+ if (!insideFrame) {
821
+ const instProps = this.parseProps(match[1]);
822
+ instProps._type = 'instance';
823
+ instProps._index = idx;
824
+ children.push(instProps);
825
+ }
826
+ }
827
+
828
+ // Sort by original position in JSX to maintain order
829
+ children.sort((a, b) => a._index - b._index);
830
+
831
+ return children;
832
+ }
833
+
834
+ generateCode(props, children, iconSvgMap = {}) {
835
+ const name = props.name || 'Frame';
836
+ const rawWidth = props.w || props.width;
837
+ const rawHeight = props.h || props.height;
838
+ const hasExplicitWidth = props.w !== undefined || props.width !== undefined;
839
+ const hasExplicitHeight = props.h !== undefined || props.height !== undefined;
840
+ // Support w="fill" / h="fill" for root frame
841
+ const fillWidth = rawWidth === 'fill';
842
+ const fillHeight = rawHeight === 'fill';
843
+ const width = fillWidth ? 100 : (rawWidth || 320);
844
+ const height = fillHeight ? 100 : (rawHeight || 200);
845
+ const bg = props.bg || props.fill || '#ffffff';
846
+ const stroke = props.stroke || null;
847
+ const strokeWidth = props.strokeWidth || 1;
848
+ const strokeAlignProp = props.strokeAlign || null;
849
+ const rounded = props.rounded || props.radius || 0;
850
+ const flex = props.flex || 'col';
851
+ const gap = props.gap || 0;
852
+ const p = props.p || props.padding || 0;
853
+ const px = props.px || p;
854
+ const py = props.py || p;
855
+ const align = props.items || props.align || 'MIN';
856
+ const justify = props.justify || 'MIN';
857
+ const useSmartPos = props.x === undefined;
858
+ const explicitX = props.x || 0;
859
+ const y = props.y || 0;
860
+ // New: clip defaults to false (don't clip auto-layout overflow). overflow="hidden" also sets clip.
861
+ const clip = props.clip === 'true' || props.clip === true || props.overflow === 'hidden';
862
+ // New: hug for auto-sizing (hug="both" | "w" | "h" | "width" | "height")
863
+ const hug = props.hug || '';
864
+ const hugWidth = hug === 'both' || hug === 'w' || hug === 'width';
865
+ const hugHeight = hug === 'both' || hug === 'h' || hug === 'height';
866
+ // New: wrap and wrapGap for horizontal layouts
867
+ const wrap = props.wrap === true || props.wrap === 'true';
868
+ const wrapGap = Number(props.wrapGap || props.counterAxisSpacing || 0);
869
+
870
+ // Track variable usage for fast binding
871
+ let usesVars = false;
872
+ const checkVarUsage = (value) => {
873
+ if (this.isVarRef(value)) usesVars = true;
874
+ };
875
+
876
+ // Check root frame for var usage
877
+ checkVarUsage(bg);
878
+ if (stroke) checkVarUsage(stroke);
879
+
880
+ // Collect all fonts and check variable usage recursively
881
+ const fonts = new Set();
882
+ const collectFontsAndVars = (items) => {
883
+ items.forEach(item => {
884
+ if (item._type === 'text') {
885
+ const weight = item.weight || 'regular';
886
+ const style = weight === 'bold' ? 'Bold' : weight === 'medium' ? 'Medium' : weight === 'semibold' ? 'Semi Bold' : 'Regular';
887
+ fonts.add(style);
888
+ const color = item.color || '#000000';
889
+ checkVarUsage(color);
890
+ } else if (item._type === 'frame') {
891
+ const fBg = item.bg || item.fill || null;
892
+ const fStroke = item.stroke || null;
893
+ if (fBg) checkVarUsage(fBg);
894
+ if (fStroke) checkVarUsage(fStroke);
895
+ if (item._children) collectFontsAndVars(item._children);
896
+ } else if (item._type === 'rect' || item._type === 'image' || item._type === 'icon') {
897
+ const itemBg = item.bg || item.fill || item.color || item.c || '#e4e4e7';
898
+ checkVarUsage(itemBg);
899
+ }
900
+ });
901
+ };
902
+ collectFontsAndVars(children);
903
+
904
+ // Font caching for single render
905
+ const fontStyles = Array.from(fonts);
906
+
907
+ // Generate child code recursively
908
+ let childCounter = 0;
909
+ const generateChildCode = (items, parentVar, parentFlex) => {
910
+ return items.map(item => {
911
+ const idx = childCounter++;
912
+ if (item._type === 'text') {
913
+ const weight = item.weight || 'regular';
914
+ const style = weight === 'bold' ? 'Bold' : weight === 'medium' ? 'Medium' : weight === 'semibold' ? 'Semi Bold' : 'Regular';
915
+ const size = item.size || 14;
916
+ const color = item.color || '#000000';
917
+ const fillWidth = item.w === 'fill';
918
+ const textFillCode = this.generateFillCode(color, `el${idx}`);
919
+
920
+ return `
921
+ __currentNode = 'Text: ${item.content.substring(0, 30).replace(/'/g, "\\'")}';
922
+ const el${idx} = figma.createText();
923
+ el${idx}.fontName = {family:'Inter',style:'${style}'};
924
+ el${idx}.fontSize = ${size};
925
+ el${idx}.characters = ${JSON.stringify(item.content)};
926
+ ${textFillCode.code}
927
+ ${parentVar}.appendChild(el${idx});
928
+ ${fillWidth ? `el${idx}.layoutSizingHorizontal = 'FILL'; el${idx}.textAutoResize = 'HEIGHT';` : ''}`;
929
+ } else if (item._type === 'frame') {
930
+ // Nested frame (button, etc.)
931
+ const fName = item.name || 'Nested Frame';
932
+ const fBg = item.bg || item.fill || null;
933
+ const fStroke = item.stroke || null;
934
+ const fStrokeWidth = item.strokeWidth || 1;
935
+ const fStrokeAlign = item.strokeAlign || null;
936
+ const fRounded = item.rounded || item.radius || 0;
937
+ const fFlex = item.flex || 'row';
938
+ const fGap = item.gap || 0;
939
+ // Default padding is 0 (only set padding when explicitly specified)
940
+ const fP = item.p !== undefined ? item.p : (item.padding !== undefined ? item.padding : null);
941
+ const fPx = item.px !== undefined ? item.px : (fP !== null ? fP : 0);
942
+ const fPy = item.py !== undefined ? item.py : (fP !== null ? fP : 0);
943
+ // Individual padding overrides (pt, pr, pb, pl)
944
+ const fPt = item.pt !== undefined ? Number(item.pt) : Number(fPy);
945
+ const fPr = item.pr !== undefined ? Number(item.pr) : Number(fPx);
946
+ const fPb = item.pb !== undefined ? Number(item.pb) : Number(fPy);
947
+ const fPl = item.pl !== undefined ? Number(item.pl) : Number(fPx);
948
+ const fAlign = item.align || 'center';
949
+ const fJustify = item.justify || 'center';
950
+ // Clip defaults to false for nested frames (overflow="hidden" also sets clip)
951
+ const fClip = item.clip === 'true' || item.clip === true || item.overflow === 'hidden';
952
+
953
+ // NEW: wrap, wrapGap, grow, position props
954
+ const fWrap = item.wrap === true || item.wrap === 'true';
955
+ const fWrapGap = Number(item.wrapGap || item.counterAxisSpacing || 0);
956
+ const fGrow = item.grow !== undefined ? Number(item.grow) : null;
957
+ const fPosition = item.position || 'auto';
958
+ const fAbsoluteX = item.x !== undefined ? Number(item.x) : 0;
959
+ const fAbsoluteY = item.y !== undefined ? Number(item.y) : 0;
960
+
961
+ // Support w="fill" for nested frames (check BEFORE setting fWidth/fHeight)
962
+ const fillWidth = item.w === 'fill';
963
+ const fillHeight = item.h === 'fill';
964
+
965
+ // HUG by default, FIXED only if explicit numeric size given
966
+ const hasWidth = (item.w !== undefined || item.width !== undefined) && !fillWidth;
967
+ const hasHeight = (item.h !== undefined || item.height !== undefined) && !fillHeight;
968
+ const fWidth = fillWidth ? 100 : (item.w || item.width || 100);
969
+ const fHeight = fillHeight ? 100 : (item.h || item.height || 40);
970
+
971
+ // Map align/justify to Figma values
972
+ const alignMap = { start: 'MIN', center: 'CENTER', end: 'MAX', stretch: 'STRETCH' };
973
+ const fAlignVal = alignMap[fAlign] || 'CENTER';
974
+ const fJustifyVal = alignMap[fJustify] || 'CENTER';
975
+
976
+ const nestedChildren = item._children ? generateChildCode(item._children, `el${idx}`, fFlex) : '';
977
+ const frameFillCode = fBg ? this.generateFillCode(fBg, `el${idx}`) : { code: `el${idx}.fills = [];`, usesVars: false };
978
+ const frameStrokeCode = fStroke ? this.generateStrokeCode(fStroke, `el${idx}`, fStrokeWidth, fStrokeAlign) : { code: '' };
979
+
980
+ // Determine sizing: FILL, FIXED, or HUG for each axis
981
+ const wantFillH = fillWidth || (fGrow !== null && parentFlex === 'row');
982
+ const wantFillV = fillHeight || (fGrow !== null && parentFlex === 'col');
983
+ const hSizing = wantFillH ? 'FILL' : (hasWidth ? 'FIXED' : 'HUG');
984
+ const vSizing = wantFillV ? 'FILL' : (hasHeight ? 'FIXED' : 'HUG');
985
+
986
+ return `
987
+ __currentNode = 'Frame: ${fName.replace(/'/g, "\\'")}';
988
+ const el${idx} = figma.createFrame();
989
+ el${idx}.name = ${JSON.stringify(fName)};
990
+ el${idx}.layoutMode = '${fFlex === 'row' ? 'HORIZONTAL' : 'VERTICAL'}';
991
+ ${fWrap && fFlex === 'row' ? `el${idx}.layoutWrap = 'WRAP';` : ''}
992
+ ${hasWidth || hasHeight ? `el${idx}.resize(${hasWidth ? fWidth : 100}, ${hasHeight ? fHeight : 100});` : ''}
993
+ el${idx}.itemSpacing = ${fGap};
994
+ el${idx}.paddingTop = ${fPt};
995
+ el${idx}.paddingBottom = ${fPb};
996
+ el${idx}.paddingLeft = ${fPl};
997
+ el${idx}.paddingRight = ${fPr};
998
+ el${idx}.cornerRadius = ${fRounded};
999
+ ${frameFillCode.code}
1000
+ ${frameStrokeCode.code}
1001
+ el${idx}.primaryAxisAlignItems = '${fJustifyVal}';
1002
+ el${idx}.counterAxisAlignItems = '${fAlignVal}';
1003
+ el${idx}.clipsContent = ${fClip};
1004
+ ${parentVar}.appendChild(el${idx});
1005
+ el${idx}.layoutSizingHorizontal = '${hSizing}';
1006
+ el${idx}.layoutSizingVertical = '${vSizing}';
1007
+ ${nestedChildren}
1008
+ ${fWrap && fFlex === 'row' && fWrapGap > 0 ? `el${idx}.counterAxisSpacing = ${fWrapGap};` : ''}
1009
+ ${fPosition === 'absolute' ? `el${idx}.layoutPositioning = 'ABSOLUTE'; el${idx}.x = ${fAbsoluteX}; el${idx}.y = ${fAbsoluteY};` : ''}`;
1010
+ } else if (item._type === 'rect') {
1011
+ // Rectangle element
1012
+ const rWidth = item.w || item.width || 100;
1013
+ const rHeight = item.h || item.height || 100;
1014
+ const rBg = item.bg || item.fill || '#e4e4e7';
1015
+ const rRounded = item.rounded || item.radius || 0;
1016
+ const rName = item.name || 'Rectangle';
1017
+ const rectFillCode = this.generateFillCode(rBg, `el${idx}`);
1018
+
1019
+ return `
1020
+ const el${idx} = figma.createRectangle();
1021
+ el${idx}.name = ${JSON.stringify(rName)};
1022
+ el${idx}.resize(${rWidth}, ${rHeight});
1023
+ el${idx}.cornerRadius = ${rRounded};
1024
+ ${rectFillCode.code}
1025
+ ${parentVar}.appendChild(el${idx});`;
1026
+ } else if (item._type === 'image') {
1027
+ // Image placeholder (gray rectangle with image icon concept)
1028
+ const iWidth = item.w || item.width || 200;
1029
+ const iHeight = item.h || item.height || 150;
1030
+ const iBg = item.bg || '#f4f4f5';
1031
+ const iRounded = item.rounded || item.radius || 8;
1032
+ const iName = item.name || 'Image';
1033
+ const imgFillCode = this.generateFillCode(iBg, `el${idx}`);
1034
+
1035
+ return `
1036
+ const el${idx} = figma.createRectangle();
1037
+ el${idx}.name = ${JSON.stringify(iName)};
1038
+ el${idx}.resize(${iWidth}, ${iHeight});
1039
+ el${idx}.cornerRadius = ${iRounded};
1040
+ ${imgFillCode.code}
1041
+ ${parentVar}.appendChild(el${idx});`;
1042
+ } else if (item._type === 'icon') {
1043
+ const icSize = item.size || item.s || 24;
1044
+ const icBg = item.color || item.c || '#71717a';
1045
+ const icName = item.name || 'Icon';
1046
+ const svgData = iconSvgMap[icName];
1047
+
1048
+ if (svgData) {
1049
+ // Real SVG icon from Iconify
1050
+ // IMPORTANT: createNodeFromSvg creates a Frame wrapper. We must:
1051
+ // 1. Clear fills on the wrapper frame (otherwise it shows as a filled square)
1052
+ // 2. Only colorize the vector children inside, not the wrapper
1053
+ const colorCode = icBg.startsWith('var:') ? '' : (() => {
1054
+ const rgb = this.hexToRgb(icBg);
1055
+ return rgb ? `
1056
+ function colorize${idx}(n) {
1057
+ if (n.fills && n.fills.length > 0) n.fills = [{type:'SOLID',color:{r:${rgb.r},g:${rgb.g},b:${rgb.b}}}];
1058
+ if (n.strokes && n.strokes.length > 0) n.strokes = [{type:'SOLID',color:{r:${rgb.r},g:${rgb.g},b:${rgb.b}}}];
1059
+ if (n.children) n.children.forEach(colorize${idx});
1060
+ }
1061
+ if (el${idx}.children) el${idx}.children.forEach(colorize${idx});` : '';
1062
+ })();
1063
+
1064
+ // Variable color binding for icons
1065
+ const varColorCode = icBg.startsWith('var:') ? (() => {
1066
+ const varName = icBg.slice(4);
1067
+ return `
1068
+ if (vars && vars[${JSON.stringify(varName)}]) {
1069
+ function colorizeVar${idx}(n) {
1070
+ if (n.fills && n.fills.length > 0) n.fills = [boundFill(vars[${JSON.stringify(varName)}])];
1071
+ if (n.strokes && n.strokes.length > 0) n.strokes = [figma.variables.setBoundVariableForPaint({type:'SOLID',color:{r:0.5,g:0.5,b:0.5}},'color',vars[${JSON.stringify(varName)}])];
1072
+ if (n.children) n.children.forEach(colorizeVar${idx});
1073
+ }
1074
+ if (el${idx}.children) el${idx}.children.forEach(colorizeVar${idx});
1075
+ }`;
1076
+ })() : '';
1077
+
1078
+ return `
1079
+ const el${idx} = figma.createNodeFromSvg(${JSON.stringify(svgData)});
1080
+ el${idx}.name = ${JSON.stringify(icName)};
1081
+ el${idx}.fills = [];
1082
+ el${idx}.resize(${icSize}, ${icSize});
1083
+ ${colorCode}${varColorCode}
1084
+ ${parentVar}.appendChild(el${idx});`;
1085
+ } else {
1086
+ // Fallback: placeholder rectangle
1087
+ const iconFillCode = this.generateFillCode(icBg, `el${idx}`);
1088
+ return `
1089
+ const el${idx} = figma.createRectangle();
1090
+ el${idx}.name = ${JSON.stringify(icName)};
1091
+ el${idx}.resize(${icSize}, ${icSize});
1092
+ el${idx}.cornerRadius = ${Math.round(icSize / 4)};
1093
+ ${iconFillCode.code}
1094
+ ${parentVar}.appendChild(el${idx});`;
1095
+ }
1096
+ } else if (item._type === 'instance') {
1097
+ // Component instance
1098
+ const compId = item.component || item.id;
1099
+ const compName = item.name;
1100
+
1101
+ if (compId) {
1102
+ // Create instance by component ID
1103
+ return `
1104
+ const comp${idx} = figma.getNodeById(${JSON.stringify(compId)});
1105
+ if (comp${idx} && comp${idx}.type === 'COMPONENT') {
1106
+ const el${idx} = comp${idx}.createInstance();
1107
+ ${parentVar}.appendChild(el${idx});
1108
+ }`;
1109
+ } else if (compName) {
1110
+ // Find component by name and create instance
1111
+ return `
1112
+ const comp${idx} = figma.currentPage.findOne(n => n.type === 'COMPONENT' && n.name === ${JSON.stringify(compName)});
1113
+ if (comp${idx}) {
1114
+ const el${idx} = comp${idx}.createInstance();
1115
+ ${parentVar}.appendChild(el${idx});
1116
+ }`;
1117
+ }
1118
+ return '';
1119
+ } else if (item._type === 'slot') {
1120
+ // Slot element - creates slot inside component
1121
+ // NOTE: createSlot only works when parent is a component
1122
+ const slotName = item.name || 'Slot';
1123
+ const slotFlex = item.flex || 'col';
1124
+ const slotGap = item.gap || 0;
1125
+ const slotP = item.p !== undefined ? item.p : (item.padding !== undefined ? item.padding : null);
1126
+ const slotPx = item.px !== undefined ? item.px : (slotP !== null ? slotP : 0);
1127
+ const slotPy = item.py !== undefined ? item.py : (slotP !== null ? slotP : 0);
1128
+ const slotBg = item.bg || item.fill || null;
1129
+ const slotWidth = item.w || item.width;
1130
+ const slotHeight = item.h || item.height;
1131
+ const fillWidth = item.w === 'fill';
1132
+ const fillHeight = item.h === 'fill';
1133
+
1134
+ const nestedChildren = item._children ? generateChildCode(item._children, `slot${idx}`) : '';
1135
+ const slotFillCode = slotBg ? this.generateFillCode(slotBg, `slot${idx}`) : { code: '' };
1136
+
1137
+ return `
1138
+ // Create slot (only works if parent is a component)
1139
+ let slot${idx} = null;
1140
+ if (${parentVar}.type === 'COMPONENT' || ${parentVar}.type === 'COMPONENT_SET') {
1141
+ slot${idx} = ${parentVar}.createSlot(${JSON.stringify(slotName)});
1142
+ } else {
1143
+ // Fall back to regular frame if parent is not a component
1144
+ slot${idx} = figma.createFrame();
1145
+ slot${idx}.name = ${JSON.stringify(slotName)};
1146
+ ${parentVar}.appendChild(slot${idx});
1147
+ }
1148
+ slot${idx}.layoutMode = '${slotFlex === 'row' ? 'HORIZONTAL' : 'VERTICAL'}';
1149
+ slot${idx}.itemSpacing = ${slotGap};
1150
+ slot${idx}.paddingTop = ${slotPy};
1151
+ slot${idx}.paddingBottom = ${slotPy};
1152
+ slot${idx}.paddingLeft = ${slotPx};
1153
+ slot${idx}.paddingRight = ${slotPx};
1154
+ ${slotWidth && !fillWidth ? `slot${idx}.resize(${slotWidth}, ${slotHeight || 100});` : ''}
1155
+ ${fillWidth ? `slot${idx}.layoutSizingHorizontal = 'FILL';` : ''}
1156
+ ${fillHeight ? `slot${idx}.layoutSizingVertical = 'FILL';` : ''}
1157
+ ${slotFillCode.code}
1158
+ ${nestedChildren}`;
1159
+ }
1160
+ return '';
1161
+ }).join('\n');
1162
+ };
1163
+
1164
+ const childCode = generateChildCode(children, 'frame', flex);
1165
+
1166
+ // Map align/justify to Figma values for root frame
1167
+ const alignMap = { start: 'MIN', center: 'CENTER', end: 'MAX', stretch: 'STRETCH' };
1168
+ const alignVal = alignMap[align] || 'MIN';
1169
+ const justifyVal = alignMap[justify] || 'MIN';
1170
+
1171
+ // Smart positioning code
1172
+ const smartPosCode = useSmartPos ? `
1173
+ let smartX = 0;
1174
+ const children = figma.currentPage.children;
1175
+ if (children.length > 0) {
1176
+ let maxRight = 0;
1177
+ children.forEach(n => {
1178
+ const right = n.x + (n.width || 0);
1179
+ if (right > maxRight) maxRight = right;
1180
+ });
1181
+ smartX = Math.round(maxRight + 100);
1182
+ }
1183
+ ` : `const smartX = ${explicitX};`;
1184
+
1185
+ // Generate fill/stroke code for root frame
1186
+ const rootFillCode = this.generateFillCode(bg, 'frame');
1187
+ const rootStrokeCode = stroke ? this.generateStrokeCode(stroke, 'frame', strokeWidth, strokeAlignProp) : { code: '', usesVars: false };
1188
+
1189
+ // Variable loading code with caching (only if any vars used)
1190
+ const varLoadCode = usesVars ? `
1191
+ // Load shadcn variables (cached for 30s)
1192
+ if (!globalThis.__varsCache || Date.now() - (globalThis.__varsCacheTime || 0) > 30000) {
1193
+ const collections = await figma.variables.getLocalVariableCollectionsAsync();
1194
+ globalThis.__varsCache = {};
1195
+ for (const col of collections) {
1196
+ if (!col.name.startsWith('shadcn')) continue;
1197
+ for (const id of col.variableIds) {
1198
+ const v = await figma.variables.getVariableByIdAsync(id);
1199
+ if (v) globalThis.__varsCache[v.name] = v;
1200
+ }
1201
+ }
1202
+ globalThis.__varsCacheTime = Date.now();
1203
+ }
1204
+ const vars = globalThis.__varsCache;
1205
+ const boundFill = (variable) => figma.variables.setBoundVariableForPaint(
1206
+ { type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }, 'color', variable
1207
+ );
1208
+ ` : '';
1209
+
1210
+ // Font loading with caching
1211
+ const fontLoadCode = fontStyles.length > 0
1212
+ ? `
1213
+ if (!globalThis.__loadedFonts) globalThis.__loadedFonts = new Set();
1214
+ const fontsToLoad = ${JSON.stringify(fontStyles)}.filter(s => !globalThis.__loadedFonts.has(s));
1215
+ if (fontsToLoad.length > 0) {
1216
+ await Promise.all(fontsToLoad.map(s => figma.loadFontAsync({family:'Inter',style:s})));
1217
+ fontsToLoad.forEach(s => globalThis.__loadedFonts.add(s));
1218
+ }
1219
+ `
1220
+ : `
1221
+ if (!globalThis.__loadedFonts) globalThis.__loadedFonts = new Set();
1222
+ if (!globalThis.__loadedFonts.has('Regular')) {
1223
+ await figma.loadFontAsync({family:'Inter',style:'Regular'});
1224
+ globalThis.__loadedFonts.add('Regular');
1225
+ }
1226
+ `;
1227
+
1228
+ return `
1229
+ (async function() {
1230
+ ${fontLoadCode}
1231
+ ${varLoadCode}
1232
+ ${smartPosCode}
1233
+
1234
+ let __currentNode = 'root';
1235
+ try {
1236
+ const frame = figma.createFrame();
1237
+ __currentNode = ${JSON.stringify(name)};
1238
+ frame.name = ${JSON.stringify(name)};
1239
+ frame.resize(${width}, ${height});
1240
+ frame.x = smartX;
1241
+ frame.y = ${y};
1242
+ frame.cornerRadius = ${rounded};
1243
+ ${rootFillCode.code}
1244
+ ${rootStrokeCode.code}
1245
+ frame.layoutMode = '${flex === 'row' ? 'HORIZONTAL' : 'VERTICAL'}';
1246
+ ${wrap && flex === 'row' ? `frame.layoutWrap = 'WRAP';` : ''}
1247
+ frame.itemSpacing = ${gap};
1248
+ frame.paddingTop = ${py};
1249
+ frame.paddingBottom = ${py};
1250
+ frame.paddingLeft = ${px};
1251
+ frame.paddingRight = ${px};
1252
+ frame.primaryAxisAlignItems = '${justifyVal}';
1253
+ frame.counterAxisAlignItems = '${alignVal}';
1254
+ frame.primaryAxisSizingMode = '${flex === 'col' ? (hugHeight || fillHeight || !hasExplicitHeight ? 'AUTO' : 'FIXED') : (hugWidth || fillWidth || !hasExplicitWidth ? 'AUTO' : 'FIXED')}';
1255
+ frame.counterAxisSizingMode = '${flex === 'col' ? (hugWidth || fillWidth || !hasExplicitWidth ? 'AUTO' : 'FIXED') : (hugHeight || fillHeight || !hasExplicitHeight ? 'AUTO' : 'FIXED')}';
1256
+ ${fillWidth ? `frame.layoutSizingHorizontal = 'FILL';` : ''}
1257
+ ${fillHeight ? `frame.layoutSizingVertical = 'FILL';` : ''}
1258
+ ${wrap && flex === 'row' && wrapGap > 0 ? `frame.counterAxisSpacing = ${wrapGap};` : ''}
1259
+ frame.clipsContent = ${clip};
1260
+
1261
+ ${childCode}
1262
+
1263
+ return { id: frame.id, name: frame.name };
1264
+ } catch(e) {
1265
+ frame.remove();
1266
+ throw new Error('[Node: ' + __currentNode + '] ' + e.message);
1267
+ }
1268
+ })()
1269
+ `;
1270
+ }
1271
+
1272
+ hexToRgb(hex) {
1273
+ if (!hex || !hex.startsWith('#')) return null;
1274
+ let r, g, b;
1275
+ if (hex.length === 4) {
1276
+ r = parseInt(hex[1] + hex[1], 16) / 255;
1277
+ g = parseInt(hex[2] + hex[2], 16) / 255;
1278
+ b = parseInt(hex[3] + hex[3], 16) / 255;
1279
+ } else {
1280
+ r = parseInt(hex.slice(1, 3), 16) / 255;
1281
+ g = parseInt(hex.slice(3, 5), 16) / 255;
1282
+ b = parseInt(hex.slice(5, 7), 16) / 255;
1283
+ }
1284
+ return { r, g, b };
1285
+ }
1286
+
1287
+ hexToRgbCode(hex) {
1288
+ // Support both #fff and #ffffff formats
1289
+ let r, g, b;
1290
+ if (hex.length === 4) {
1291
+ // #rgb -> #rrggbb
1292
+ r = parseInt(hex[1] + hex[1], 16) / 255;
1293
+ g = parseInt(hex[2] + hex[2], 16) / 255;
1294
+ b = parseInt(hex[3] + hex[3], 16) / 255;
1295
+ } else {
1296
+ // #rrggbb
1297
+ r = parseInt(hex.slice(1, 3), 16) / 255;
1298
+ g = parseInt(hex.slice(3, 5), 16) / 255;
1299
+ b = parseInt(hex.slice(5, 7), 16) / 255;
1300
+ }
1301
+ return `{r:${r},g:${g},b:${b}}`;
1302
+ }
1303
+
1304
+ /**
1305
+ * Check if a value is a variable reference (var:name)
1306
+ */
1307
+ isVarRef(value) {
1308
+ return typeof value === 'string' && value.startsWith('var:');
1309
+ }
1310
+
1311
+ /**
1312
+ * Extract variable name from var:name syntax
1313
+ */
1314
+ getVarName(value) {
1315
+ return value.slice(4); // Remove 'var:' prefix
1316
+ }
1317
+
1318
+ /**
1319
+ * Generate fill code - either hex color or bound variable
1320
+ * Returns { code: string, usesVars: boolean }
1321
+ */
1322
+ generateFillCode(value, elementVar, property = 'fills') {
1323
+ if (this.isVarRef(value)) {
1324
+ const varName = this.getVarName(value);
1325
+ return {
1326
+ code: `${elementVar}.${property} = [boundFill(vars['${varName}'])];`,
1327
+ usesVars: true
1328
+ };
1329
+ } else {
1330
+ return {
1331
+ code: `${elementVar}.${property} = [{type:'SOLID',color:${this.hexToRgbCode(value)}}];`,
1332
+ usesVars: false
1333
+ };
1334
+ }
1335
+ }
1336
+
1337
+ /**
1338
+ * Generate stroke code - either hex color or bound variable
1339
+ */
1340
+ generateStrokeCode(value, elementVar, strokeWidth = 1, strokeAlign = null) {
1341
+ const alignCode = strokeAlign ? ` ${elementVar}.strokeAlign = '${strokeAlign.toUpperCase()}';` : '';
1342
+ if (this.isVarRef(value)) {
1343
+ const varName = this.getVarName(value);
1344
+ return {
1345
+ code: `${elementVar}.strokes = [boundFill(vars['${varName}'])]; ${elementVar}.strokeWeight = ${strokeWidth};${alignCode}`,
1346
+ usesVars: true
1347
+ };
1348
+ } else {
1349
+ return {
1350
+ code: `${elementVar}.strokes = [{type:'SOLID',color:${this.hexToRgbCode(value)}}]; ${elementVar}.strokeWeight = ${strokeWidth};${alignCode}`,
1351
+ usesVars: false
1352
+ };
1353
+ }
1354
+ }
1355
+
1356
+ // ============ Node Operations ============
1357
+
1358
+ /**
1359
+ * Get a node by ID
1360
+ */
1361
+ async getNode(nodeId) {
1362
+ return await this.eval(`
1363
+ (function() {
1364
+ const n = figma.getNodeById(${JSON.stringify(nodeId)});
1365
+ if (!n) return null;
1366
+ return {
1367
+ id: n.id,
1368
+ type: n.type,
1369
+ name: n.name || '',
1370
+ x: n.x,
1371
+ y: n.y,
1372
+ width: n.width,
1373
+ height: n.height,
1374
+ visible: n.visible,
1375
+ opacity: n.opacity
1376
+ };
1377
+ })()
1378
+ `);
1379
+ }
1380
+
1381
+ /**
1382
+ * Delete a node by ID
1383
+ */
1384
+ async deleteNode(nodeId) {
1385
+ return await this.eval(`
1386
+ (function() {
1387
+ const n = figma.getNodeById(${JSON.stringify(nodeId)});
1388
+ if (!n) return { success: false, error: 'Node not found' };
1389
+ n.remove();
1390
+ return { success: true };
1391
+ })()
1392
+ `);
1393
+ }
1394
+
1395
+ /**
1396
+ * Move a node to new position
1397
+ */
1398
+ async moveNode(nodeId, x, y) {
1399
+ return await this.eval(`
1400
+ (function() {
1401
+ const n = figma.getNodeById(${JSON.stringify(nodeId)});
1402
+ if (!n) return { success: false, error: 'Node not found' };
1403
+ n.x = ${x};
1404
+ n.y = ${y};
1405
+ return { success: true, x: n.x, y: n.y };
1406
+ })()
1407
+ `);
1408
+ }
1409
+
1410
+ /**
1411
+ * Resize a node
1412
+ */
1413
+ async resizeNode(nodeId, width, height) {
1414
+ return await this.eval(`
1415
+ (function() {
1416
+ const n = figma.getNodeById(${JSON.stringify(nodeId)});
1417
+ if (!n) return { success: false, error: 'Node not found' };
1418
+ if (n.resize) n.resize(${width}, ${height});
1419
+ return { success: true, width: n.width, height: n.height };
1420
+ })()
1421
+ `);
1422
+ }
1423
+
1424
+ /**
1425
+ * Get current selection
1426
+ */
1427
+ async getSelection() {
1428
+ return await this.eval(`
1429
+ figma.currentPage.selection.map(n => ({
1430
+ id: n.id,
1431
+ type: n.type,
1432
+ name: n.name || ''
1433
+ }))
1434
+ `);
1435
+ }
1436
+
1437
+ /**
1438
+ * Set selection by node IDs
1439
+ */
1440
+ async setSelection(nodeIds) {
1441
+ const ids = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
1442
+ return await this.eval(`
1443
+ (function() {
1444
+ const nodes = ${JSON.stringify(ids)}.map(id => figma.getNodeById(id)).filter(n => n);
1445
+ figma.currentPage.selection = nodes;
1446
+ return nodes.map(n => n.id);
1447
+ })()
1448
+ `);
1449
+ }
1450
+
1451
+ /**
1452
+ * Get node tree (recursive structure)
1453
+ */
1454
+ async getNodeTree(nodeId, maxDepth = 10) {
1455
+ return await this.eval(`
1456
+ (function() {
1457
+ function buildTree(node, depth) {
1458
+ if (depth > ${maxDepth}) return null;
1459
+ const result = {
1460
+ id: node.id,
1461
+ type: node.type,
1462
+ name: node.name || '',
1463
+ x: Math.round(node.x || 0),
1464
+ y: Math.round(node.y || 0),
1465
+ width: Math.round(node.width || 0),
1466
+ height: Math.round(node.height || 0)
1467
+ };
1468
+ if (node.children) {
1469
+ result.children = node.children.map(c => buildTree(c, depth + 1)).filter(c => c);
1470
+ }
1471
+ return result;
1472
+ }
1473
+ const node = ${nodeId ? `figma.getNodeById(${JSON.stringify(nodeId)})` : 'figma.currentPage'};
1474
+ if (!node) return null;
1475
+ return buildTree(node, 0);
1476
+ })()
1477
+ `);
1478
+ }
1479
+
1480
+ /**
1481
+ * Convert nodes to components
1482
+ */
1483
+ async toComponent(nodeIds) {
1484
+ const ids = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
1485
+ return await this.eval(`
1486
+ (function() {
1487
+ const results = [];
1488
+ ${JSON.stringify(ids)}.forEach(id => {
1489
+ const node = figma.getNodeById(id);
1490
+ if (node && node.type === 'FRAME') {
1491
+ const component = figma.createComponentFromNode(node);
1492
+ results.push({ id: component.id, name: component.name });
1493
+ }
1494
+ });
1495
+ return results;
1496
+ })()
1497
+ `);
1498
+ }
1499
+
1500
+ /**
1501
+ * Duplicate a node
1502
+ */
1503
+ async duplicateNode(nodeId, offsetX = 50, offsetY = 0) {
1504
+ return await this.eval(`
1505
+ (function() {
1506
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
1507
+ if (!node) return null;
1508
+ const clone = node.clone();
1509
+ clone.x = node.x + ${offsetX};
1510
+ clone.y = node.y + ${offsetY};
1511
+ return { id: clone.id, name: clone.name, x: clone.x, y: clone.y };
1512
+ })()
1513
+ `);
1514
+ }
1515
+
1516
+ /**
1517
+ * Rename a node
1518
+ */
1519
+ async renameNode(nodeId, newName) {
1520
+ return await this.eval(`
1521
+ (function() {
1522
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
1523
+ if (!node) return { success: false, error: 'Node not found' };
1524
+ node.name = ${JSON.stringify(newName)};
1525
+ return { success: true, name: node.name };
1526
+ })()
1527
+ `);
1528
+ }
1529
+
1530
+ /**
1531
+ * Set node fill color
1532
+ */
1533
+ async setFill(nodeId, hexColor) {
1534
+ return await this.eval(`
1535
+ (function() {
1536
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
1537
+ if (!node) return { success: false, error: 'Node not found' };
1538
+ const rgb = ${this.hexToRgbCode(hexColor)};
1539
+ node.fills = [{type: 'SOLID', color: rgb}];
1540
+ return { success: true };
1541
+ })()
1542
+ `);
1543
+ }
1544
+
1545
+ /**
1546
+ * Set node corner radius
1547
+ */
1548
+ async setRadius(nodeId, radius) {
1549
+ return await this.eval(`
1550
+ (function() {
1551
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
1552
+ if (!node) return { success: false, error: 'Node not found' };
1553
+ if ('cornerRadius' in node) node.cornerRadius = ${radius};
1554
+ return { success: true };
1555
+ })()
1556
+ `);
1557
+ }
1558
+
1559
+ /**
1560
+ * Get file key from current file
1561
+ */
1562
+ async getFileKey() {
1563
+ return await this.eval('figma.fileKey');
1564
+ }
1565
+
1566
+ /**
1567
+ * Arrange nodes on canvas
1568
+ */
1569
+ async arrangeNodes(gap = 100, columns = null) {
1570
+ return await this.eval(`
1571
+ (function() {
1572
+ const nodes = figma.currentPage.children.filter(n => n.type === 'FRAME' || n.type === 'COMPONENT');
1573
+ if (nodes.length === 0) return { arranged: 0 };
1574
+
1575
+ const cols = ${columns || 'null'} || nodes.length;
1576
+ let x = 0, y = 0, rowHeight = 0, col = 0;
1577
+
1578
+ nodes.forEach(n => {
1579
+ n.x = x;
1580
+ n.y = y;
1581
+ rowHeight = Math.max(rowHeight, n.height);
1582
+ col++;
1583
+ if (col >= cols) {
1584
+ col = 0;
1585
+ x = 0;
1586
+ y += rowHeight + ${gap};
1587
+ rowHeight = 0;
1588
+ } else {
1589
+ x += n.width + ${gap};
1590
+ }
1591
+ });
1592
+
1593
+ return { arranged: nodes.length };
1594
+ })()
1595
+ `);
1596
+ }
1597
+
1598
+ // ============ Create Primitives ============
1599
+
1600
+ /**
1601
+ * Create a frame
1602
+ */
1603
+ async createFrame(options = {}) {
1604
+ const { name = 'Frame', width = 100, height = 100, x, y, fill = '#ffffff', radius = 0 } = options;
1605
+ return await this.eval(`
1606
+ (function() {
1607
+ const frame = figma.createFrame();
1608
+ frame.name = ${JSON.stringify(name)};
1609
+ frame.resize(${width}, ${height});
1610
+ ${x !== undefined ? `frame.x = ${x};` : ''}
1611
+ ${y !== undefined ? `frame.y = ${y};` : ''}
1612
+ frame.cornerRadius = ${radius};
1613
+ frame.fills = [{type:'SOLID',color:${this.hexToRgbCode(fill)}}];
1614
+ return { id: frame.id, name: frame.name, x: frame.x, y: frame.y };
1615
+ })()
1616
+ `);
1617
+ }
1618
+
1619
+ /**
1620
+ * Create a rectangle
1621
+ */
1622
+ async createRectangle(options = {}) {
1623
+ const { name = 'Rectangle', width = 100, height = 100, x, y, fill = '#d9d9d9', radius = 0 } = options;
1624
+ return await this.eval(`
1625
+ (function() {
1626
+ const rect = figma.createRectangle();
1627
+ rect.name = ${JSON.stringify(name)};
1628
+ rect.resize(${width}, ${height});
1629
+ ${x !== undefined ? `rect.x = ${x};` : ''}
1630
+ ${y !== undefined ? `rect.y = ${y};` : ''}
1631
+ rect.cornerRadius = ${radius};
1632
+ rect.fills = [{type:'SOLID',color:${this.hexToRgbCode(fill)}}];
1633
+ return { id: rect.id, name: rect.name };
1634
+ })()
1635
+ `);
1636
+ }
1637
+
1638
+ /**
1639
+ * Create an ellipse/circle
1640
+ */
1641
+ async createEllipse(options = {}) {
1642
+ const { name = 'Ellipse', width = 100, height = 100, x, y, fill = '#d9d9d9' } = options;
1643
+ return await this.eval(`
1644
+ (function() {
1645
+ const ellipse = figma.createEllipse();
1646
+ ellipse.name = ${JSON.stringify(name)};
1647
+ ellipse.resize(${width}, ${height || width});
1648
+ ${x !== undefined ? `ellipse.x = ${x};` : ''}
1649
+ ${y !== undefined ? `ellipse.y = ${y};` : ''}
1650
+ ellipse.fills = [{type:'SOLID',color:${this.hexToRgbCode(fill)}}];
1651
+ return { id: ellipse.id, name: ellipse.name };
1652
+ })()
1653
+ `);
1654
+ }
1655
+
1656
+ /**
1657
+ * Create a text node
1658
+ */
1659
+ async createText(options = {}) {
1660
+ const { content = 'Text', x, y, size = 14, color = '#000000', weight = 'Regular' } = options;
1661
+ const style = weight === 'bold' ? 'Bold' : weight === 'medium' ? 'Medium' : 'Regular';
1662
+ return await this.eval(`
1663
+ (async function() {
1664
+ await figma.loadFontAsync({family:'Inter',style:'${style}'});
1665
+ const text = figma.createText();
1666
+ text.fontName = {family:'Inter',style:'${style}'};
1667
+ text.fontSize = ${size};
1668
+ text.characters = ${JSON.stringify(content)};
1669
+ text.fills = [{type:'SOLID',color:${this.hexToRgbCode(color)}}];
1670
+ ${x !== undefined ? `text.x = ${x};` : ''}
1671
+ ${y !== undefined ? `text.y = ${y};` : ''}
1672
+ return { id: text.id, characters: text.characters };
1673
+ })()
1674
+ `);
1675
+ }
1676
+
1677
+ /**
1678
+ * Create a line
1679
+ */
1680
+ async createLine(options = {}) {
1681
+ const { length = 100, x, y, color = '#000000', strokeWeight = 1 } = options;
1682
+ return await this.eval(`
1683
+ (function() {
1684
+ const line = figma.createLine();
1685
+ line.resize(${length}, 0);
1686
+ ${x !== undefined ? `line.x = ${x};` : ''}
1687
+ ${y !== undefined ? `line.y = ${y};` : ''}
1688
+ line.strokes = [{type:'SOLID',color:${this.hexToRgbCode(color)}}];
1689
+ line.strokeWeight = ${strokeWeight};
1690
+ return { id: line.id };
1691
+ })()
1692
+ `);
1693
+ }
1694
+
1695
+ /**
1696
+ * Create an auto-layout frame
1697
+ */
1698
+ async createAutoLayout(options = {}) {
1699
+ const {
1700
+ name = 'AutoLayout',
1701
+ direction = 'VERTICAL',
1702
+ gap = 8,
1703
+ padding = 16,
1704
+ width, height, x, y,
1705
+ fill = '#ffffff',
1706
+ radius = 0
1707
+ } = options;
1708
+ return await this.eval(`
1709
+ (function() {
1710
+ const frame = figma.createFrame();
1711
+ frame.name = ${JSON.stringify(name)};
1712
+ frame.layoutMode = '${direction === 'row' || direction === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL'}';
1713
+ frame.itemSpacing = ${gap};
1714
+ frame.paddingTop = frame.paddingBottom = frame.paddingLeft = frame.paddingRight = ${padding};
1715
+ frame.primaryAxisSizingMode = 'AUTO';
1716
+ frame.counterAxisSizingMode = 'AUTO';
1717
+ ${width ? `frame.resize(${width}, ${height || width}); frame.primaryAxisSizingMode = 'FIXED'; frame.counterAxisSizingMode = 'FIXED';` : ''}
1718
+ ${x !== undefined ? `frame.x = ${x};` : ''}
1719
+ ${y !== undefined ? `frame.y = ${y};` : ''}
1720
+ frame.cornerRadius = ${radius};
1721
+ frame.fills = [{type:'SOLID',color:${this.hexToRgbCode(fill)}}];
1722
+ return { id: frame.id, name: frame.name };
1723
+ })()
1724
+ `);
1725
+ }
1726
+
1727
+ // ============ Query & Find ============
1728
+
1729
+ /**
1730
+ * Find nodes by name (partial match)
1731
+ */
1732
+ async findByName(name, type = null) {
1733
+ return await this.eval(`
1734
+ (function() {
1735
+ const results = [];
1736
+ function search(node) {
1737
+ if (node.name && node.name.includes(${JSON.stringify(name)})) {
1738
+ ${type ? `if (node.type === '${type}')` : ''} {
1739
+ results.push({ id: node.id, type: node.type, name: node.name });
1740
+ }
1741
+ }
1742
+ if (node.children) node.children.forEach(search);
1743
+ }
1744
+ search(figma.currentPage);
1745
+ return results.slice(0, 100);
1746
+ })()
1747
+ `);
1748
+ }
1749
+
1750
+ /**
1751
+ * Find nodes by type
1752
+ */
1753
+ async findByType(type) {
1754
+ return await this.eval(`
1755
+ figma.currentPage.findAll(n => n.type === '${type}').slice(0, 100).map(n => ({
1756
+ id: n.id, name: n.name, x: Math.round(n.x), y: Math.round(n.y)
1757
+ }))
1758
+ `);
1759
+ }
1760
+
1761
+ // ============ Variables ============
1762
+
1763
+ /**
1764
+ * Create a variable
1765
+ */
1766
+ async createVariable(options = {}) {
1767
+ const { name, collectionId, type = 'COLOR', value } = options;
1768
+ return await this.eval(`
1769
+ (function() {
1770
+ const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
1771
+ if (!col) return { error: 'Collection not found' };
1772
+ const variable = figma.variables.createVariable(${JSON.stringify(name)}, col, '${type}');
1773
+ ${value ? `variable.setValueForMode(col.defaultModeId, ${type === 'COLOR' ? this.hexToRgbCode(value) : JSON.stringify(value)});` : ''}
1774
+ return { id: variable.id, name: variable.name };
1775
+ })()
1776
+ `);
1777
+ }
1778
+
1779
+ /**
1780
+ * Create a variable collection
1781
+ */
1782
+ async createCollection(name) {
1783
+ return await this.eval(`
1784
+ (function() {
1785
+ const col = figma.variables.createVariableCollection(${JSON.stringify(name)});
1786
+ return { id: col.id, name: col.name, defaultModeId: col.defaultModeId };
1787
+ })()
1788
+ `);
1789
+ }
1790
+
1791
+ /**
1792
+ * Bind a variable to a node property
1793
+ */
1794
+ async bindVariable(nodeId, property, variableName) {
1795
+ return await this.eval(`
1796
+ (function() {
1797
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
1798
+ if (!node) return { error: 'Node not found' };
1799
+
1800
+ const allVars = figma.variables.getLocalVariables();
1801
+ const variable = allVars.find(v => v.name === ${JSON.stringify(variableName)});
1802
+ if (!variable) return { error: 'Variable not found: ' + ${JSON.stringify(variableName)} };
1803
+
1804
+ const prop = ${JSON.stringify(property)};
1805
+ if (prop === 'fill' || prop === 'fills') {
1806
+ node.fills = [figma.variables.setBoundVariableForPaint(
1807
+ {type:'SOLID',color:{r:1,g:1,b:1}}, 'color', variable
1808
+ )];
1809
+ } else if (prop === 'stroke' || prop === 'strokes') {
1810
+ node.strokes = [figma.variables.setBoundVariableForPaint(
1811
+ {type:'SOLID',color:{r:0,g:0,b:0}}, 'color', variable
1812
+ )];
1813
+ } else {
1814
+ node.setBoundVariable(prop, variable);
1815
+ }
1816
+ return { success: true, nodeId: node.id, property: prop, variable: variable.name };
1817
+ })()
1818
+ `);
1819
+ }
1820
+
1821
+ // ============ Components ============
1822
+
1823
+ /**
1824
+ * Create a component from a frame
1825
+ */
1826
+ async createComponent(nodeId) {
1827
+ return await this.eval(`
1828
+ (function() {
1829
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
1830
+ if (!node) return { error: 'Node not found' };
1831
+ const component = figma.createComponentFromNode(node);
1832
+ return { id: component.id, name: component.name };
1833
+ })()
1834
+ `);
1835
+ }
1836
+
1837
+ /**
1838
+ * Create an instance of a component
1839
+ */
1840
+ async createInstance(componentId, x, y) {
1841
+ return await this.eval(`
1842
+ (function() {
1843
+ const comp = figma.getNodeById(${JSON.stringify(componentId)});
1844
+ if (!comp || comp.type !== 'COMPONENT') return { error: 'Component not found' };
1845
+ const instance = comp.createInstance();
1846
+ ${x !== undefined ? `instance.x = ${x};` : ''}
1847
+ ${y !== undefined ? `instance.y = ${y};` : ''}
1848
+ return { id: instance.id, name: instance.name, x: instance.x, y: instance.y };
1849
+ })()
1850
+ `);
1851
+ }
1852
+
1853
+ /**
1854
+ * Get all local components
1855
+ */
1856
+ async getComponents() {
1857
+ return await this.eval(`
1858
+ figma.root.findAll(n => n.type === 'COMPONENT').map(c => ({
1859
+ id: c.id, name: c.name, page: c.parent?.parent?.name
1860
+ }))
1861
+ `);
1862
+ }
1863
+
1864
+ // ============ Export ============
1865
+
1866
+ /**
1867
+ * Export a node as PNG (returns base64)
1868
+ */
1869
+ async exportPNG(nodeId, scale = 2) {
1870
+ return await this.eval(`
1871
+ (async function() {
1872
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
1873
+ if (!node) return { error: 'Node not found' };
1874
+ const bytes = await node.exportAsync({ format: 'PNG', scale: ${scale} });
1875
+ // Convert to base64
1876
+ let binary = '';
1877
+ for (let i = 0; i < bytes.length; i++) {
1878
+ binary += String.fromCharCode(bytes[i]);
1879
+ }
1880
+ return { base64: btoa(binary), width: node.width * ${scale}, height: node.height * ${scale} };
1881
+ })()
1882
+ `);
1883
+ }
1884
+
1885
+ /**
1886
+ * Export a node as SVG
1887
+ */
1888
+ async exportSVG(nodeId) {
1889
+ return await this.eval(`
1890
+ (async function() {
1891
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
1892
+ if (!node) return { error: 'Node not found' };
1893
+ const bytes = await node.exportAsync({ format: 'SVG' });
1894
+ return { svg: String.fromCharCode.apply(null, bytes) };
1895
+ })()
1896
+ `);
1897
+ }
1898
+
1899
+ // ============ Layout ============
1900
+
1901
+ /**
1902
+ * Set auto-layout on a frame
1903
+ */
1904
+ async setAutoLayout(nodeId, options = {}) {
1905
+ const { direction = 'VERTICAL', gap = 8, padding = 0 } = options;
1906
+ return await this.eval(`
1907
+ (function() {
1908
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
1909
+ if (!node || node.type !== 'FRAME') return { error: 'Frame not found' };
1910
+ node.layoutMode = '${direction === 'row' || direction === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL'}';
1911
+ node.itemSpacing = ${gap};
1912
+ node.paddingTop = node.paddingBottom = node.paddingLeft = node.paddingRight = ${padding};
1913
+ return { success: true };
1914
+ })()
1915
+ `);
1916
+ }
1917
+
1918
+ /**
1919
+ * Set sizing mode (hug/fill/fixed)
1920
+ */
1921
+ async setSizing(nodeId, horizontal = 'FIXED', vertical = 'FIXED') {
1922
+ return await this.eval(`
1923
+ (function() {
1924
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
1925
+ if (!node) return { error: 'Node not found' };
1926
+ if (node.layoutSizingHorizontal !== undefined) {
1927
+ node.layoutSizingHorizontal = '${horizontal}';
1928
+ node.layoutSizingVertical = '${vertical}';
1929
+ }
1930
+ return { success: true };
1931
+ })()
1932
+ `);
1933
+ }
1934
+
1935
+ // ============ Icon (Iconify) ============
1936
+
1937
+ /**
1938
+ * Create an icon from Iconify
1939
+ * @param {string} iconName - e.g., "lucide:star", "mdi:home"
1940
+ */
1941
+ async createIcon(iconName, options = {}) {
1942
+ const { size = 24, color = '#000000', x, y } = options;
1943
+ const [prefix, name] = iconName.split(':');
1944
+
1945
+ // Fetch SVG from Iconify API
1946
+ const response = await fetch(`https://api.iconify.design/${prefix}/${name}.svg?width=${size}&height=${size}`);
1947
+ const svg = await response.text();
1948
+
1949
+ return await this.eval(`
1950
+ (function() {
1951
+ const svgString = ${JSON.stringify(svg)};
1952
+ const node = figma.createNodeFromSvg(svgString);
1953
+ node.name = ${JSON.stringify(iconName)};
1954
+ ${x !== undefined ? `node.x = ${x};` : ''}
1955
+ ${y !== undefined ? `node.y = ${y};` : ''}
1956
+ // Apply color
1957
+ function colorize(n) {
1958
+ if (n.fills && n.fills.length > 0) {
1959
+ n.fills = [{type:'SOLID',color:${this.hexToRgbCode(color)}}];
1960
+ }
1961
+ if (n.children) n.children.forEach(colorize);
1962
+ }
1963
+ colorize(node);
1964
+ return { id: node.id, name: node.name };
1965
+ })()
1966
+ `);
1967
+ }
1968
+
1969
+ // ============ Delete All ============
1970
+
1971
+ /**
1972
+ * Delete all nodes on current page
1973
+ */
1974
+ async deleteAll() {
1975
+ return await this.eval(`
1976
+ (function() {
1977
+ const count = figma.currentPage.children.length;
1978
+ figma.currentPage.children.forEach(n => n.remove());
1979
+ return { deleted: count };
1980
+ })()
1981
+ `);
1982
+ }
1983
+
1984
+ /**
1985
+ * Zoom to fit all content
1986
+ */
1987
+ async zoomToFit() {
1988
+ return await this.eval(`
1989
+ (function() {
1990
+ figma.viewport.scrollAndZoomIntoView(figma.currentPage.children);
1991
+ return { success: true };
1992
+ })()
1993
+ `);
1994
+ }
1995
+
1996
+ /**
1997
+ * Group nodes
1998
+ */
1999
+ async groupNodes(nodeIds, name = 'Group') {
2000
+ return await this.eval(`
2001
+ (function() {
2002
+ const nodes = ${JSON.stringify(nodeIds)}.map(id => figma.getNodeById(id)).filter(n => n);
2003
+ if (nodes.length === 0) return { error: 'No nodes found' };
2004
+ const group = figma.group(nodes, figma.currentPage);
2005
+ group.name = ${JSON.stringify(name)};
2006
+ return { id: group.id, name: group.name, childCount: nodes.length };
2007
+ })()
2008
+ `);
2009
+ }
2010
+
2011
+ // ============ Team Libraries ============
2012
+
2013
+ /**
2014
+ * Get available library variable collections
2015
+ */
2016
+ async getLibraryCollections() {
2017
+ return await this.eval(`
2018
+ (async function() {
2019
+ const collections = await figma.teamLibrary.getAvailableLibraryVariableCollectionsAsync();
2020
+ return collections.map(c => ({
2021
+ key: c.key,
2022
+ name: c.name,
2023
+ libraryName: c.libraryName
2024
+ }));
2025
+ })()
2026
+ `);
2027
+ }
2028
+
2029
+ /**
2030
+ * Get variables from a library collection
2031
+ */
2032
+ async getLibraryVariables(collectionKey) {
2033
+ return await this.eval(`
2034
+ (async function() {
2035
+ const variables = await figma.teamLibrary.getVariablesInLibraryCollectionAsync(${JSON.stringify(collectionKey)});
2036
+ return variables.map(v => ({
2037
+ key: v.key,
2038
+ name: v.name,
2039
+ resolvedType: v.resolvedType
2040
+ }));
2041
+ })()
2042
+ `);
2043
+ }
2044
+
2045
+ /**
2046
+ * Import a variable from a library by key
2047
+ */
2048
+ async importLibraryVariable(variableKey) {
2049
+ return await this.eval(`
2050
+ (async function() {
2051
+ const variable = await figma.variables.importVariableByKeyAsync(${JSON.stringify(variableKey)});
2052
+ return { id: variable.id, name: variable.name, resolvedType: variable.resolvedType };
2053
+ })()
2054
+ `);
2055
+ }
2056
+
2057
+ /**
2058
+ * Get available library components
2059
+ */
2060
+ async getLibraryComponents() {
2061
+ return await this.eval(`
2062
+ (async function() {
2063
+ // Get all component sets and components from enabled libraries
2064
+ const components = [];
2065
+
2066
+ // Search through all pages for component instances to find library components
2067
+ const instances = figma.root.findAll(n => n.type === 'INSTANCE');
2068
+ const seen = new Set();
2069
+
2070
+ for (const instance of instances) {
2071
+ const mainComponent = await instance.getMainComponentAsync();
2072
+ if (mainComponent && mainComponent.remote && !seen.has(mainComponent.key)) {
2073
+ seen.add(mainComponent.key);
2074
+ components.push({
2075
+ key: mainComponent.key,
2076
+ name: mainComponent.name,
2077
+ description: mainComponent.description || ''
2078
+ });
2079
+ }
2080
+ }
2081
+
2082
+ return components;
2083
+ })()
2084
+ `);
2085
+ }
2086
+
2087
+ /**
2088
+ * Import a component from a library by key
2089
+ */
2090
+ async importLibraryComponent(componentKey) {
2091
+ return await this.eval(`
2092
+ (async function() {
2093
+ const component = await figma.importComponentByKeyAsync(${JSON.stringify(componentKey)});
2094
+ return { id: component.id, name: component.name, key: component.key };
2095
+ })()
2096
+ `);
2097
+ }
2098
+
2099
+ /**
2100
+ * Create an instance of a library component
2101
+ */
2102
+ async createLibraryInstance(componentKey, x, y) {
2103
+ return await this.eval(`
2104
+ (async function() {
2105
+ const component = await figma.importComponentByKeyAsync(${JSON.stringify(componentKey)});
2106
+ const instance = component.createInstance();
2107
+ ${x !== undefined ? `instance.x = ${x};` : ''}
2108
+ ${y !== undefined ? `instance.y = ${y};` : ''}
2109
+ return { id: instance.id, name: instance.name, x: instance.x, y: instance.y };
2110
+ })()
2111
+ `);
2112
+ }
2113
+
2114
+ /**
2115
+ * Get available library styles (color, text, effect)
2116
+ */
2117
+ async getLibraryStyles() {
2118
+ return await this.eval(`
2119
+ (async function() {
2120
+ const styles = {
2121
+ paint: [],
2122
+ text: [],
2123
+ effect: [],
2124
+ grid: []
2125
+ };
2126
+
2127
+ // Get local styles that reference library
2128
+ const paintStyles = figma.getLocalPaintStyles();
2129
+ const textStyles = figma.getLocalTextStyles();
2130
+ const effectStyles = figma.getLocalEffectStyles();
2131
+ const gridStyles = figma.getLocalGridStyles();
2132
+
2133
+ paintStyles.forEach(s => {
2134
+ styles.paint.push({ id: s.id, name: s.name, key: s.key, remote: s.remote });
2135
+ });
2136
+ textStyles.forEach(s => {
2137
+ styles.text.push({ id: s.id, name: s.name, key: s.key, remote: s.remote });
2138
+ });
2139
+ effectStyles.forEach(s => {
2140
+ styles.effect.push({ id: s.id, name: s.name, key: s.key, remote: s.remote });
2141
+ });
2142
+ gridStyles.forEach(s => {
2143
+ styles.grid.push({ id: s.id, name: s.name, key: s.key, remote: s.remote });
2144
+ });
2145
+
2146
+ return styles;
2147
+ })()
2148
+ `);
2149
+ }
2150
+
2151
+ /**
2152
+ * Import a style from a library by key
2153
+ */
2154
+ async importLibraryStyle(styleKey) {
2155
+ return await this.eval(`
2156
+ (async function() {
2157
+ const style = await figma.importStyleByKeyAsync(${JSON.stringify(styleKey)});
2158
+ return { id: style.id, name: style.name, type: style.type };
2159
+ })()
2160
+ `);
2161
+ }
2162
+
2163
+ /**
2164
+ * Apply a library style to a node
2165
+ */
2166
+ async applyLibraryStyle(nodeId, styleKey, styleType = 'fill') {
2167
+ return await this.eval(`
2168
+ (async function() {
2169
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
2170
+ if (!node) return { error: 'Node not found' };
2171
+
2172
+ const style = await figma.importStyleByKeyAsync(${JSON.stringify(styleKey)});
2173
+ const type = ${JSON.stringify(styleType)};
2174
+
2175
+ if (type === 'fill' && 'fillStyleId' in node) {
2176
+ node.fillStyleId = style.id;
2177
+ } else if (type === 'stroke' && 'strokeStyleId' in node) {
2178
+ node.strokeStyleId = style.id;
2179
+ } else if (type === 'text' && 'textStyleId' in node) {
2180
+ node.textStyleId = style.id;
2181
+ } else if (type === 'effect' && 'effectStyleId' in node) {
2182
+ node.effectStyleId = style.id;
2183
+ }
2184
+
2185
+ return { success: true, styleId: style.id, styleName: style.name };
2186
+ })()
2187
+ `);
2188
+ }
2189
+
2190
+ /**
2191
+ * Bind a library variable to a node
2192
+ */
2193
+ async bindLibraryVariable(nodeId, property, variableKey) {
2194
+ return await this.eval(`
2195
+ (async function() {
2196
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
2197
+ if (!node) return { error: 'Node not found' };
2198
+
2199
+ const variable = await figma.variables.importVariableByKeyAsync(${JSON.stringify(variableKey)});
2200
+ const prop = ${JSON.stringify(property)};
2201
+
2202
+ if (prop === 'fill' || prop === 'fills') {
2203
+ node.fills = [figma.variables.setBoundVariableForPaint(
2204
+ {type:'SOLID',color:{r:1,g:1,b:1}}, 'color', variable
2205
+ )];
2206
+ } else if (prop === 'stroke' || prop === 'strokes') {
2207
+ node.strokes = [figma.variables.setBoundVariableForPaint(
2208
+ {type:'SOLID',color:{r:0,g:0,b:0}}, 'color', variable
2209
+ )];
2210
+ } else {
2211
+ node.setBoundVariable(prop, variable);
2212
+ }
2213
+
2214
+ return { success: true, variableId: variable.id, variableName: variable.name };
2215
+ })()
2216
+ `);
2217
+ }
2218
+
2219
+ /**
2220
+ * List all enabled libraries
2221
+ */
2222
+ async getEnabledLibraries() {
2223
+ return await this.eval(`
2224
+ (async function() {
2225
+ const collections = await figma.teamLibrary.getAvailableLibraryVariableCollectionsAsync();
2226
+ const libraries = new Map();
2227
+
2228
+ collections.forEach(c => {
2229
+ if (!libraries.has(c.libraryName)) {
2230
+ libraries.set(c.libraryName, { name: c.libraryName, collections: [] });
2231
+ }
2232
+ libraries.get(c.libraryName).collections.push({ key: c.key, name: c.name });
2233
+ });
2234
+
2235
+ return Array.from(libraries.values());
2236
+ })()
2237
+ `);
2238
+ }
2239
+
2240
+ /**
2241
+ * Swap a component instance to another library component
2242
+ */
2243
+ async swapComponent(instanceId, newComponentKey) {
2244
+ return await this.eval(`
2245
+ (async function() {
2246
+ const instance = figma.getNodeById(${JSON.stringify(instanceId)});
2247
+ if (!instance || instance.type !== 'INSTANCE') return { error: 'Instance not found' };
2248
+
2249
+ const newComponent = await figma.importComponentByKeyAsync(${JSON.stringify(newComponentKey)});
2250
+ instance.swapComponent(newComponent);
2251
+
2252
+ return { success: true, newComponentName: newComponent.name };
2253
+ })()
2254
+ `);
2255
+ }
2256
+
2257
+ // ============ Designer Utilities ============
2258
+
2259
+ /**
2260
+ * Batch rename layers with pattern
2261
+ * Patterns: {n} = number, {name} = original name, {type} = node type
2262
+ */
2263
+ async batchRename(nodeIds, pattern, options = {}) {
2264
+ const { startNumber = 1, case: textCase = null } = options;
2265
+ return await this.eval(`
2266
+ (function() {
2267
+ const ids = ${JSON.stringify(nodeIds)};
2268
+ const pattern = ${JSON.stringify(pattern)};
2269
+ let num = ${startNumber};
2270
+ const results = [];
2271
+
2272
+ ids.forEach(id => {
2273
+ const node = figma.getNodeById(id);
2274
+ if (!node) return;
2275
+
2276
+ let newName = pattern
2277
+ .replace(/{n}/g, num)
2278
+ .replace(/{name}/g, node.name)
2279
+ .replace(/{type}/g, node.type.toLowerCase());
2280
+
2281
+ ${textCase === 'camel' ? "newName = newName.replace(/[-_\\s]+(\\w)/g, (_, c) => c.toUpperCase()).replace(/^\\w/, c => c.toLowerCase());" : ''}
2282
+ ${textCase === 'pascal' ? "newName = newName.replace(/[-_\\s]+(\\w)/g, (_, c) => c.toUpperCase()).replace(/^\\w/, c => c.toUpperCase());" : ''}
2283
+ ${textCase === 'snake' ? "newName = newName.replace(/[\\s-]+/g, '_').toLowerCase();" : ''}
2284
+ ${textCase === 'kebab' ? "newName = newName.replace(/[\\s_]+/g, '-').toLowerCase();" : ''}
2285
+
2286
+ node.name = newName;
2287
+ results.push({ id: node.id, name: newName });
2288
+ num++;
2289
+ });
2290
+
2291
+ return results;
2292
+ })()
2293
+ `);
2294
+ }
2295
+
2296
+ /**
2297
+ * Rename all children of a node
2298
+ */
2299
+ async batchRenameChildren(parentId, pattern, options = {}) {
2300
+ return await this.eval(`
2301
+ (function() {
2302
+ const parent = figma.getNodeById(${JSON.stringify(parentId)});
2303
+ if (!parent || !parent.children) return { error: 'Parent not found or has no children' };
2304
+
2305
+ const ids = parent.children.map(c => c.id);
2306
+ return ids;
2307
+ })()
2308
+ `).then(ids => this.batchRename(ids, pattern, options));
2309
+ }
2310
+
2311
+ /**
2312
+ * Generate lorem ipsum text
2313
+ */
2314
+ async loremIpsum(options = {}) {
2315
+ const { type = 'paragraph', count = 1 } = options;
2316
+ const lorem = {
2317
+ words: ['lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing', 'elit', 'sed', 'do', 'eiusmod', 'tempor', 'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua', 'enim', 'ad', 'minim', 'veniam', 'quis', 'nostrud', 'exercitation', 'ullamco', 'laboris', 'nisi', 'aliquip', 'ex', 'ea', 'commodo', 'consequat'],
2318
+ paragraph: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.'
2319
+ };
2320
+
2321
+ if (type === 'words') {
2322
+ const words = [];
2323
+ for (let i = 0; i < count; i++) {
2324
+ words.push(lorem.words[Math.floor(Math.random() * lorem.words.length)]);
2325
+ }
2326
+ return words.join(' ');
2327
+ } else if (type === 'sentences') {
2328
+ const sentences = [];
2329
+ for (let i = 0; i < count; i++) {
2330
+ const wordCount = 8 + Math.floor(Math.random() * 8);
2331
+ const words = [];
2332
+ for (let j = 0; j < wordCount; j++) {
2333
+ words.push(lorem.words[Math.floor(Math.random() * lorem.words.length)]);
2334
+ }
2335
+ words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1);
2336
+ sentences.push(words.join(' ') + '.');
2337
+ }
2338
+ return sentences.join(' ');
2339
+ } else {
2340
+ return Array(count).fill(lorem.paragraph).join('\n\n');
2341
+ }
2342
+ }
2343
+
2344
+ /**
2345
+ * Fill text layer with lorem ipsum
2346
+ */
2347
+ async fillWithLorem(nodeId, options = {}) {
2348
+ const text = await this.loremIpsum(options);
2349
+ return await this.eval(`
2350
+ (async function() {
2351
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
2352
+ if (!node || node.type !== 'TEXT') return { error: 'Text node not found' };
2353
+
2354
+ await figma.loadFontAsync(node.fontName);
2355
+ node.characters = ${JSON.stringify(text)};
2356
+ return { success: true, text: node.characters };
2357
+ })()
2358
+ `);
2359
+ }
2360
+
2361
+ /**
2362
+ * Insert image from URL (Unsplash, etc.)
2363
+ */
2364
+ async insertImage(imageUrl, options = {}) {
2365
+ const { x = 0, y = 0, width = 400, height = 300, name = 'Image' } = options;
2366
+
2367
+ // Fetch image and convert to base64
2368
+ const response = await fetch(imageUrl);
2369
+ const buffer = await response.arrayBuffer();
2370
+ const base64 = Buffer.from(buffer).toString('base64');
2371
+
2372
+ return await this.eval(`
2373
+ (async function() {
2374
+ const imageData = Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0));
2375
+ const image = figma.createImage(imageData);
2376
+
2377
+ const rect = figma.createRectangle();
2378
+ rect.name = ${JSON.stringify(name)};
2379
+ rect.x = ${x};
2380
+ rect.y = ${y};
2381
+ rect.resize(${width}, ${height});
2382
+ rect.fills = [{
2383
+ type: 'IMAGE',
2384
+ scaleMode: 'FILL',
2385
+ imageHash: image.hash
2386
+ }];
2387
+
2388
+ return { id: rect.id, name: rect.name, imageHash: image.hash };
2389
+ })()
2390
+ `);
2391
+ }
2392
+
2393
+ /**
2394
+ * Insert random Unsplash image
2395
+ */
2396
+ async insertUnsplash(query, options = {}) {
2397
+ const { width = 800, height = 600 } = options;
2398
+ const imageUrl = `https://source.unsplash.com/random/${width}x${height}/?${encodeURIComponent(query)}`;
2399
+ return await this.insertImage(imageUrl, { ...options, width, height, name: `Unsplash: ${query}` });
2400
+ }
2401
+
2402
+ /**
2403
+ * Export node in multiple sizes (@1x, @2x, @3x)
2404
+ */
2405
+ async exportMultipleSizes(nodeId, options = {}) {
2406
+ const { scales = [1, 2, 3], format = 'PNG' } = options;
2407
+ const results = [];
2408
+
2409
+ for (const scale of scales) {
2410
+ const result = await this.eval(`
2411
+ (async function() {
2412
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
2413
+ if (!node) return { error: 'Node not found' };
2414
+
2415
+ const bytes = await node.exportAsync({ format: '${format}', scale: ${scale} });
2416
+ let binary = '';
2417
+ for (let i = 0; i < bytes.length; i++) {
2418
+ binary += String.fromCharCode(bytes[i]);
2419
+ }
2420
+ return {
2421
+ scale: ${scale},
2422
+ suffix: '@${scale}x',
2423
+ base64: btoa(binary),
2424
+ width: Math.round(node.width * ${scale}),
2425
+ height: Math.round(node.height * ${scale})
2426
+ };
2427
+ })()
2428
+ `);
2429
+ results.push(result);
2430
+ }
2431
+
2432
+ return results;
2433
+ }
2434
+
2435
+ /**
2436
+ * Check contrast ratio between two colors (WCAG)
2437
+ */
2438
+ checkContrast(color1, color2) {
2439
+ const getLuminance = (hex) => {
2440
+ const rgb = [
2441
+ parseInt(hex.slice(1, 3), 16) / 255,
2442
+ parseInt(hex.slice(3, 5), 16) / 255,
2443
+ parseInt(hex.slice(5, 7), 16) / 255
2444
+ ].map(c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
2445
+ return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
2446
+ };
2447
+
2448
+ const l1 = getLuminance(color1);
2449
+ const l2 = getLuminance(color2);
2450
+ const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
2451
+
2452
+ return {
2453
+ ratio: Math.round(ratio * 100) / 100,
2454
+ AA: ratio >= 4.5,
2455
+ AALarge: ratio >= 3,
2456
+ AAA: ratio >= 7,
2457
+ AAALarge: ratio >= 4.5
2458
+ };
2459
+ }
2460
+
2461
+ /**
2462
+ * Check contrast of text node against background
2463
+ */
2464
+ async checkNodeContrast(textNodeId) {
2465
+ return await this.eval(`
2466
+ (function() {
2467
+ const textNode = figma.getNodeById(${JSON.stringify(textNodeId)});
2468
+ if (!textNode || textNode.type !== 'TEXT') return { error: 'Text node not found' };
2469
+
2470
+ // Get text color
2471
+ const textFill = textNode.fills[0];
2472
+ if (!textFill || textFill.type !== 'SOLID') return { error: 'Text has no solid fill' };
2473
+ const textColor = textFill.color;
2474
+
2475
+ // Find background (parent frame)
2476
+ let parent = textNode.parent;
2477
+ let bgColor = null;
2478
+ while (parent && !bgColor) {
2479
+ if (parent.fills && parent.fills.length > 0) {
2480
+ const fill = parent.fills.find(f => f.type === 'SOLID' && f.visible !== false);
2481
+ if (fill) bgColor = fill.color;
2482
+ }
2483
+ parent = parent.parent;
2484
+ }
2485
+
2486
+ if (!bgColor) bgColor = { r: 1, g: 1, b: 1 }; // Default white
2487
+
2488
+ const toHex = (c) => '#' +
2489
+ Math.round(c.r * 255).toString(16).padStart(2, '0') +
2490
+ Math.round(c.g * 255).toString(16).padStart(2, '0') +
2491
+ Math.round(c.b * 255).toString(16).padStart(2, '0');
2492
+
2493
+ return {
2494
+ textColor: toHex(textColor),
2495
+ bgColor: toHex(bgColor),
2496
+ nodeId: textNode.id,
2497
+ nodeName: textNode.name
2498
+ };
2499
+ })()
2500
+ `).then(result => {
2501
+ if (result.error) return result;
2502
+ const contrast = this.checkContrast(result.textColor, result.bgColor);
2503
+ return { ...result, ...contrast };
2504
+ });
2505
+ }
2506
+
2507
+ /**
2508
+ * Find and replace text in all text nodes
2509
+ */
2510
+ async findReplaceText(find, replace, options = {}) {
2511
+ const { caseSensitive = false, wholeWord = false } = options;
2512
+ return await this.eval(`
2513
+ (async function() {
2514
+ const textNodes = figma.currentPage.findAll(n => n.type === 'TEXT');
2515
+ const results = [];
2516
+ const findStr = ${JSON.stringify(find)};
2517
+ const replaceStr = ${JSON.stringify(replace)};
2518
+ const caseSensitive = ${caseSensitive};
2519
+ const wholeWord = ${wholeWord};
2520
+
2521
+ for (const node of textNodes) {
2522
+ let text = node.characters;
2523
+ let pattern = caseSensitive ? findStr : findStr.toLowerCase();
2524
+ let searchText = caseSensitive ? text : text.toLowerCase();
2525
+
2526
+ if (wholeWord) {
2527
+ pattern = '\\\\b' + pattern + '\\\\b';
2528
+ }
2529
+
2530
+ const regex = new RegExp(pattern, caseSensitive ? 'g' : 'gi');
2531
+ if (regex.test(searchText)) {
2532
+ await figma.loadFontAsync(node.fontName);
2533
+ node.characters = text.replace(new RegExp(findStr, caseSensitive ? 'g' : 'gi'), replaceStr);
2534
+ results.push({ id: node.id, name: node.name, newText: node.characters });
2535
+ }
2536
+ }
2537
+
2538
+ return { replaced: results.length, nodes: results };
2539
+ })()
2540
+ `);
2541
+ }
2542
+
2543
+ /**
2544
+ * Select all nodes with same fill color
2545
+ */
2546
+ async selectSameFill(nodeId) {
2547
+ return await this.eval(`
2548
+ (function() {
2549
+ const refNode = figma.getNodeById(${JSON.stringify(nodeId)});
2550
+ if (!refNode || !refNode.fills || refNode.fills.length === 0) return { error: 'Node has no fill' };
2551
+
2552
+ const refFill = refNode.fills[0];
2553
+ if (refFill.type !== 'SOLID') return { error: 'Reference fill is not solid' };
2554
+
2555
+ const matches = figma.currentPage.findAll(n => {
2556
+ if (!n.fills || n.fills.length === 0) return false;
2557
+ const fill = n.fills[0];
2558
+ if (fill.type !== 'SOLID') return false;
2559
+ return Math.abs(fill.color.r - refFill.color.r) < 0.01 &&
2560
+ Math.abs(fill.color.g - refFill.color.g) < 0.01 &&
2561
+ Math.abs(fill.color.b - refFill.color.b) < 0.01;
2562
+ });
2563
+
2564
+ figma.currentPage.selection = matches;
2565
+ return { selected: matches.length, ids: matches.map(n => n.id) };
2566
+ })()
2567
+ `);
2568
+ }
2569
+
2570
+ /**
2571
+ * Select all nodes with same stroke color
2572
+ */
2573
+ async selectSameStroke(nodeId) {
2574
+ return await this.eval(`
2575
+ (function() {
2576
+ const refNode = figma.getNodeById(${JSON.stringify(nodeId)});
2577
+ if (!refNode || !refNode.strokes || refNode.strokes.length === 0) return { error: 'Node has no stroke' };
2578
+
2579
+ const refStroke = refNode.strokes[0];
2580
+ if (refStroke.type !== 'SOLID') return { error: 'Reference stroke is not solid' };
2581
+
2582
+ const matches = figma.currentPage.findAll(n => {
2583
+ if (!n.strokes || n.strokes.length === 0) return false;
2584
+ const stroke = n.strokes[0];
2585
+ if (stroke.type !== 'SOLID') return false;
2586
+ return Math.abs(stroke.color.r - refStroke.color.r) < 0.01 &&
2587
+ Math.abs(stroke.color.g - refStroke.color.g) < 0.01 &&
2588
+ Math.abs(stroke.color.b - refStroke.color.b) < 0.01;
2589
+ });
2590
+
2591
+ figma.currentPage.selection = matches;
2592
+ return { selected: matches.length, ids: matches.map(n => n.id) };
2593
+ })()
2594
+ `);
2595
+ }
2596
+
2597
+ /**
2598
+ * Select all text nodes with same font
2599
+ */
2600
+ async selectSameFont(nodeId) {
2601
+ return await this.eval(`
2602
+ (function() {
2603
+ const refNode = figma.getNodeById(${JSON.stringify(nodeId)});
2604
+ if (!refNode || refNode.type !== 'TEXT') return { error: 'Not a text node' };
2605
+
2606
+ const refFont = refNode.fontName;
2607
+ const refSize = refNode.fontSize;
2608
+
2609
+ const matches = figma.currentPage.findAll(n => {
2610
+ if (n.type !== 'TEXT') return false;
2611
+ return n.fontName.family === refFont.family &&
2612
+ n.fontName.style === refFont.style &&
2613
+ n.fontSize === refSize;
2614
+ });
2615
+
2616
+ figma.currentPage.selection = matches;
2617
+ return { selected: matches.length, ids: matches.map(n => n.id) };
2618
+ })()
2619
+ `);
2620
+ }
2621
+
2622
+ /**
2623
+ * Select all nodes of same type and size
2624
+ */
2625
+ async selectSameSize(nodeId) {
2626
+ return await this.eval(`
2627
+ (function() {
2628
+ const refNode = figma.getNodeById(${JSON.stringify(nodeId)});
2629
+ if (!refNode) return { error: 'Node not found' };
2630
+
2631
+ const matches = figma.currentPage.findAll(n => {
2632
+ return n.type === refNode.type &&
2633
+ Math.abs(n.width - refNode.width) < 1 &&
2634
+ Math.abs(n.height - refNode.height) < 1;
2635
+ });
2636
+
2637
+ figma.currentPage.selection = matches;
2638
+ return { selected: matches.length, ids: matches.map(n => n.id) };
2639
+ })()
2640
+ `);
2641
+ }
2642
+
2643
+ /**
2644
+ * Simulate color blindness on a frame (creates a copy with filters)
2645
+ */
2646
+ async simulateColorBlindness(nodeId, type = 'deuteranopia') {
2647
+ const matrices = {
2648
+ deuteranopia: [0.625, 0.375, 0, 0, 0, 0.7, 0.3, 0, 0, 0, 0, 0.3, 0.7, 0, 0, 0, 0, 0, 1, 0],
2649
+ protanopia: [0.567, 0.433, 0, 0, 0, 0.558, 0.442, 0, 0, 0, 0, 0.242, 0.758, 0, 0, 0, 0, 0, 1, 0],
2650
+ tritanopia: [0.95, 0.05, 0, 0, 0, 0, 0.433, 0.567, 0, 0, 0, 0.475, 0.525, 0, 0, 0, 0, 0, 1, 0],
2651
+ grayscale: [0.299, 0.587, 0.114, 0, 0, 0.299, 0.587, 0.114, 0, 0, 0.299, 0.587, 0.114, 0, 0, 0, 0, 0, 1, 0]
2652
+ };
2653
+
2654
+ const matrix = matrices[type] || matrices.deuteranopia;
2655
+
2656
+ return await this.eval(`
2657
+ (function() {
2658
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
2659
+ if (!node) return { error: 'Node not found' };
2660
+
2661
+ const clone = node.clone();
2662
+ clone.name = node.name + ' (${type})';
2663
+ clone.x = node.x + node.width + 50;
2664
+
2665
+ // Apply as layer blur with color matrix (simplified simulation)
2666
+ // Note: Figma doesn't have native color matrix, this is a visual approximation
2667
+ clone.opacity = 0.9;
2668
+
2669
+ return { id: clone.id, name: clone.name, type: '${type}' };
2670
+ })()
2671
+ `);
2672
+ }
2673
+
2674
+ // ============ Export to JSX ============
2675
+
2676
+ /**
2677
+ * Export a node to JSX code
2678
+ */
2679
+ async exportToJSX(nodeId, options = {}) {
2680
+ const { pretty = true } = options;
2681
+ return await this.eval(`
2682
+ (function() {
2683
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
2684
+ if (!node) return { error: 'Node not found' };
2685
+
2686
+ function rgbToHex(r, g, b) {
2687
+ return '#' + [r, g, b].map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('');
2688
+ }
2689
+
2690
+ function nodeToJSX(n, indent = 0) {
2691
+ const pad = ${pretty} ? ' '.repeat(indent) : '';
2692
+ const nl = ${pretty} ? '\\n' : '';
2693
+
2694
+ let tag = 'Frame';
2695
+ if (n.type === 'TEXT') tag = 'Text';
2696
+ else if (n.type === 'RECTANGLE') tag = 'Rectangle';
2697
+ else if (n.type === 'ELLIPSE') tag = 'Ellipse';
2698
+ else if (n.type === 'LINE') tag = 'Line';
2699
+ else if (n.type === 'VECTOR') tag = 'Vector';
2700
+ else if (n.type === 'COMPONENT') tag = 'Component';
2701
+ else if (n.type === 'INSTANCE') tag = 'Instance';
2702
+
2703
+ const props = [];
2704
+ if (n.name) props.push('name="' + n.name + '"');
2705
+ if (n.width) props.push('w={' + Math.round(n.width) + '}');
2706
+ if (n.height) props.push('h={' + Math.round(n.height) + '}');
2707
+
2708
+ if (n.fills && n.fills.length > 0 && n.fills[0].type === 'SOLID') {
2709
+ const c = n.fills[0].color;
2710
+ props.push('bg="' + rgbToHex(c.r, c.g, c.b) + '"');
2711
+ }
2712
+
2713
+ if (n.cornerRadius && n.cornerRadius > 0) {
2714
+ props.push('rounded={' + n.cornerRadius + '}');
2715
+ }
2716
+
2717
+ if (n.layoutMode === 'HORIZONTAL') props.push('flex="row"');
2718
+ if (n.layoutMode === 'VERTICAL') props.push('flex="col"');
2719
+ if (n.itemSpacing) props.push('gap={' + n.itemSpacing + '}');
2720
+ if (n.paddingTop) props.push('p={' + n.paddingTop + '}');
2721
+
2722
+ if (n.type === 'TEXT') {
2723
+ const fontSize = n.fontSize || 14;
2724
+ props.push('size={' + fontSize + '}');
2725
+ if (n.fontName && n.fontName.style) {
2726
+ const weight = n.fontName.style.toLowerCase();
2727
+ if (weight.includes('bold')) props.push('weight="bold"');
2728
+ else if (weight.includes('medium')) props.push('weight="medium"');
2729
+ }
2730
+ if (n.fills && n.fills[0] && n.fills[0].type === 'SOLID') {
2731
+ const c = n.fills[0].color;
2732
+ props.push('color="' + rgbToHex(c.r, c.g, c.b) + '"');
2733
+ }
2734
+ return pad + '<Text ' + props.join(' ') + '>' + (n.characters || '') + '</Text>';
2735
+ }
2736
+
2737
+ const hasChildren = n.children && n.children.length > 0;
2738
+ const propsStr = props.length > 0 ? ' ' + props.join(' ') : '';
2739
+
2740
+ if (!hasChildren) {
2741
+ return pad + '<' + tag + propsStr + ' />';
2742
+ }
2743
+
2744
+ const childrenJSX = n.children.map(c => nodeToJSX(c, indent + 1)).join(nl);
2745
+ return pad + '<' + tag + propsStr + '>' + nl + childrenJSX + nl + pad + '</' + tag + '>';
2746
+ }
2747
+
2748
+ return { jsx: nodeToJSX(node) };
2749
+ })()
2750
+ `);
2751
+ }
2752
+
2753
+ /**
2754
+ * Export component to Storybook story
2755
+ */
2756
+ async exportToStorybook(nodeId) {
2757
+ const jsxResult = await this.exportToJSX(nodeId);
2758
+ if (jsxResult.error) return jsxResult;
2759
+
2760
+ const nodeInfo = await this.getNode(nodeId);
2761
+ const componentName = (nodeInfo.name || 'Component').replace(/[^a-zA-Z0-9]/g, '');
2762
+
2763
+ const story = `import type { Meta, StoryObj } from '@storybook/react';
2764
+
2765
+ // Auto-generated from Figma
2766
+ const ${componentName} = () => (
2767
+ ${jsxResult.jsx.split('\n').map(l => ' ' + l).join('\n')}
2768
+ );
2769
+
2770
+ const meta: Meta<typeof ${componentName}> = {
2771
+ title: 'Components/${componentName}',
2772
+ component: ${componentName},
2773
+ };
2774
+
2775
+ export default meta;
2776
+ type Story = StoryObj<typeof ${componentName}>;
2777
+
2778
+ export const Default: Story = {};
2779
+ `;
2780
+
2781
+ return { story, componentName };
2782
+ }
2783
+
2784
+ // ============ Visual Diff ============
2785
+
2786
+ /**
2787
+ * Compare two nodes visually (returns diff info)
2788
+ */
2789
+ async visualDiff(nodeId1, nodeId2) {
2790
+ return await this.eval(`
2791
+ (async function() {
2792
+ const node1 = figma.getNodeById(${JSON.stringify(nodeId1)});
2793
+ const node2 = figma.getNodeById(${JSON.stringify(nodeId2)});
2794
+
2795
+ if (!node1 || !node2) return { error: 'One or both nodes not found' };
2796
+
2797
+ const differences = [];
2798
+
2799
+ // Compare basic properties
2800
+ if (node1.width !== node2.width || node1.height !== node2.height) {
2801
+ differences.push({
2802
+ property: 'size',
2803
+ from: node1.width + 'x' + node1.height,
2804
+ to: node2.width + 'x' + node2.height
2805
+ });
2806
+ }
2807
+
2808
+ if (JSON.stringify(node1.fills) !== JSON.stringify(node2.fills)) {
2809
+ differences.push({ property: 'fills', changed: true });
2810
+ }
2811
+
2812
+ if (JSON.stringify(node1.strokes) !== JSON.stringify(node2.strokes)) {
2813
+ differences.push({ property: 'strokes', changed: true });
2814
+ }
2815
+
2816
+ if (node1.cornerRadius !== node2.cornerRadius) {
2817
+ differences.push({
2818
+ property: 'cornerRadius',
2819
+ from: node1.cornerRadius,
2820
+ to: node2.cornerRadius
2821
+ });
2822
+ }
2823
+
2824
+ if (node1.opacity !== node2.opacity) {
2825
+ differences.push({
2826
+ property: 'opacity',
2827
+ from: node1.opacity,
2828
+ to: node2.opacity
2829
+ });
2830
+ }
2831
+
2832
+ // Compare children count
2833
+ const children1 = node1.children ? node1.children.length : 0;
2834
+ const children2 = node2.children ? node2.children.length : 0;
2835
+ if (children1 !== children2) {
2836
+ differences.push({
2837
+ property: 'childCount',
2838
+ from: children1,
2839
+ to: children2
2840
+ });
2841
+ }
2842
+
2843
+ return {
2844
+ node1: { id: node1.id, name: node1.name },
2845
+ node2: { id: node2.id, name: node2.name },
2846
+ identical: differences.length === 0,
2847
+ differences
2848
+ };
2849
+ })()
2850
+ `);
2851
+ }
2852
+
2853
+ /**
2854
+ * Create a structural diff patch between two nodes
2855
+ */
2856
+ async createDiffPatch(fromId, toId) {
2857
+ return await this.eval(`
2858
+ (function() {
2859
+ const from = figma.getNodeById(${JSON.stringify(fromId)});
2860
+ const to = figma.getNodeById(${JSON.stringify(toId)});
2861
+
2862
+ if (!from || !to) return { error: 'Node not found' };
2863
+
2864
+ function getProps(n) {
2865
+ return {
2866
+ type: n.type,
2867
+ name: n.name,
2868
+ width: n.width,
2869
+ height: n.height,
2870
+ x: n.x,
2871
+ y: n.y,
2872
+ fills: n.fills,
2873
+ strokes: n.strokes,
2874
+ cornerRadius: n.cornerRadius,
2875
+ opacity: n.opacity,
2876
+ layoutMode: n.layoutMode,
2877
+ itemSpacing: n.itemSpacing
2878
+ };
2879
+ }
2880
+
2881
+ const fromProps = getProps(from);
2882
+ const toProps = getProps(to);
2883
+ const patch = [];
2884
+
2885
+ for (const key in toProps) {
2886
+ if (JSON.stringify(fromProps[key]) !== JSON.stringify(toProps[key])) {
2887
+ patch.push({ property: key, from: fromProps[key], to: toProps[key] });
2888
+ }
2889
+ }
2890
+
2891
+ return { fromId: from.id, toId: to.id, patch };
2892
+ })()
2893
+ `);
2894
+ }
2895
+
2896
+ // ============ XPath-like Query ============
2897
+
2898
+ /**
2899
+ * Query nodes with XPath-like syntax
2900
+ * Examples:
2901
+ * //FRAME - all frames
2902
+ * //TEXT[@fontSize > 20] - text larger than 20px
2903
+ * //FRAME[contains(@name, 'Card')] - frames with 'Card' in name
2904
+ * //*[@cornerRadius > 0] - any node with radius
2905
+ */
2906
+ async query(xpath) {
2907
+ return await this.eval(`
2908
+ (function() {
2909
+ const xpath = ${JSON.stringify(xpath)};
2910
+ const results = [];
2911
+
2912
+ // Parse simple XPath patterns
2913
+ const typeMatch = xpath.match(/\\/\\/([A-Z_*]+)/);
2914
+ const attrMatch = xpath.match(/@(\\w+)\\s*(=|>|<|>=|<=|!=)\\s*["']?([^"'\\]]+)["']?/);
2915
+ const containsMatch = xpath.match(/contains\\(@(\\w+),\\s*["']([^"']+)["']\\)/);
2916
+ const startsMatch = xpath.match(/starts-with\\(@(\\w+),\\s*["']([^"']+)["']\\)/);
2917
+
2918
+ const targetType = typeMatch ? typeMatch[1] : '*';
2919
+
2920
+ function matches(node) {
2921
+ // Type check
2922
+ if (targetType !== '*' && node.type !== targetType) return false;
2923
+
2924
+ // Attribute comparison
2925
+ if (attrMatch) {
2926
+ const [, attr, op, val] = attrMatch;
2927
+ const nodeVal = node[attr];
2928
+ const numVal = parseFloat(val);
2929
+
2930
+ if (op === '=' && nodeVal != val && nodeVal != numVal) return false;
2931
+ if (op === '!=' && (nodeVal == val || nodeVal == numVal)) return false;
2932
+ if (op === '>' && !(nodeVal > numVal)) return false;
2933
+ if (op === '<' && !(nodeVal < numVal)) return false;
2934
+ if (op === '>=' && !(nodeVal >= numVal)) return false;
2935
+ if (op === '<=' && !(nodeVal <= numVal)) return false;
2936
+ }
2937
+
2938
+ // contains()
2939
+ if (containsMatch) {
2940
+ const [, attr, val] = containsMatch;
2941
+ if (!node[attr] || !String(node[attr]).includes(val)) return false;
2942
+ }
2943
+
2944
+ // starts-with()
2945
+ if (startsMatch) {
2946
+ const [, attr, val] = startsMatch;
2947
+ if (!node[attr] || !String(node[attr]).startsWith(val)) return false;
2948
+ }
2949
+
2950
+ return true;
2951
+ }
2952
+
2953
+ function search(node) {
2954
+ if (matches(node)) {
2955
+ results.push({
2956
+ id: node.id,
2957
+ type: node.type,
2958
+ name: node.name || '',
2959
+ x: Math.round(node.x || 0),
2960
+ y: Math.round(node.y || 0),
2961
+ width: Math.round(node.width || 0),
2962
+ height: Math.round(node.height || 0)
2963
+ });
2964
+ }
2965
+ if (node.children) node.children.forEach(search);
2966
+ }
2967
+
2968
+ search(figma.currentPage);
2969
+ return results.slice(0, 200);
2970
+ })()
2971
+ `);
2972
+ }
2973
+
2974
+ // ============ Path/Vector Operations ============
2975
+
2976
+ /**
2977
+ * Get vector path data from a node
2978
+ */
2979
+ async getPath(nodeId) {
2980
+ return await this.eval(`
2981
+ (function() {
2982
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
2983
+ if (!node) return { error: 'Node not found' };
2984
+ if (!node.vectorPaths) return { error: 'Node has no vector paths' };
2985
+
2986
+ return {
2987
+ id: node.id,
2988
+ name: node.name,
2989
+ paths: node.vectorPaths.map(p => ({
2990
+ data: p.data,
2991
+ windingRule: p.windingRule
2992
+ }))
2993
+ };
2994
+ })()
2995
+ `);
2996
+ }
2997
+
2998
+ /**
2999
+ * Set vector path data on a node
3000
+ */
3001
+ async setPath(nodeId, pathData) {
3002
+ return await this.eval(`
3003
+ (function() {
3004
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
3005
+ if (!node) return { error: 'Node not found' };
3006
+ if (node.type !== 'VECTOR') return { error: 'Node is not a vector' };
3007
+
3008
+ node.vectorPaths = [{ data: ${JSON.stringify(pathData)}, windingRule: 'EVENODD' }];
3009
+ return { success: true };
3010
+ })()
3011
+ `);
3012
+ }
3013
+
3014
+ /**
3015
+ * Scale a vector path
3016
+ */
3017
+ async scalePath(nodeId, factor) {
3018
+ return await this.eval(`
3019
+ (function() {
3020
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
3021
+ if (!node) return { error: 'Node not found' };
3022
+
3023
+ node.rescale(${factor});
3024
+ return { success: true, newWidth: node.width, newHeight: node.height };
3025
+ })()
3026
+ `);
3027
+ }
3028
+
3029
+ /**
3030
+ * Flip a node horizontally or vertically
3031
+ */
3032
+ async flipNode(nodeId, axis = 'x') {
3033
+ return await this.eval(`
3034
+ (function() {
3035
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
3036
+ if (!node) return { error: 'Node not found' };
3037
+
3038
+ if (${JSON.stringify(axis)} === 'x') {
3039
+ // Flip horizontally
3040
+ const transform = node.relativeTransform;
3041
+ node.relativeTransform = [
3042
+ [-transform[0][0], transform[0][1], transform[0][2] + node.width],
3043
+ [transform[1][0], transform[1][1], transform[1][2]]
3044
+ ];
3045
+ } else {
3046
+ // Flip vertically
3047
+ const transform = node.relativeTransform;
3048
+ node.relativeTransform = [
3049
+ [transform[0][0], transform[0][1], transform[0][2]],
3050
+ [transform[1][0], -transform[1][1], transform[1][2] + node.height]
3051
+ ];
3052
+ }
3053
+
3054
+ return { success: true, axis: ${JSON.stringify(axis)} };
3055
+ })()
3056
+ `);
3057
+ }
3058
+
3059
+ // ============ Analyze ============
3060
+
3061
+ /**
3062
+ * Analyze colors used in the design
3063
+ */
3064
+ async analyzeColors() {
3065
+ return await this.eval(`
3066
+ (function() {
3067
+ const colorMap = new Map();
3068
+
3069
+ function rgbToHex(r, g, b) {
3070
+ return '#' + [r, g, b].map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('').toUpperCase();
3071
+ }
3072
+
3073
+ function processNode(node) {
3074
+ // Check fills
3075
+ if (node.fills && Array.isArray(node.fills)) {
3076
+ node.fills.forEach(fill => {
3077
+ if (fill.type === 'SOLID' && fill.visible !== false) {
3078
+ const hex = rgbToHex(fill.color.r, fill.color.g, fill.color.b);
3079
+ const existing = colorMap.get(hex) || { count: 0, nodes: [], hasVariable: false };
3080
+ existing.count++;
3081
+ if (existing.nodes.length < 5) existing.nodes.push(node.id);
3082
+
3083
+ // Check if bound to variable
3084
+ if (node.boundVariables && node.boundVariables.fills) {
3085
+ existing.hasVariable = true;
3086
+ }
3087
+ colorMap.set(hex, existing);
3088
+ }
3089
+ });
3090
+ }
3091
+
3092
+ // Check strokes
3093
+ if (node.strokes && Array.isArray(node.strokes)) {
3094
+ node.strokes.forEach(stroke => {
3095
+ if (stroke.type === 'SOLID' && stroke.visible !== false) {
3096
+ const hex = rgbToHex(stroke.color.r, stroke.color.g, stroke.color.b);
3097
+ const existing = colorMap.get(hex) || { count: 0, nodes: [], hasVariable: false };
3098
+ existing.count++;
3099
+ if (existing.nodes.length < 5) existing.nodes.push(node.id);
3100
+ colorMap.set(hex, existing);
3101
+ }
3102
+ });
3103
+ }
3104
+
3105
+ if (node.children) node.children.forEach(processNode);
3106
+ }
3107
+
3108
+ processNode(figma.currentPage);
3109
+
3110
+ const colors = Array.from(colorMap.entries())
3111
+ .map(([hex, data]) => ({ hex, ...data }))
3112
+ .sort((a, b) => b.count - a.count);
3113
+
3114
+ return {
3115
+ totalColors: colors.length,
3116
+ colors: colors.slice(0, 50)
3117
+ };
3118
+ })()
3119
+ `);
3120
+ }
3121
+
3122
+ /**
3123
+ * Analyze typography used in the design
3124
+ */
3125
+ async analyzeTypography() {
3126
+ return await this.eval(`
3127
+ (function() {
3128
+ const fontMap = new Map();
3129
+
3130
+ function processNode(node) {
3131
+ if (node.type === 'TEXT') {
3132
+ const key = node.fontName.family + '/' + node.fontName.style + '/' + node.fontSize;
3133
+ const existing = fontMap.get(key) || { count: 0, nodes: [] };
3134
+ existing.count++;
3135
+ existing.family = node.fontName.family;
3136
+ existing.style = node.fontName.style;
3137
+ existing.size = node.fontSize;
3138
+ if (existing.nodes.length < 5) existing.nodes.push(node.id);
3139
+ fontMap.set(key, existing);
3140
+ }
3141
+ if (node.children) node.children.forEach(processNode);
3142
+ }
3143
+
3144
+ processNode(figma.currentPage);
3145
+
3146
+ const fonts = Array.from(fontMap.values())
3147
+ .sort((a, b) => b.count - a.count);
3148
+
3149
+ return {
3150
+ totalStyles: fonts.length,
3151
+ fonts: fonts.slice(0, 30)
3152
+ };
3153
+ })()
3154
+ `);
3155
+ }
3156
+
3157
+ /**
3158
+ * Analyze spacing (gaps and padding) used in the design
3159
+ */
3160
+ async analyzeSpacing(gridBase = 8) {
3161
+ return await this.eval(`
3162
+ (function() {
3163
+ const spacingMap = new Map();
3164
+ const gridBase = ${gridBase};
3165
+
3166
+ function processNode(node) {
3167
+ if (node.layoutMode) {
3168
+ // Gap
3169
+ if (node.itemSpacing !== undefined) {
3170
+ const key = 'gap:' + node.itemSpacing;
3171
+ const existing = spacingMap.get(key) || { value: node.itemSpacing, type: 'gap', count: 0, onGrid: node.itemSpacing % gridBase === 0 };
3172
+ existing.count++;
3173
+ spacingMap.set(key, existing);
3174
+ }
3175
+
3176
+ // Padding
3177
+ const paddings = [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft].filter(p => p > 0);
3178
+ paddings.forEach(p => {
3179
+ const key = 'padding:' + p;
3180
+ const existing = spacingMap.get(key) || { value: p, type: 'padding', count: 0, onGrid: p % gridBase === 0 };
3181
+ existing.count++;
3182
+ spacingMap.set(key, existing);
3183
+ });
3184
+ }
3185
+
3186
+ if (node.children) node.children.forEach(processNode);
3187
+ }
3188
+
3189
+ processNode(figma.currentPage);
3190
+
3191
+ const spacing = Array.from(spacingMap.values())
3192
+ .sort((a, b) => b.count - a.count);
3193
+
3194
+ const offGrid = spacing.filter(s => !s.onGrid);
3195
+
3196
+ return {
3197
+ gridBase,
3198
+ totalValues: spacing.length,
3199
+ offGridCount: offGrid.length,
3200
+ spacing: spacing.slice(0, 30),
3201
+ offGrid: offGrid.slice(0, 10)
3202
+ };
3203
+ })()
3204
+ `);
3205
+ }
3206
+
3207
+ /**
3208
+ * Find repeated patterns (potential components)
3209
+ */
3210
+ async analyzeClusters() {
3211
+ return await this.eval(`
3212
+ (function() {
3213
+ const patterns = new Map();
3214
+
3215
+ function getSignature(node) {
3216
+ if (!node.children) return node.type;
3217
+
3218
+ const childTypes = node.children.map(c => c.type).sort().join(',');
3219
+ return node.type + '[' + childTypes + ']' + node.width + 'x' + node.height;
3220
+ }
3221
+
3222
+ function processNode(node) {
3223
+ if (node.type === 'FRAME' && node.children && node.children.length > 0) {
3224
+ const sig = getSignature(node);
3225
+ const existing = patterns.get(sig) || { signature: sig, count: 0, examples: [] };
3226
+ existing.count++;
3227
+ if (existing.examples.length < 5) {
3228
+ existing.examples.push({ id: node.id, name: node.name });
3229
+ }
3230
+ patterns.set(sig, existing);
3231
+ }
3232
+ if (node.children) node.children.forEach(processNode);
3233
+ }
3234
+
3235
+ processNode(figma.currentPage);
3236
+
3237
+ const clusters = Array.from(patterns.values())
3238
+ .filter(p => p.count >= 2)
3239
+ .sort((a, b) => b.count - a.count);
3240
+
3241
+ return {
3242
+ potentialComponents: clusters.length,
3243
+ clusters: clusters.slice(0, 20)
3244
+ };
3245
+ })()
3246
+ `);
3247
+ }
3248
+
3249
+ // ============ Lint ============
3250
+
3251
+ /**
3252
+ * Lint the design for common issues
3253
+ */
3254
+ async lint(options = {}) {
3255
+ const { preset = 'recommended' } = options;
3256
+ return await this.eval(`
3257
+ (function() {
3258
+ const issues = [];
3259
+ const preset = ${JSON.stringify(preset)};
3260
+
3261
+ function rgbToHex(r, g, b) {
3262
+ return '#' + [r, g, b].map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('');
3263
+ }
3264
+
3265
+ function getLuminance(r, g, b) {
3266
+ const [rs, gs, bs] = [r, g, b].map(c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
3267
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
3268
+ }
3269
+
3270
+ function getContrastRatio(c1, c2) {
3271
+ const l1 = getLuminance(c1.r, c1.g, c1.b);
3272
+ const l2 = getLuminance(c2.r, c2.g, c2.b);
3273
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
3274
+ }
3275
+
3276
+ function checkNode(node, depth = 0) {
3277
+ // No default names
3278
+ if (node.name && (node.name.startsWith('Frame ') || node.name.startsWith('Rectangle ') || node.name.startsWith('Group '))) {
3279
+ issues.push({
3280
+ nodeId: node.id,
3281
+ nodeName: node.name,
3282
+ rule: 'no-default-names',
3283
+ severity: 'warning',
3284
+ message: 'Layer has default name'
3285
+ });
3286
+ }
3287
+
3288
+ // Deeply nested
3289
+ if (depth > 10) {
3290
+ issues.push({
3291
+ nodeId: node.id,
3292
+ nodeName: node.name,
3293
+ rule: 'no-deeply-nested',
3294
+ severity: 'warning',
3295
+ message: 'Node is nested too deeply (' + depth + ' levels)'
3296
+ });
3297
+ }
3298
+
3299
+ // Empty frames
3300
+ if (node.type === 'FRAME' && (!node.children || node.children.length === 0)) {
3301
+ issues.push({
3302
+ nodeId: node.id,
3303
+ nodeName: node.name,
3304
+ rule: 'no-empty-frames',
3305
+ severity: 'info',
3306
+ message: 'Frame is empty'
3307
+ });
3308
+ }
3309
+
3310
+ // Prefer auto-layout
3311
+ if (node.type === 'FRAME' && node.children && node.children.length > 2 && !node.layoutMode) {
3312
+ issues.push({
3313
+ nodeId: node.id,
3314
+ nodeName: node.name,
3315
+ rule: 'prefer-auto-layout',
3316
+ severity: 'info',
3317
+ message: 'Frame with ' + node.children.length + ' children doesn\\'t use Auto Layout'
3318
+ });
3319
+ }
3320
+
3321
+ // Hardcoded colors (not bound to variables)
3322
+ if (node.fills && node.fills.length > 0 && node.fills[0].type === 'SOLID') {
3323
+ if (!node.boundVariables || !node.boundVariables.fills) {
3324
+ issues.push({
3325
+ nodeId: node.id,
3326
+ nodeName: node.name,
3327
+ rule: 'no-hardcoded-colors',
3328
+ severity: 'warning',
3329
+ message: 'Fill color is not bound to a variable'
3330
+ });
3331
+ }
3332
+ }
3333
+
3334
+ // Text contrast check
3335
+ if (node.type === 'TEXT' && node.fills && node.fills[0] && node.fills[0].type === 'SOLID') {
3336
+ let parent = node.parent;
3337
+ let bgColor = null;
3338
+ while (parent && !bgColor) {
3339
+ if (parent.fills && parent.fills.length > 0 && parent.fills[0].type === 'SOLID') {
3340
+ bgColor = parent.fills[0].color;
3341
+ }
3342
+ parent = parent.parent;
3343
+ }
3344
+ if (bgColor) {
3345
+ const textColor = node.fills[0].color;
3346
+ const ratio = getContrastRatio(textColor, bgColor);
3347
+ if (ratio < 4.5) {
3348
+ issues.push({
3349
+ nodeId: node.id,
3350
+ nodeName: node.name,
3351
+ rule: 'color-contrast',
3352
+ severity: 'error',
3353
+ message: 'Contrast ratio ' + ratio.toFixed(1) + ':1 is below AA threshold (4.5:1)'
3354
+ });
3355
+ }
3356
+ }
3357
+ }
3358
+
3359
+ // Touch target size
3360
+ if ((node.type === 'FRAME' || node.type === 'INSTANCE') && node.name && (node.name.toLowerCase().includes('button') || node.name.toLowerCase().includes('link'))) {
3361
+ if (node.width < 44 || node.height < 44) {
3362
+ issues.push({
3363
+ nodeId: node.id,
3364
+ nodeName: node.name,
3365
+ rule: 'touch-target-size',
3366
+ severity: 'warning',
3367
+ message: 'Touch target ' + Math.round(node.width) + 'x' + Math.round(node.height) + ' is below minimum 44x44'
3368
+ });
3369
+ }
3370
+ }
3371
+
3372
+ // Min text size
3373
+ if (node.type === 'TEXT' && node.fontSize < 12) {
3374
+ issues.push({
3375
+ nodeId: node.id,
3376
+ nodeName: node.name,
3377
+ rule: 'min-text-size',
3378
+ severity: 'warning',
3379
+ message: 'Text size ' + node.fontSize + 'px is below minimum 12px'
3380
+ });
3381
+ }
3382
+
3383
+ if (node.children) node.children.forEach(c => checkNode(c, depth + 1));
3384
+ }
3385
+
3386
+ checkNode(figma.currentPage);
3387
+
3388
+ const errors = issues.filter(i => i.severity === 'error').length;
3389
+ const warnings = issues.filter(i => i.severity === 'warning').length;
3390
+ const infos = issues.filter(i => i.severity === 'info').length;
3391
+
3392
+ return {
3393
+ preset,
3394
+ errors,
3395
+ warnings,
3396
+ infos,
3397
+ total: issues.length,
3398
+ issues: issues.slice(0, 100)
3399
+ };
3400
+ })()
3401
+ `);
3402
+ }
3403
+
3404
+ // ============ Component Variants ============
3405
+
3406
+ /**
3407
+ * Create a component set with variants
3408
+ */
3409
+ async createComponentSet(name, variants) {
3410
+ // variants = [{ props: { variant: 'Primary', size: 'Large' }, nodeId: '1:23' }, ...]
3411
+ return await this.eval(`
3412
+ (async function() {
3413
+ const name = ${JSON.stringify(name)};
3414
+ const variants = ${JSON.stringify(variants)};
3415
+
3416
+ // Convert each node to component
3417
+ const components = [];
3418
+ for (const v of variants) {
3419
+ const node = figma.getNodeById(v.nodeId);
3420
+ if (!node) continue;
3421
+
3422
+ // Create component from node
3423
+ const component = figma.createComponentFromNode(node);
3424
+
3425
+ // Set name with variant properties
3426
+ const propStr = Object.entries(v.props).map(([k, val]) => k + '=' + val).join(', ');
3427
+ component.name = propStr;
3428
+
3429
+ components.push(component);
3430
+ }
3431
+
3432
+ if (components.length === 0) return { error: 'No valid nodes found' };
3433
+
3434
+ // Combine into component set
3435
+ const componentSet = figma.combineAsVariants(components, figma.currentPage);
3436
+ componentSet.name = name;
3437
+
3438
+ return {
3439
+ id: componentSet.id,
3440
+ name: componentSet.name,
3441
+ variantCount: components.length
3442
+ };
3443
+ })()
3444
+ `);
3445
+ }
3446
+
3447
+ /**
3448
+ * Add variant properties to existing component
3449
+ */
3450
+ async addVariantProperty(componentSetId, propertyName, values) {
3451
+ return await this.eval(`
3452
+ (function() {
3453
+ const componentSet = figma.getNodeById(${JSON.stringify(componentSetId)});
3454
+ if (!componentSet || componentSet.type !== 'COMPONENT_SET') {
3455
+ return { error: 'Component set not found' };
3456
+ }
3457
+
3458
+ // Add property definition
3459
+ const propDefs = componentSet.componentPropertyDefinitions;
3460
+ propDefs[${JSON.stringify(propertyName)}] = {
3461
+ type: 'VARIANT',
3462
+ defaultValue: ${JSON.stringify(values[0])},
3463
+ variantOptions: ${JSON.stringify(values)}
3464
+ };
3465
+
3466
+ return { success: true, property: ${JSON.stringify(propertyName)}, values: ${JSON.stringify(values)} };
3467
+ })()
3468
+ `);
3469
+ }
3470
+
3471
+ // ============ CSS Grid Layout ============
3472
+
3473
+ /**
3474
+ * Set CSS Grid layout on a frame
3475
+ */
3476
+ async setGridLayout(nodeId, options = {}) {
3477
+ const { cols = '1fr 1fr', rows = 'auto', gap = 16, colGap, rowGap } = options;
3478
+ return await this.eval(`
3479
+ (function() {
3480
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
3481
+ if (!node || node.type !== 'FRAME') return { error: 'Frame not found' };
3482
+
3483
+ // Parse columns
3484
+ const cols = ${JSON.stringify(cols)}.split(' ');
3485
+ const rows = ${JSON.stringify(rows)}.split(' ');
3486
+
3487
+ // Figma doesn't have native CSS Grid, so we simulate with auto-layout
3488
+ // For true grid, we create nested frames
3489
+
3490
+ node.layoutMode = 'VERTICAL';
3491
+ node.itemSpacing = ${rowGap || gap};
3492
+ node.primaryAxisSizingMode = 'AUTO';
3493
+ node.counterAxisSizingMode = 'FIXED';
3494
+
3495
+ // If children exist, reorganize into rows
3496
+ const children = [...node.children];
3497
+ const colCount = cols.length;
3498
+
3499
+ // Remove all children first
3500
+ children.forEach(c => c.remove());
3501
+
3502
+ // Create rows
3503
+ let childIndex = 0;
3504
+ while (childIndex < children.length) {
3505
+ const rowFrame = figma.createFrame();
3506
+ rowFrame.name = 'Row';
3507
+ rowFrame.layoutMode = 'HORIZONTAL';
3508
+ rowFrame.itemSpacing = ${colGap || gap};
3509
+ rowFrame.primaryAxisSizingMode = 'AUTO';
3510
+ rowFrame.counterAxisSizingMode = 'AUTO';
3511
+ rowFrame.fills = [];
3512
+
3513
+ for (let i = 0; i < colCount && childIndex < children.length; i++) {
3514
+ rowFrame.appendChild(children[childIndex]);
3515
+ children[childIndex].layoutSizingHorizontal = 'FILL';
3516
+ childIndex++;
3517
+ }
3518
+
3519
+ node.appendChild(rowFrame);
3520
+ }
3521
+
3522
+ return { success: true, cols: colCount, childrenReorganized: children.length };
3523
+ })()
3524
+ `);
3525
+ }
3526
+
3527
+ // ============ Accessibility Snapshot ============
3528
+
3529
+ /**
3530
+ * Get accessibility tree snapshot
3531
+ */
3532
+ async getAccessibilitySnapshot(nodeId = null) {
3533
+ return await this.eval(`
3534
+ (function() {
3535
+ const root = ${nodeId ? `figma.getNodeById(${JSON.stringify(nodeId)})` : 'figma.currentPage'};
3536
+ if (!root) return { error: 'Node not found' };
3537
+
3538
+ const elements = [];
3539
+
3540
+ function processNode(node, depth = 0) {
3541
+ const isInteractive = node.name && (
3542
+ node.name.toLowerCase().includes('button') ||
3543
+ node.name.toLowerCase().includes('link') ||
3544
+ node.name.toLowerCase().includes('input') ||
3545
+ node.name.toLowerCase().includes('checkbox') ||
3546
+ node.name.toLowerCase().includes('toggle') ||
3547
+ node.type === 'INSTANCE'
3548
+ );
3549
+
3550
+ const isText = node.type === 'TEXT';
3551
+
3552
+ if (isInteractive || isText) {
3553
+ elements.push({
3554
+ id: node.id,
3555
+ type: node.type,
3556
+ name: node.name,
3557
+ role: isInteractive ? 'interactive' : 'text',
3558
+ depth,
3559
+ width: Math.round(node.width || 0),
3560
+ height: Math.round(node.height || 0),
3561
+ text: node.characters || null
3562
+ });
3563
+ }
3564
+
3565
+ if (node.children) {
3566
+ node.children.forEach(c => processNode(c, depth + 1));
3567
+ }
3568
+ }
3569
+
3570
+ processNode(root);
3571
+
3572
+ return {
3573
+ totalElements: elements.length,
3574
+ interactive: elements.filter(e => e.role === 'interactive').length,
3575
+ textElements: elements.filter(e => e.role === 'text').length,
3576
+ elements: elements.slice(0, 100)
3577
+ };
3578
+ })()
3579
+ `);
3580
+ }
3581
+
3582
+ // ============ Match Icons ============
3583
+
3584
+ /**
3585
+ * Try to match a vector node to an Iconify icon
3586
+ */
3587
+ async matchIcon(nodeId, preferredSets = ['lucide', 'mdi']) {
3588
+ // Get the SVG export of the node
3589
+ const svgResult = await this.exportSVG(nodeId);
3590
+ if (svgResult.error) return svgResult;
3591
+
3592
+ // This would require an external service to match
3593
+ // For now, return info about the vector
3594
+ return await this.eval(`
3595
+ (function() {
3596
+ const node = figma.getNodeById(${JSON.stringify(nodeId)});
3597
+ if (!node) return { error: 'Node not found' };
3598
+
3599
+ return {
3600
+ id: node.id,
3601
+ name: node.name,
3602
+ type: node.type,
3603
+ width: node.width,
3604
+ height: node.height,
3605
+ suggestion: 'Use Iconify search to find matching icon: https://icon-sets.iconify.design/',
3606
+ preferredSets: ${JSON.stringify(preferredSets)}
3607
+ };
3608
+ })()
3609
+ `);
3610
+ }
3611
+
3612
+ // ============ Variable Modes ============
3613
+
3614
+ /**
3615
+ * Get all modes in a variable collection
3616
+ */
3617
+ async getCollectionModes(collectionId) {
3618
+ return await this.eval(`
3619
+ (function() {
3620
+ const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
3621
+ if (!col) return { error: 'Collection not found' };
3622
+ return {
3623
+ id: col.id,
3624
+ name: col.name,
3625
+ modes: col.modes,
3626
+ defaultModeId: col.defaultModeId
3627
+ };
3628
+ })()
3629
+ `);
3630
+ }
3631
+
3632
+ /**
3633
+ * Add a new mode to a variable collection
3634
+ */
3635
+ async addMode(collectionId, modeName) {
3636
+ return await this.eval(`
3637
+ (function() {
3638
+ const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
3639
+ if (!col) return { error: 'Collection not found' };
3640
+
3641
+ const modeId = col.addMode(${JSON.stringify(modeName)});
3642
+ return {
3643
+ success: true,
3644
+ modeId,
3645
+ modeName: ${JSON.stringify(modeName)},
3646
+ allModes: col.modes
3647
+ };
3648
+ })()
3649
+ `);
3650
+ }
3651
+
3652
+ /**
3653
+ * Rename a mode in a variable collection
3654
+ */
3655
+ async renameMode(collectionId, modeId, newName) {
3656
+ return await this.eval(`
3657
+ (function() {
3658
+ const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
3659
+ if (!col) return { error: 'Collection not found' };
3660
+
3661
+ col.renameMode(${JSON.stringify(modeId)}, ${JSON.stringify(newName)});
3662
+ return { success: true, modeId: ${JSON.stringify(modeId)}, newName: ${JSON.stringify(newName)} };
3663
+ })()
3664
+ `);
3665
+ }
3666
+
3667
+ /**
3668
+ * Remove a mode from a variable collection
3669
+ */
3670
+ async removeMode(collectionId, modeId) {
3671
+ return await this.eval(`
3672
+ (function() {
3673
+ const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
3674
+ if (!col) return { error: 'Collection not found' };
3675
+
3676
+ col.removeMode(${JSON.stringify(modeId)});
3677
+ return { success: true, modeId: ${JSON.stringify(modeId)} };
3678
+ })()
3679
+ `);
3680
+ }
3681
+
3682
+ /**
3683
+ * Set variable value for a specific mode
3684
+ */
3685
+ async setVariableValueForMode(variableId, modeId, value) {
3686
+ return await this.eval(`
3687
+ (function() {
3688
+ const variable = figma.variables.getVariableById(${JSON.stringify(variableId)});
3689
+ if (!variable) return { error: 'Variable not found' };
3690
+
3691
+ let val = ${JSON.stringify(value)};
3692
+
3693
+ // Convert hex color to RGB if needed
3694
+ if (variable.resolvedType === 'COLOR' && typeof val === 'string' && val.startsWith('#')) {
3695
+ const hex = val.slice(1);
3696
+ val = {
3697
+ r: parseInt(hex.slice(0, 2), 16) / 255,
3698
+ g: parseInt(hex.slice(2, 4), 16) / 255,
3699
+ b: parseInt(hex.slice(4, 6), 16) / 255
3700
+ };
3701
+ }
3702
+
3703
+ variable.setValueForMode(${JSON.stringify(modeId)}, val);
3704
+ return { success: true, variableId: variable.id, modeId: ${JSON.stringify(modeId)} };
3705
+ })()
3706
+ `);
3707
+ }
3708
+
3709
+ /**
3710
+ * Get variable value for a specific mode
3711
+ */
3712
+ async getVariableValueForMode(variableId, modeId) {
3713
+ return await this.eval(`
3714
+ (function() {
3715
+ const variable = figma.variables.getVariableById(${JSON.stringify(variableId)});
3716
+ if (!variable) return { error: 'Variable not found' };
3717
+
3718
+ const value = variable.valuesByMode[${JSON.stringify(modeId)}];
3719
+ return {
3720
+ variableId: variable.id,
3721
+ variableName: variable.name,
3722
+ modeId: ${JSON.stringify(modeId)},
3723
+ value
3724
+ };
3725
+ })()
3726
+ `);
3727
+ }
3728
+
3729
+ /**
3730
+ * Create a complete variable collection with modes (e.g., Light/Dark)
3731
+ */
3732
+ async createCollectionWithModes(name, modeNames = ['Light', 'Dark']) {
3733
+ return await this.eval(`
3734
+ (function() {
3735
+ const col = figma.variables.createVariableCollection(${JSON.stringify(name)});
3736
+
3737
+ // Rename default mode to first mode name
3738
+ col.renameMode(col.modes[0].modeId, ${JSON.stringify(modeNames[0])});
3739
+
3740
+ // Add additional modes
3741
+ const modes = [{ modeId: col.modes[0].modeId, name: ${JSON.stringify(modeNames[0])} }];
3742
+ for (let i = 1; i < ${JSON.stringify(modeNames)}.length; i++) {
3743
+ const modeId = col.addMode(${JSON.stringify(modeNames)}[i]);
3744
+ modes.push({ modeId, name: ${JSON.stringify(modeNames)}[i] });
3745
+ }
3746
+
3747
+ return {
3748
+ id: col.id,
3749
+ name: col.name,
3750
+ modes
3751
+ };
3752
+ })()
3753
+ `);
3754
+ }
3755
+
3756
+ // ============ Batch Variable Operations ============
3757
+
3758
+ /**
3759
+ * Batch create variables (up to 100)
3760
+ * @param {Array} variables - [{name, type, value, modeValues: {modeId: value}}]
3761
+ */
3762
+ async batchCreateVariables(collectionId, variables) {
3763
+ return await this.eval(`
3764
+ (async function() {
3765
+ const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
3766
+ if (!col) return { error: 'Collection not found' };
3767
+
3768
+ const vars = ${JSON.stringify(variables)};
3769
+ const results = [];
3770
+
3771
+ for (const v of vars.slice(0, 100)) {
3772
+ const variable = figma.variables.createVariable(v.name, col, v.type || 'COLOR');
3773
+
3774
+ // Set default value
3775
+ if (v.value !== undefined) {
3776
+ let val = v.value;
3777
+ if (variable.resolvedType === 'COLOR' && typeof val === 'string' && val.startsWith('#')) {
3778
+ const hex = val.slice(1);
3779
+ val = {
3780
+ r: parseInt(hex.slice(0, 2), 16) / 255,
3781
+ g: parseInt(hex.slice(2, 4), 16) / 255,
3782
+ b: parseInt(hex.slice(4, 6), 16) / 255
3783
+ };
3784
+ }
3785
+ variable.setValueForMode(col.defaultModeId, val);
3786
+ }
3787
+
3788
+ // Set mode-specific values
3789
+ if (v.modeValues) {
3790
+ for (const [modeId, modeVal] of Object.entries(v.modeValues)) {
3791
+ let val = modeVal;
3792
+ if (variable.resolvedType === 'COLOR' && typeof val === 'string' && val.startsWith('#')) {
3793
+ const hex = val.slice(1);
3794
+ val = {
3795
+ r: parseInt(hex.slice(0, 2), 16) / 255,
3796
+ g: parseInt(hex.slice(2, 4), 16) / 255,
3797
+ b: parseInt(hex.slice(4, 6), 16) / 255
3798
+ };
3799
+ }
3800
+ variable.setValueForMode(modeId, val);
3801
+ }
3802
+ }
3803
+
3804
+ results.push({ id: variable.id, name: variable.name });
3805
+ }
3806
+
3807
+ return { created: results.length, variables: results };
3808
+ })()
3809
+ `);
3810
+ }
3811
+
3812
+ /**
3813
+ * Batch update variable values
3814
+ * @param {Array} updates - [{variableId, modeId, value}]
3815
+ */
3816
+ async batchUpdateVariables(updates) {
3817
+ return await this.eval(`
3818
+ (function() {
3819
+ const updates = ${JSON.stringify(updates)};
3820
+ const results = [];
3821
+
3822
+ for (const u of updates.slice(0, 100)) {
3823
+ const variable = figma.variables.getVariableById(u.variableId);
3824
+ if (!variable) {
3825
+ results.push({ variableId: u.variableId, error: 'Not found' });
3826
+ continue;
3827
+ }
3828
+
3829
+ let val = u.value;
3830
+ if (variable.resolvedType === 'COLOR' && typeof val === 'string' && val.startsWith('#')) {
3831
+ const hex = val.slice(1);
3832
+ val = {
3833
+ r: parseInt(hex.slice(0, 2), 16) / 255,
3834
+ g: parseInt(hex.slice(2, 4), 16) / 255,
3835
+ b: parseInt(hex.slice(4, 6), 16) / 255
3836
+ };
3837
+ }
3838
+
3839
+ variable.setValueForMode(u.modeId, val);
3840
+ results.push({ variableId: u.variableId, success: true });
3841
+ }
3842
+
3843
+ return { updated: results.filter(r => r.success).length, results };
3844
+ })()
3845
+ `);
3846
+ }
3847
+
3848
+ /**
3849
+ * Batch delete variables
3850
+ */
3851
+ async batchDeleteVariables(variableIds) {
3852
+ return await this.eval(`
3853
+ (function() {
3854
+ const ids = ${JSON.stringify(variableIds)};
3855
+ let deleted = 0;
3856
+
3857
+ for (const id of ids.slice(0, 100)) {
3858
+ const variable = figma.variables.getVariableById(id);
3859
+ if (variable) {
3860
+ variable.remove();
3861
+ deleted++;
3862
+ }
3863
+ }
3864
+
3865
+ return { deleted };
3866
+ })()
3867
+ `);
3868
+ }
3869
+
3870
+ // ============ Component Descriptions ============
3871
+
3872
+ /**
3873
+ * Set description on a component (supports markdown)
3874
+ */
3875
+ async setComponentDescription(componentId, description) {
3876
+ return await this.eval(`
3877
+ (function() {
3878
+ const node = figma.getNodeById(${JSON.stringify(componentId)});
3879
+ if (!node) return { error: 'Node not found' };
3880
+ if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') {
3881
+ return { error: 'Node is not a component' };
3882
+ }
3883
+
3884
+ node.description = ${JSON.stringify(description)};
3885
+ return { success: true, id: node.id, description: node.description };
3886
+ })()
3887
+ `);
3888
+ }
3889
+
3890
+ /**
3891
+ * Get description from a component
3892
+ */
3893
+ async getComponentDescription(componentId) {
3894
+ return await this.eval(`
3895
+ (function() {
3896
+ const node = figma.getNodeById(${JSON.stringify(componentId)});
3897
+ if (!node) return { error: 'Node not found' };
3898
+
3899
+ return {
3900
+ id: node.id,
3901
+ name: node.name,
3902
+ type: node.type,
3903
+ description: node.description || ''
3904
+ };
3905
+ })()
3906
+ `);
3907
+ }
3908
+
3909
+ /**
3910
+ * Set description with documentation template
3911
+ */
3912
+ async documentComponent(componentId, options = {}) {
3913
+ const { usage = '', props = [], notes = '' } = options;
3914
+
3915
+ let description = '';
3916
+ if (usage) description += `## Usage\n${usage}\n\n`;
3917
+ if (props.length > 0) {
3918
+ description += `## Properties\n`;
3919
+ props.forEach(p => {
3920
+ description += `- **${p.name}**: ${p.description}\n`;
3921
+ });
3922
+ description += '\n';
3923
+ }
3924
+ if (notes) description += `## Notes\n${notes}`;
3925
+
3926
+ return await this.setComponentDescription(componentId, description.trim());
3927
+ }
3928
+
3929
+ // ============ Console & Debugging ============
3930
+
3931
+ /**
3932
+ * Get console logs from Figma
3933
+ */
3934
+ async getConsoleLogs(limit = 50) {
3935
+ // Enable console tracking if not already
3936
+ await this.send('Runtime.enable');
3937
+
3938
+ return await this.eval(`
3939
+ (function() {
3940
+ // Note: We can't access past console logs directly
3941
+ // But we can return info about current state
3942
+ return {
3943
+ message: 'Console log streaming enabled. Use captureConsoleLogs() to start capturing.',
3944
+ tip: 'Run your plugin code and logs will be captured.'
3945
+ };
3946
+ })()
3947
+ `);
3948
+ }
3949
+
3950
+ /**
3951
+ * Start capturing console logs
3952
+ * Returns logs via callback
3953
+ */
3954
+ async startConsoleCapture(callback) {
3955
+ await this.send('Runtime.enable');
3956
+
3957
+ // Listen for console messages
3958
+ this.ws.on('message', (data) => {
3959
+ const msg = JSON.parse(data);
3960
+ if (msg.method === 'Runtime.consoleAPICalled') {
3961
+ const args = msg.params.args.map(arg => arg.value || arg.description || '');
3962
+ callback({
3963
+ type: msg.params.type,
3964
+ message: args.join(' '),
3965
+ timestamp: msg.params.timestamp
3966
+ });
3967
+ }
3968
+ });
3969
+
3970
+ return { capturing: true };
3971
+ }
3972
+
3973
+ /**
3974
+ * Execute code and capture its console output
3975
+ */
3976
+ async evalWithLogs(expression) {
3977
+ const logs = [];
3978
+
3979
+ // Wrap expression to capture console
3980
+ const wrappedCode = `
3981
+ (function() {
3982
+ const _logs = [];
3983
+ const _origLog = console.log;
3984
+ const _origWarn = console.warn;
3985
+ const _origError = console.error;
3986
+
3987
+ console.log = (...args) => { _logs.push({ type: 'log', args }); _origLog(...args); };
3988
+ console.warn = (...args) => { _logs.push({ type: 'warn', args }); _origWarn(...args); };
3989
+ console.error = (...args) => { _logs.push({ type: 'error', args }); _origError(...args); };
3990
+
3991
+ try {
3992
+ const result = (function() { ${expression} })();
3993
+ return { result, logs: _logs };
3994
+ } finally {
3995
+ console.log = _origLog;
3996
+ console.warn = _origWarn;
3997
+ console.error = _origError;
3998
+ }
3999
+ })()
4000
+ `;
4001
+
4002
+ return await this.eval(wrappedCode);
4003
+ }
4004
+
4005
+ // ============ Page & Plugin Reload ============
4006
+
4007
+ /**
4008
+ * Reload the current page
4009
+ */
4010
+ async reloadPage() {
4011
+ return await this.send('Page.reload');
4012
+ }
4013
+
4014
+ /**
4015
+ * Navigate to a different Figma file
4016
+ */
4017
+ async navigateToFile(fileUrl) {
4018
+ return await this.send('Page.navigate', { url: fileUrl });
4019
+ }
4020
+
4021
+ /**
4022
+ * Get current page URL
4023
+ */
4024
+ async getCurrentUrl() {
4025
+ const result = await this.eval('window.location.href');
4026
+ return { url: result };
4027
+ }
4028
+
4029
+ /**
4030
+ * Reload/refresh plugins
4031
+ */
4032
+ async refreshPlugins() {
4033
+ return await this.eval(`
4034
+ (function() {
4035
+ // Trigger a plugin refresh by accessing the plugin API
4036
+ // This doesn't actually reload plugins but refreshes the state
4037
+ const pluginData = figma.root.getPluginData('__refresh__');
4038
+ figma.root.setPluginData('__refresh__', Date.now().toString());
4039
+ return { refreshed: true, timestamp: Date.now() };
4040
+ })()
4041
+ `);
4042
+ }
4043
+
4044
+ // ============ Organize Variants ============
4045
+
4046
+ /**
4047
+ * Organize component variants into a grid with labels
4048
+ */
4049
+ async organizeVariants(componentSetId, options = {}) {
4050
+ const { gap = 40, labelGap = 20, showLabels = true } = options;
4051
+
4052
+ return await this.eval(`
4053
+ (async function() {
4054
+ const componentSet = figma.getNodeById(${JSON.stringify(componentSetId)});
4055
+ if (!componentSet || componentSet.type !== 'COMPONENT_SET') {
4056
+ return { error: 'Component set not found' };
4057
+ }
4058
+
4059
+ const variants = componentSet.children.filter(c => c.type === 'COMPONENT');
4060
+ if (variants.length === 0) return { error: 'No variants found' };
4061
+
4062
+ // Parse variant properties
4063
+ const propValues = {};
4064
+ variants.forEach(v => {
4065
+ const props = v.name.split(', ');
4066
+ props.forEach(p => {
4067
+ const [key, val] = p.split('=');
4068
+ if (!propValues[key]) propValues[key] = new Set();
4069
+ propValues[key].add(val);
4070
+ });
4071
+ });
4072
+
4073
+ const propNames = Object.keys(propValues);
4074
+ if (propNames.length === 0) return { organized: 0 };
4075
+
4076
+ // Use first two properties for grid (rows/cols)
4077
+ const rowProp = propNames[0];
4078
+ const colProp = propNames[1] || null;
4079
+
4080
+ const rowValues = Array.from(propValues[rowProp]);
4081
+ const colValues = colProp ? Array.from(propValues[colProp]) : [''];
4082
+
4083
+ const gap = ${gap};
4084
+ const labelGap = ${labelGap};
4085
+ const showLabels = ${showLabels};
4086
+
4087
+ // Get max dimensions
4088
+ let maxW = 0, maxH = 0;
4089
+ variants.forEach(v => {
4090
+ maxW = Math.max(maxW, v.width);
4091
+ maxH = Math.max(maxH, v.height);
4092
+ });
4093
+
4094
+ // Position variants in grid
4095
+ let organized = 0;
4096
+ rowValues.forEach((rowVal, rowIdx) => {
4097
+ colValues.forEach((colVal, colIdx) => {
4098
+ const variant = variants.find(v => {
4099
+ const hasRow = v.name.includes(rowProp + '=' + rowVal);
4100
+ const hasCol = !colProp || v.name.includes(colProp + '=' + colVal);
4101
+ return hasRow && hasCol;
4102
+ });
4103
+
4104
+ if (variant) {
4105
+ const xOffset = showLabels ? 100 : 0;
4106
+ const yOffset = showLabels ? 40 : 0;
4107
+
4108
+ variant.x = xOffset + colIdx * (maxW + gap);
4109
+ variant.y = yOffset + rowIdx * (maxH + gap);
4110
+ organized++;
4111
+ }
4112
+ });
4113
+ });
4114
+
4115
+ // Add labels if requested
4116
+ if (showLabels && organized > 0) {
4117
+ await figma.loadFontAsync({ family: 'Inter', style: 'Medium' });
4118
+
4119
+ // Row labels
4120
+ rowValues.forEach((val, idx) => {
4121
+ const label = figma.createText();
4122
+ label.characters = val;
4123
+ label.fontSize = 12;
4124
+ label.x = 0;
4125
+ label.y = 40 + idx * (maxH + gap) + maxH / 2 - 6;
4126
+ componentSet.parent.appendChild(label);
4127
+ });
4128
+
4129
+ // Column labels
4130
+ if (colProp) {
4131
+ colValues.forEach((val, idx) => {
4132
+ const label = figma.createText();
4133
+ label.characters = val;
4134
+ label.fontSize = 12;
4135
+ label.x = 100 + idx * (maxW + gap) + maxW / 2;
4136
+ label.y = 10;
4137
+ componentSet.parent.appendChild(label);
4138
+ });
4139
+ }
4140
+ }
4141
+
4142
+ // Resize component set to fit
4143
+ componentSet.resizeWithoutConstraints(
4144
+ (showLabels ? 100 : 0) + colValues.length * (maxW + gap) - gap,
4145
+ (showLabels ? 40 : 0) + rowValues.length * (maxH + gap) - gap
4146
+ );
4147
+
4148
+ return {
4149
+ organized,
4150
+ rows: rowValues.length,
4151
+ cols: colValues.length,
4152
+ rowProperty: rowProp,
4153
+ colProperty: colProp
4154
+ };
4155
+ })()
4156
+ `);
4157
+ }
4158
+
4159
+ /**
4160
+ * Auto-generate component set from similar frames
4161
+ */
4162
+ async createComponentSetFromFrames(frameIds, name, variantProperty = 'variant') {
4163
+ return await this.eval(`
4164
+ (async function() {
4165
+ const ids = ${JSON.stringify(frameIds)};
4166
+ const frames = ids.map(id => figma.getNodeById(id)).filter(n => n && n.type === 'FRAME');
4167
+
4168
+ if (frames.length < 2) return { error: 'Need at least 2 frames' };
4169
+
4170
+ // Convert frames to components
4171
+ const components = frames.map((frame, idx) => {
4172
+ const component = figma.createComponentFromNode(frame);
4173
+ component.name = ${JSON.stringify(variantProperty)} + '=' + (frame.name || 'Variant' + (idx + 1));
4174
+ return component;
4175
+ });
4176
+
4177
+ // Combine into component set
4178
+ const componentSet = figma.combineAsVariants(components, figma.currentPage);
4179
+ componentSet.name = ${JSON.stringify(name)};
4180
+
4181
+ return {
4182
+ id: componentSet.id,
4183
+ name: componentSet.name,
4184
+ variantCount: components.length
4185
+ };
4186
+ })()
4187
+ `);
4188
+ }
4189
+
4190
+ close() {
4191
+ if (this.ws) {
4192
+ this.ws.close();
4193
+ this.ws = null;
4194
+ }
4195
+ }
4196
+ }
4197
+
4198
+ export default FigmaClient;