@nnao45/figma-use 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2747 @@
1
+ import svgpath from 'svgpath'
2
+
3
+ import { queryNodes } from './query.ts'
4
+
5
+ console.log('[Figma Bridge] Plugin main loaded at', new Date().toISOString())
6
+
7
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
8
+
9
+ async function retry<T>(
10
+ fn: () => Promise<T | null | undefined>,
11
+ maxAttempts = 10,
12
+ delayMs = 50,
13
+ backoff: 'fixed' | 'linear' | 'exponential' = 'fixed'
14
+ ): Promise<T | null> {
15
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
16
+ const result = await fn()
17
+ if (result) return result
18
+ if (attempt < maxAttempts - 1) {
19
+ const delay =
20
+ backoff === 'linear'
21
+ ? delayMs * (attempt + 1)
22
+ : backoff === 'exponential'
23
+ ? delayMs * Math.pow(2, attempt)
24
+ : delayMs
25
+ await sleep(delay)
26
+ }
27
+ }
28
+ return null
29
+ }
30
+
31
+ figma.showUI(__html__, { width: 300, height: 200 })
32
+
33
+ // Font cache to avoid repeated loadFontAsync calls
34
+ const loadedFonts = new Set<string>()
35
+ const fontLoadPromises = new Map<string, Promise<void>>()
36
+
37
+ // Preload common font
38
+ const interPromise = figma.loadFontAsync({ family: 'Inter', style: 'Regular' })
39
+ fontLoadPromises.set('Inter:Regular', interPromise)
40
+ interPromise.then(() => loadedFonts.add('Inter:Regular'))
41
+
42
+ function loadFont(family: string, style: string): Promise<void> | void {
43
+ const key = `${family}:${style}`
44
+ if (loadedFonts.has(key)) return // sync return if cached
45
+
46
+ // Check if already loading
47
+ const pending = fontLoadPromises.get(key)
48
+ if (pending) return pending
49
+
50
+ // Start new load
51
+ const promise = figma.loadFontAsync({ family, style })
52
+ fontLoadPromises.set(key, promise)
53
+ promise.then(() => {
54
+ loadedFonts.add(key)
55
+ fontLoadPromises.delete(key)
56
+ })
57
+ return promise
58
+ }
59
+
60
+ // Fast node creation for batch operations - skips full serialization
61
+ async function createNodeFast(
62
+ command: string,
63
+ args: Record<string, unknown> | undefined,
64
+ nodeCache?: Map<string, SceneNode>,
65
+ deferredLayouts?: Array<{
66
+ frame: FrameNode
67
+ layoutMode: 'HORIZONTAL' | 'VERTICAL'
68
+ itemSpacing?: number
69
+ padding?: { top: number; right: number; bottom: number; left: number }
70
+ }>
71
+ ): Promise<SceneNode | null> {
72
+ if (!args) return null
73
+
74
+ const {
75
+ x = 0,
76
+ y = 0,
77
+ width,
78
+ height,
79
+ name,
80
+ fill,
81
+ stroke,
82
+ strokeWeight,
83
+ radius,
84
+ opacity,
85
+ layoutMode,
86
+ itemSpacing,
87
+ padding,
88
+ text,
89
+ fontSize,
90
+ fontFamily,
91
+ fontStyle
92
+ } = args as {
93
+ x?: number
94
+ y?: number
95
+ width?: number
96
+ height?: number
97
+ name?: string
98
+ fill?: string
99
+ stroke?: string
100
+ strokeWeight?: number
101
+ radius?: number
102
+ opacity?: number
103
+ layoutMode?: string
104
+ itemSpacing?: number
105
+ padding?: { top: number; right: number; bottom: number; left: number }
106
+ text?: string
107
+ fontSize?: number
108
+ fontFamily?: string
109
+ fontStyle?: string
110
+ }
111
+
112
+ let node: SceneNode | null = null
113
+
114
+ switch (command) {
115
+ case 'create-frame': {
116
+ const frame = figma.createFrame()
117
+ frame.x = x
118
+ frame.y = y
119
+ frame.resize(width || 100, height || 100)
120
+ if (name) frame.name = name
121
+ if (fill) frame.fills = [{ type: 'SOLID', color: hexToRgb(getHexColor(fill)) }]
122
+ if (stroke) frame.strokes = [{ type: 'SOLID', color: hexToRgb(getHexColor(stroke)) }]
123
+ if (strokeWeight) frame.strokeWeight = strokeWeight
124
+ if (typeof radius === 'number') frame.cornerRadius = radius
125
+ if (typeof opacity === 'number') frame.opacity = opacity
126
+ if (layoutMode && layoutMode !== 'NONE') {
127
+ deferredLayouts?.push({
128
+ frame,
129
+ layoutMode: layoutMode as 'HORIZONTAL' | 'VERTICAL',
130
+ itemSpacing,
131
+ padding
132
+ })
133
+ }
134
+ node = frame
135
+ break
136
+ }
137
+ case 'create-rectangle': {
138
+ const rect = figma.createRectangle()
139
+ rect.x = x
140
+ rect.y = y
141
+ rect.resize(width || 100, height || 100)
142
+ if (name) rect.name = name
143
+ if (fill) rect.fills = [{ type: 'SOLID', color: hexToRgb(getHexColor(fill)) }]
144
+ if (stroke) rect.strokes = [{ type: 'SOLID', color: hexToRgb(getHexColor(stroke)) }]
145
+ if (strokeWeight) rect.strokeWeight = strokeWeight
146
+ if (typeof radius === 'number') rect.cornerRadius = radius
147
+ if (typeof opacity === 'number') rect.opacity = opacity
148
+ node = rect
149
+ break
150
+ }
151
+ case 'create-ellipse': {
152
+ const ellipse = figma.createEllipse()
153
+ ellipse.x = x
154
+ ellipse.y = y
155
+ ellipse.resize(width || 100, height || 100)
156
+ if (name) ellipse.name = name
157
+ if (fill) ellipse.fills = [{ type: 'SOLID', color: hexToRgb(getHexColor(fill)) }]
158
+ if (stroke) ellipse.strokes = [{ type: 'SOLID', color: hexToRgb(getHexColor(stroke)) }]
159
+ if (strokeWeight) ellipse.strokeWeight = strokeWeight
160
+ if (typeof opacity === 'number') ellipse.opacity = opacity
161
+ node = ellipse
162
+ break
163
+ }
164
+ case 'create-text': {
165
+ const textNode = figma.createText()
166
+ const family = fontFamily || 'Inter'
167
+ const style = fontStyle || 'Regular'
168
+ await loadFont(family, style)
169
+ textNode.fontName = { family, style }
170
+ textNode.characters = text || ''
171
+ textNode.x = x
172
+ textNode.y = y
173
+ if (name) textNode.name = name
174
+ if (fontSize) textNode.fontSize = fontSize
175
+ if (fill) textNode.fills = [{ type: 'SOLID', color: hexToRgb(getHexColor(fill)) }]
176
+ if (typeof opacity === 'number') textNode.opacity = opacity
177
+ node = textNode
178
+ break
179
+ }
180
+ default:
181
+ return null
182
+ }
183
+
184
+ return node
185
+ }
186
+
187
+ // Commands that need access to nodes on other pages
188
+ const NEEDS_ALL_PAGES = new Set([
189
+ 'get-node-info',
190
+ 'get-node-tree',
191
+ 'get-node-children',
192
+ 'set-parent',
193
+ 'clone-node',
194
+ 'delete-node',
195
+ 'get-pages',
196
+ 'set-current-page',
197
+ 'get-components',
198
+ 'get-styles',
199
+ 'export-node',
200
+ 'screenshot'
201
+ ])
202
+
203
+ let allPagesLoaded = false
204
+
205
+ figma.ui.onmessage = async (msg: {
206
+ type: string
207
+ id?: string
208
+ command?: string
209
+ args?: unknown
210
+ }) => {
211
+ // Handle file info request
212
+ if (msg.type === 'get-file-info') {
213
+ // sessionID is unique per open file
214
+ const sessionId = figma.currentPage.id.split(':')[0]
215
+ const fileName = figma.root.name
216
+ figma.ui.postMessage({ type: 'file-info', sessionId, fileName })
217
+ return
218
+ }
219
+
220
+ if (msg.type !== 'command') return
221
+
222
+ try {
223
+ // Only load all pages when needed
224
+ if (!allPagesLoaded && NEEDS_ALL_PAGES.has(msg.command)) {
225
+ await figma.loadAllPagesAsync()
226
+ allPagesLoaded = true
227
+ }
228
+ const result = await handleCommand(msg.command, msg.args)
229
+ figma.ui.postMessage({ type: 'result', id: msg.id, result })
230
+ } catch (error) {
231
+ figma.ui.postMessage({ type: 'result', id: msg.id, error: String(error) })
232
+ }
233
+ }
234
+
235
+ async function handleCommand(command: string, args?: unknown): Promise<unknown> {
236
+ switch (command) {
237
+ // ==================== BATCH ====================
238
+ case 'batch': {
239
+ const { commands } = args as {
240
+ commands: Array<{ command: string; args?: Record<string, unknown>; parentRef?: string }>
241
+ }
242
+ const results: Array<{ id: string; name: string }> = []
243
+ const refMap = new Map<string, string>() // ref -> actual node id
244
+ const nodeCache = new Map<string, SceneNode>() // cache created nodes for parent lookups
245
+ const deferredLayouts: Array<{
246
+ frame: FrameNode
247
+ layoutMode: 'HORIZONTAL' | 'VERTICAL'
248
+ itemSpacing?: number
249
+ padding?: { top: number; right: number; bottom: number; left: number }
250
+ }> = []
251
+ const internalAttachments: Array<{ node: SceneNode; parentId: string }> = []
252
+ const externalAttachments: Array<{ node: SceneNode; parentId: string }> = []
253
+ const rootNodes: SceneNode[] = []
254
+
255
+ for (const cmd of commands) {
256
+ // Resolve parent reference if needed
257
+ if (cmd.args?.parentRef && refMap.has(cmd.args.parentRef)) {
258
+ cmd.args.parentId = refMap.get(cmd.args.parentRef)
259
+ delete cmd.args.parentRef
260
+ }
261
+
262
+ // Use fast path for create commands
263
+ const node = await createNodeFast(cmd.command, cmd.args, nodeCache, deferredLayouts)
264
+ if (node) {
265
+ results.push({ id: node.id, name: node.name })
266
+ nodeCache.set(node.id, node) // cache for child lookups
267
+ if (cmd.args?.ref) {
268
+ refMap.set(cmd.args.ref as string, node.id)
269
+ }
270
+
271
+ const parentId = cmd.args?.parentId as string | undefined
272
+ if (parentId) {
273
+ if (nodeCache.has(parentId)) {
274
+ internalAttachments.push({ node, parentId })
275
+ } else {
276
+ externalAttachments.push({ node, parentId })
277
+ }
278
+ } else {
279
+ rootNodes.push(node)
280
+ }
281
+ } else {
282
+ // Fallback to full handler
283
+ const result = (await handleCommand(cmd.command, cmd.args)) as {
284
+ id: string
285
+ name: string
286
+ }
287
+ results.push(result)
288
+ if (cmd.args?.ref) {
289
+ refMap.set(cmd.args.ref as string, result.id)
290
+ }
291
+ }
292
+ }
293
+
294
+ for (const attachment of internalAttachments) {
295
+ const parent = nodeCache.get(attachment.parentId)
296
+ if (parent && 'appendChild' in parent) {
297
+ parent.appendChild(attachment.node)
298
+ }
299
+ }
300
+
301
+ // Apply layouts in reverse order (children first, then parents)
302
+ for (let i = deferredLayouts.length - 1; i >= 0; i--) {
303
+ const layout = deferredLayouts[i]
304
+ layout.frame.layoutMode = layout.layoutMode
305
+ layout.frame.primaryAxisSizingMode = 'AUTO'
306
+ layout.frame.counterAxisSizingMode = 'AUTO'
307
+ if (layout.itemSpacing) layout.frame.itemSpacing = layout.itemSpacing
308
+ if (layout.padding) {
309
+ layout.frame.paddingTop = layout.padding.top
310
+ layout.frame.paddingRight = layout.padding.right
311
+ layout.frame.paddingBottom = layout.padding.bottom
312
+ layout.frame.paddingLeft = layout.padding.left
313
+ }
314
+ }
315
+
316
+ for (const node of rootNodes) {
317
+ figma.currentPage.appendChild(node)
318
+ }
319
+
320
+ for (const attachment of externalAttachments) {
321
+ let parent = nodeCache.get(attachment.parentId)
322
+ if (!parent) {
323
+ parent = (await figma.getNodeByIdAsync(attachment.parentId)) as SceneNode | null
324
+ }
325
+ if (parent && 'appendChild' in parent) {
326
+ parent.appendChild(attachment.node)
327
+ }
328
+ }
329
+
330
+ figma.commitUndo()
331
+
332
+ return results
333
+ }
334
+
335
+ // ==================== READ ====================
336
+ case 'get-selection':
337
+ return figma.currentPage.selection.map(serializeNode)
338
+
339
+ case 'get-node-info': {
340
+ const { id } = args as { id: string }
341
+ const node = await figma.getNodeByIdAsync(id)
342
+ return node ? serializeNode(node) : null
343
+ }
344
+
345
+ case 'get-current-page':
346
+ return { id: figma.currentPage.id, name: figma.currentPage.name }
347
+
348
+ case 'list-fonts': {
349
+ const fonts = await figma.listAvailableFontsAsync()
350
+ return fonts.map((f) => ({ family: f.fontName.family, style: f.fontName.style }))
351
+ }
352
+
353
+ case 'get-node-tree': {
354
+ const { id } = args as { id: string }
355
+ const node = await figma.getNodeByIdAsync(id)
356
+ if (!node) throw new Error('Node not found')
357
+
358
+ const serializeTreeNode = (n: BaseNode): object => {
359
+ const base: Record<string, unknown> = {
360
+ id: n.id,
361
+ name: n.name,
362
+ type: n.type
363
+ }
364
+ if ('x' in n) base.x = Math.round(n.x)
365
+ if ('y' in n) base.y = Math.round(n.y)
366
+ if ('width' in n) base.width = Math.round(n.width)
367
+ if ('height' in n) base.height = Math.round(n.height)
368
+
369
+ // Only essential properties for tree view
370
+ if ('fills' in n && Array.isArray(n.fills)) {
371
+ const solid = n.fills.find((f: Paint) => f.type === 'SOLID') as SolidPaint | undefined
372
+ if (solid) base.fills = [{ type: 'SOLID', color: rgbToHex(solid.color) }]
373
+ }
374
+ if ('strokes' in n && Array.isArray(n.strokes) && n.strokes.length > 0) {
375
+ const solid = n.strokes.find((s: Paint) => s.type === 'SOLID') as SolidPaint | undefined
376
+ if (solid) base.strokes = [{ type: 'SOLID', color: rgbToHex(solid.color) }]
377
+ }
378
+ if ('strokeWeight' in n && typeof n.strokeWeight === 'number' && n.strokeWeight > 0) {
379
+ base.strokeWeight = n.strokeWeight
380
+ }
381
+ if ('cornerRadius' in n && typeof n.cornerRadius === 'number' && n.cornerRadius > 0) {
382
+ base.cornerRadius = n.cornerRadius
383
+ }
384
+ if ('opacity' in n && n.opacity !== 1) base.opacity = n.opacity
385
+ if ('visible' in n && !n.visible) base.visible = false
386
+ if ('locked' in n && n.locked) base.locked = true
387
+ if ('layoutMode' in n && n.layoutMode !== 'NONE') {
388
+ base.layoutMode = n.layoutMode
389
+ if ('itemSpacing' in n) base.itemSpacing = n.itemSpacing
390
+ }
391
+ if (n.type === 'TEXT') {
392
+ const t = n as TextNode
393
+ base.characters = t.characters
394
+ if (typeof t.fontSize === 'number') base.fontSize = t.fontSize
395
+ if (typeof t.fontName === 'object') {
396
+ base.fontFamily = t.fontName.family
397
+ base.fontStyle = t.fontName.style
398
+ }
399
+ }
400
+
401
+ if ('children' in n && (n as FrameNode).children) {
402
+ base.children = (n as FrameNode).children.map(serializeTreeNode)
403
+ }
404
+ return base
405
+ }
406
+ return serializeTreeNode(node)
407
+ }
408
+
409
+ case 'get-all-components': {
410
+ const {
411
+ name,
412
+ limit = 50,
413
+ page
414
+ } = (args as { name?: string; limit?: number; page?: string }) || {}
415
+ const components: object[] = []
416
+ const nameLower = name?.toLowerCase()
417
+
418
+ const searchNode = (node: SceneNode): boolean => {
419
+ if (components.length >= limit) return false
420
+ if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') {
421
+ if (!nameLower || node.name.toLowerCase().includes(nameLower)) {
422
+ components.push(serializeNode(node))
423
+ }
424
+ }
425
+ if ('children' in node) {
426
+ for (const child of (node as FrameNode).children) {
427
+ if (!searchNode(child)) return false
428
+ }
429
+ }
430
+ return components.length < limit
431
+ }
432
+
433
+ const pages = page
434
+ ? figma.root.children.filter(
435
+ (p) => p.id === page || p.name === page || p.name.includes(page)
436
+ )
437
+ : figma.root.children
438
+
439
+ for (const pageNode of pages) {
440
+ if (components.length >= limit) break
441
+ for (const child of pageNode.children) {
442
+ if (!searchNode(child)) break
443
+ }
444
+ }
445
+ return components
446
+ }
447
+
448
+ case 'get-pages':
449
+ return figma.root.children.map((page) => ({ id: page.id, name: page.name }))
450
+
451
+ case 'create-page': {
452
+ const { name } = args as { name: string }
453
+ const page = figma.createPage()
454
+ page.name = name
455
+ return { id: page.id, name: page.name }
456
+ }
457
+
458
+ case 'set-current-page': {
459
+ const { page: pageArg } = args as { page: string }
460
+ let page: PageNode | null = null
461
+
462
+ // Try by ID first
463
+ const byId = (await figma.getNodeByIdAsync(pageArg)) as PageNode | null
464
+ if (byId && byId.type === 'PAGE') {
465
+ page = byId
466
+ } else {
467
+ // Try by name
468
+ page =
469
+ figma.root.children.find((p) => p.name === pageArg || p.name.includes(pageArg)) || null
470
+ }
471
+
472
+ if (!page) throw new Error('Page not found')
473
+ await figma.setCurrentPageAsync(page)
474
+ return { id: page.id, name: page.name }
475
+ }
476
+
477
+ case 'get-local-styles': {
478
+ const { type } = (args as { type?: string }) || {}
479
+ const result: Record<string, object[]> = {}
480
+ if (!type || type === 'all' || type === 'paint') {
481
+ const styles = await figma.getLocalPaintStylesAsync()
482
+ if (styles.length > 0) {
483
+ result.paintStyles = styles.map((s) => ({
484
+ id: s.id,
485
+ name: s.name,
486
+ paints: s.paints.map(serializePaint)
487
+ }))
488
+ }
489
+ }
490
+ if (!type || type === 'all' || type === 'text') {
491
+ const styles = await figma.getLocalTextStylesAsync()
492
+ if (styles.length > 0) {
493
+ result.textStyles = styles.map((s) => ({
494
+ id: s.id,
495
+ name: s.name,
496
+ fontSize: s.fontSize,
497
+ fontFamily: s.fontName.family,
498
+ fontStyle: s.fontName.style
499
+ }))
500
+ }
501
+ }
502
+ if (!type || type === 'all' || type === 'effect') {
503
+ const styles = await figma.getLocalEffectStylesAsync()
504
+ if (styles.length > 0) {
505
+ result.effectStyles = styles.map((s) => ({
506
+ id: s.id,
507
+ name: s.name,
508
+ effects: s.effects.map((e) => ({
509
+ type: e.type,
510
+ radius: 'radius' in e ? e.radius : undefined
511
+ }))
512
+ }))
513
+ }
514
+ }
515
+ if (!type || type === 'all' || type === 'grid') {
516
+ const styles = await figma.getLocalGridStylesAsync()
517
+ if (styles.length > 0) {
518
+ result.gridStyles = styles.map((s) => ({
519
+ id: s.id,
520
+ name: s.name,
521
+ grids: s.layoutGrids.length
522
+ }))
523
+ }
524
+ }
525
+ return result
526
+ }
527
+
528
+ case 'get-viewport':
529
+ return {
530
+ center: figma.viewport.center,
531
+ zoom: figma.viewport.zoom,
532
+ bounds: figma.viewport.bounds
533
+ }
534
+
535
+ // ==================== CREATE SHAPES ====================
536
+ case 'create-rectangle': {
537
+ const { x, y, width, height, name, parentId, fill, stroke, strokeWeight, radius, opacity } =
538
+ args as {
539
+ x: number
540
+ y: number
541
+ width: number
542
+ height: number
543
+ name?: string
544
+ parentId?: string
545
+ fill?: string
546
+ stroke?: string
547
+ strokeWeight?: number
548
+ radius?: number
549
+ opacity?: number
550
+ }
551
+ const rect = figma.createRectangle()
552
+ rect.x = x
553
+ rect.y = y
554
+ rect.resize(width, height)
555
+ if (name) rect.name = name
556
+ if (fill) rect.fills = [await createSolidPaint(fill)]
557
+ if (stroke) rect.strokes = [await createSolidPaint(stroke)]
558
+ if (strokeWeight !== undefined) rect.strokeWeight = strokeWeight
559
+ if (radius !== undefined) rect.cornerRadius = radius
560
+ if (opacity !== undefined) rect.opacity = opacity
561
+ await appendToParent(rect, parentId)
562
+ return serializeNode(rect)
563
+ }
564
+
565
+ case 'create-ellipse': {
566
+ const { x, y, width, height, name, parentId, fill, stroke, strokeWeight, opacity } = args as {
567
+ x: number
568
+ y: number
569
+ width: number
570
+ height: number
571
+ name?: string
572
+ parentId?: string
573
+ fill?: string
574
+ stroke?: string
575
+ strokeWeight?: number
576
+ opacity?: number
577
+ }
578
+ const ellipse = figma.createEllipse()
579
+ ellipse.x = x
580
+ ellipse.y = y
581
+ ellipse.resize(width, height)
582
+ if (name) ellipse.name = name
583
+ if (fill) ellipse.fills = [await createSolidPaint(fill)]
584
+ if (stroke) ellipse.strokes = [await createSolidPaint(stroke)]
585
+ if (strokeWeight !== undefined) ellipse.strokeWeight = strokeWeight
586
+ if (opacity !== undefined) ellipse.opacity = opacity
587
+ await appendToParent(ellipse, parentId)
588
+ return serializeNode(ellipse)
589
+ }
590
+
591
+ case 'create-line': {
592
+ const { x, y, length, rotation, name, parentId, stroke, strokeWeight } = args as {
593
+ x: number
594
+ y: number
595
+ length: number
596
+ rotation?: number
597
+ name?: string
598
+ parentId?: string
599
+ stroke?: string
600
+ strokeWeight?: number
601
+ }
602
+ const line = figma.createLine()
603
+ line.x = x
604
+ line.y = y
605
+ line.resize(length, 0)
606
+ if (rotation) line.rotation = rotation
607
+ if (name) line.name = name
608
+ if (stroke) line.strokes = [await createSolidPaint(stroke)]
609
+ if (strokeWeight !== undefined) line.strokeWeight = strokeWeight
610
+ await appendToParent(line, parentId)
611
+ return serializeNode(line)
612
+ }
613
+
614
+ case 'create-polygon': {
615
+ const { x, y, size, sides, name, parentId } = args as {
616
+ x: number
617
+ y: number
618
+ size: number
619
+ sides?: number
620
+ name?: string
621
+ parentId?: string
622
+ }
623
+ const polygon = figma.createPolygon()
624
+ polygon.x = x
625
+ polygon.y = y
626
+ polygon.resize(size, size)
627
+ if (sides) polygon.pointCount = sides
628
+ if (name) polygon.name = name
629
+ await appendToParent(polygon, parentId)
630
+ return serializeNode(polygon)
631
+ }
632
+
633
+ case 'create-star': {
634
+ const { x, y, size, points, innerRadius, name, parentId } = args as {
635
+ x: number
636
+ y: number
637
+ size: number
638
+ points?: number
639
+ innerRadius?: number
640
+ name?: string
641
+ parentId?: string
642
+ }
643
+ const star = figma.createStar()
644
+ star.x = x
645
+ star.y = y
646
+ star.resize(size, size)
647
+ if (points) star.pointCount = points
648
+ if (innerRadius !== undefined) star.innerRadius = innerRadius
649
+ if (name) star.name = name
650
+ await appendToParent(star, parentId)
651
+ return serializeNode(star)
652
+ }
653
+
654
+ case 'create-vector': {
655
+ const { x, y, path, name, parentId } = args as {
656
+ x: number
657
+ y: number
658
+ path: string
659
+ name?: string
660
+ parentId?: string
661
+ }
662
+ const frame = figma.createNodeFromSvg(`<svg><path d="${path}"/></svg>`)
663
+ frame.x = x
664
+ frame.y = y
665
+ if (name) frame.name = name
666
+ await appendToParent(frame, parentId)
667
+ return serializeNode(frame)
668
+ }
669
+
670
+ // ==================== CREATE CONTAINERS ====================
671
+ case 'create-frame': {
672
+ const {
673
+ x,
674
+ y,
675
+ width,
676
+ height,
677
+ name,
678
+ parentId,
679
+ fill,
680
+ stroke,
681
+ strokeWeight,
682
+ radius,
683
+ opacity,
684
+ layoutMode,
685
+ itemSpacing,
686
+ padding
687
+ } = args as {
688
+ x: number
689
+ y: number
690
+ width: number
691
+ height: number
692
+ name?: string
693
+ parentId?: string
694
+ fill?: string
695
+ stroke?: string
696
+ strokeWeight?: number
697
+ radius?: number
698
+ opacity?: number
699
+ layoutMode?: 'HORIZONTAL' | 'VERTICAL' | 'NONE'
700
+ itemSpacing?: number
701
+ padding?: { top: number; right: number; bottom: number; left: number }
702
+ }
703
+ const frame = figma.createFrame()
704
+ frame.x = x
705
+ frame.y = y
706
+ frame.resize(width, height)
707
+ if (name) frame.name = name
708
+ if (fill) frame.fills = [await createSolidPaint(fill)]
709
+ if (stroke) frame.strokes = [await createSolidPaint(stroke)]
710
+ if (strokeWeight !== undefined) frame.strokeWeight = strokeWeight
711
+ if (radius !== undefined) frame.cornerRadius = radius
712
+ if (opacity !== undefined) frame.opacity = opacity
713
+ if (layoutMode && layoutMode !== 'NONE') {
714
+ frame.layoutMode = layoutMode
715
+ if (itemSpacing !== undefined) frame.itemSpacing = itemSpacing
716
+ if (padding) {
717
+ frame.paddingTop = padding.top
718
+ frame.paddingRight = padding.right
719
+ frame.paddingBottom = padding.bottom
720
+ frame.paddingLeft = padding.left
721
+ }
722
+ }
723
+ await appendToParent(frame, parentId)
724
+ return serializeNode(frame)
725
+ }
726
+
727
+ case 'create-section': {
728
+ const { x, y, width, height, name } = args as {
729
+ x: number
730
+ y: number
731
+ width: number
732
+ height: number
733
+ name?: string
734
+ }
735
+ const section = figma.createSection()
736
+ section.x = x
737
+ section.y = y
738
+ section.resizeWithoutConstraints(width, height)
739
+ if (name) section.name = name
740
+ return serializeNode(section)
741
+ }
742
+
743
+ case 'create-slice': {
744
+ const { x, y, width, height, name } = args as {
745
+ x: number
746
+ y: number
747
+ width: number
748
+ height: number
749
+ name?: string
750
+ }
751
+ const slice = figma.createSlice()
752
+ slice.x = x
753
+ slice.y = y
754
+ slice.resize(width, height)
755
+ if (name) slice.name = name
756
+ return serializeNode(slice)
757
+ }
758
+
759
+ // ==================== CREATE OTHER ====================
760
+ case 'create-text': {
761
+ const { x, y, text, fontSize, fontFamily, fontStyle, fill, opacity, name, parentId } =
762
+ args as {
763
+ x: number
764
+ y: number
765
+ text: string
766
+ fontSize?: number
767
+ fontFamily?: string
768
+ fontStyle?: string
769
+ fill?: string
770
+ opacity?: number
771
+ name?: string
772
+ parentId?: string
773
+ }
774
+ const textNode = figma.createText()
775
+ const family = fontFamily || 'Inter'
776
+ const style = fontStyle || 'Regular'
777
+ await loadFont(family, style)
778
+ textNode.x = x
779
+ textNode.y = y
780
+ textNode.fontName = { family, style }
781
+ textNode.characters = text
782
+ if (fontSize) textNode.fontSize = fontSize
783
+ if (fill) textNode.fills = [await createSolidPaint(fill)]
784
+ if (opacity !== undefined) textNode.opacity = opacity
785
+ if (name) textNode.name = name
786
+ await appendToParent(textNode, parentId)
787
+ return serializeNode(textNode)
788
+ }
789
+
790
+ case 'create-instance': {
791
+ const { componentId, x, y, name, parentId } = args as {
792
+ componentId: string
793
+ x?: number
794
+ y?: number
795
+ name?: string
796
+ parentId?: string
797
+ }
798
+ const component = (await figma.getNodeByIdAsync(componentId)) as ComponentNode | null
799
+ if (!component || component.type !== 'COMPONENT') throw new Error('Component not found')
800
+ const instance = component.createInstance()
801
+ if (x !== undefined) instance.x = x
802
+ if (y !== undefined) instance.y = y
803
+ if (name) instance.name = name
804
+ await appendToParent(instance, parentId)
805
+ return serializeNode(instance)
806
+ }
807
+
808
+ case 'create-component': {
809
+ const { name, parentId, x, y, width, height, fill } = args as {
810
+ name: string
811
+ parentId?: string
812
+ x?: number
813
+ y?: number
814
+ width?: number
815
+ height?: number
816
+ fill?: string
817
+ }
818
+ const component = figma.createComponent()
819
+ component.name = name
820
+ if (x !== undefined) component.x = x
821
+ if (y !== undefined) component.y = y
822
+ if (width && height) component.resize(width, height)
823
+ if (fill) component.fills = [await createSolidPaint(fill)]
824
+ await appendToParent(component, parentId)
825
+ return serializeNode(component)
826
+ }
827
+
828
+ case 'clone-node': {
829
+ const { id } = args as { id: string }
830
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
831
+ if (!node || !('clone' in node)) throw new Error('Node not found')
832
+ const clone = node.clone()
833
+ return serializeNode(clone)
834
+ }
835
+
836
+ case 'convert-to-component': {
837
+ const { id } = args as { id: string }
838
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
839
+ if (!node) throw new Error('Node not found')
840
+ const component = figma.createComponentFromNode(node)
841
+ return serializeNode(component)
842
+ }
843
+
844
+ // ==================== CREATE STYLES ====================
845
+ case 'create-paint-style': {
846
+ const { name, color } = args as { name: string; color: string }
847
+ const style = figma.createPaintStyle()
848
+ style.name = name
849
+ style.paints = [await createSolidPaint(color)]
850
+ return { id: style.id, name: style.name, key: style.key }
851
+ }
852
+
853
+ case 'create-text-style': {
854
+ const { name, fontFamily, fontStyle, fontSize } = args as {
855
+ name: string
856
+ fontFamily?: string
857
+ fontStyle?: string
858
+ fontSize?: number
859
+ }
860
+ const style = figma.createTextStyle()
861
+ style.name = name
862
+ await loadFont(fontFamily || 'Inter', fontStyle || 'Regular')
863
+ style.fontName = { family: fontFamily || 'Inter', style: fontStyle || 'Regular' }
864
+ if (fontSize) style.fontSize = fontSize
865
+ return { id: style.id, name: style.name, key: style.key }
866
+ }
867
+
868
+ case 'create-effect-style': {
869
+ const { name, type, radius, color, offsetX, offsetY } = args as {
870
+ name: string
871
+ type: string
872
+ radius?: number
873
+ color?: string
874
+ offsetX?: number
875
+ offsetY?: number
876
+ }
877
+ const style = figma.createEffectStyle()
878
+ style.name = name
879
+ const rgba = color ? hexToRgba(color) : { r: 0, g: 0, b: 0, a: 0.25 }
880
+ if (type === 'DROP_SHADOW' || type === 'INNER_SHADOW') {
881
+ style.effects = [
882
+ {
883
+ type: type as 'DROP_SHADOW' | 'INNER_SHADOW',
884
+ color: rgba,
885
+ offset: { x: offsetX || 0, y: offsetY || 4 },
886
+ radius: radius || 10,
887
+ spread: 0,
888
+ visible: true,
889
+ blendMode: 'NORMAL'
890
+ }
891
+ ]
892
+ } else if (type === 'BLUR' || type === 'BACKGROUND_BLUR') {
893
+ style.effects = [
894
+ {
895
+ type: type as 'LAYER_BLUR' | 'BACKGROUND_BLUR',
896
+ blurType: 'NORMAL',
897
+ radius: radius || 10,
898
+ visible: true
899
+ } as BlurEffect
900
+ ]
901
+ }
902
+ return { id: style.id, name: style.name, key: style.key }
903
+ }
904
+
905
+ // ==================== UPDATE POSITION/SIZE ====================
906
+ case 'move-node': {
907
+ const { id, x, y } = args as { id: string; x: number; y: number }
908
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
909
+ if (!node) throw new Error('Node not found')
910
+ node.x = x
911
+ node.y = y
912
+ return serializeNode(node)
913
+ }
914
+
915
+ case 'resize-node': {
916
+ const { id, width, height } = args as { id: string; width: number; height: number }
917
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
918
+ if (!node) throw new Error('Node not found')
919
+ if ('resize' in node) {
920
+ node.resize(width, height)
921
+ } else if ('width' in node && 'height' in node) {
922
+ ;(node as SectionNode).resizeWithoutConstraints(width, height)
923
+ } else {
924
+ throw new Error('Node cannot be resized')
925
+ }
926
+ return serializeNode(node)
927
+ }
928
+
929
+ // ==================== UPDATE APPEARANCE ====================
930
+ case 'set-fill-color': {
931
+ const { id, color } = args as { id: string; color: string }
932
+ const node = (await figma.getNodeByIdAsync(id)) as GeometryMixin | null
933
+ if (!node || !('fills' in node)) throw new Error('Node not found')
934
+ node.fills = [await createSolidPaint(color)]
935
+ return serializeNode(node as BaseNode)
936
+ }
937
+
938
+ case 'set-stroke-color': {
939
+ const { id, color, weight, align } = args as {
940
+ id: string
941
+ color: string
942
+ weight?: number
943
+ align?: string
944
+ }
945
+ const node = (await figma.getNodeByIdAsync(id)) as GeometryMixin | null
946
+ if (!node || !('strokes' in node)) throw new Error('Node not found')
947
+ node.strokes = [await createSolidPaint(color)]
948
+ if (weight !== undefined && 'strokeWeight' in node) (node as any).strokeWeight = weight
949
+ if (align && 'strokeAlign' in node)
950
+ (node as any).strokeAlign = align as 'INSIDE' | 'OUTSIDE' | 'CENTER'
951
+ return serializeNode(node as BaseNode)
952
+ }
953
+
954
+ case 'set-corner-radius': {
955
+ const {
956
+ id,
957
+ cornerRadius,
958
+ topLeftRadius,
959
+ topRightRadius,
960
+ bottomLeftRadius,
961
+ bottomRightRadius
962
+ } = args as {
963
+ id: string
964
+ cornerRadius: number
965
+ topLeftRadius?: number
966
+ topRightRadius?: number
967
+ bottomLeftRadius?: number
968
+ bottomRightRadius?: number
969
+ }
970
+ const node = (await figma.getNodeByIdAsync(id)) as RectangleNode | null
971
+ if (!node || !('cornerRadius' in node)) throw new Error('Node not found')
972
+ node.cornerRadius = cornerRadius
973
+ if (topLeftRadius !== undefined) node.topLeftRadius = topLeftRadius
974
+ if (topRightRadius !== undefined) node.topRightRadius = topRightRadius
975
+ if (bottomLeftRadius !== undefined) node.bottomLeftRadius = bottomLeftRadius
976
+ if (bottomRightRadius !== undefined) node.bottomRightRadius = bottomRightRadius
977
+ return serializeNode(node)
978
+ }
979
+
980
+ case 'set-opacity': {
981
+ const { id, opacity } = args as { id: string; opacity: number }
982
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
983
+ if (!node || !('opacity' in node)) throw new Error('Node not found')
984
+ node.opacity = opacity
985
+ return serializeNode(node)
986
+ }
987
+
988
+ case 'set-image-fill': {
989
+ const { id, url, scaleMode } = args as { id: string; url: string; scaleMode?: string }
990
+ const node = (await figma.getNodeByIdAsync(id)) as GeometryMixin | null
991
+ if (!node || !('fills' in node)) throw new Error('Node not found')
992
+ const image = await figma.createImageAsync(url)
993
+ node.fills = [
994
+ {
995
+ type: 'IMAGE',
996
+ imageHash: image.hash,
997
+ scaleMode: (scaleMode || 'FILL') as 'FILL' | 'FIT' | 'CROP' | 'TILE'
998
+ }
999
+ ]
1000
+ return serializeNode(node as BaseNode)
1001
+ }
1002
+
1003
+ // ==================== UPDATE PROPERTIES ====================
1004
+ case 'rename-node': {
1005
+ const { id, name } = args as { id: string; name: string }
1006
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1007
+ if (!node) throw new Error('Node not found')
1008
+ node.name = name
1009
+ return serializeNode(node)
1010
+ }
1011
+
1012
+ case 'bind-fill-variable-by-name': {
1013
+ const { id, variableName, recursive } = args as {
1014
+ id: string
1015
+ variableName: string
1016
+ recursive?: boolean
1017
+ }
1018
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1019
+ if (!node) throw new Error('Node not found')
1020
+
1021
+ const variables = await figma.variables.getLocalVariablesAsync('COLOR')
1022
+ const variable = variables.find((v) => v.name === variableName)
1023
+ if (!variable) throw new Error(`Variable "${variableName}" not found`)
1024
+
1025
+ function bindFills(n: SceneNode) {
1026
+ if ('fills' in n && Array.isArray(n.fills) && n.fills.length > 0) {
1027
+ const fills = [...n.fills] as Paint[]
1028
+ for (let i = 0; i < fills.length; i++) {
1029
+ if (fills[i].type === 'SOLID') {
1030
+ fills[i] = figma.variables.setBoundVariableForPaint(fills[i], 'color', variable)
1031
+ }
1032
+ }
1033
+ ;(n as GeometryMixin).fills = fills
1034
+ }
1035
+ if (recursive && 'children' in n) {
1036
+ for (const child of (n as ChildrenMixin).children) {
1037
+ bindFills(child as SceneNode)
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ bindFills(node)
1043
+ return serializeNode(node)
1044
+ }
1045
+
1046
+ case 'set-visible': {
1047
+ const { id, visible } = args as { id: string; visible: boolean }
1048
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1049
+ if (!node) throw new Error('Node not found')
1050
+ node.visible = visible
1051
+ return serializeNode(node)
1052
+ }
1053
+
1054
+ case 'set-locked': {
1055
+ const { id, locked } = args as { id: string; locked: boolean }
1056
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1057
+ if (!node) throw new Error('Node not found')
1058
+ node.locked = locked
1059
+ return serializeNode(node)
1060
+ }
1061
+
1062
+ case 'set-effect': {
1063
+ const { id, type, color, offsetX, offsetY, radius, spread } = args as {
1064
+ id: string
1065
+ type: string
1066
+ color?: string
1067
+ offsetX?: number
1068
+ offsetY?: number
1069
+ radius?: number
1070
+ spread?: number
1071
+ }
1072
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1073
+ if (!node || !('effects' in node)) throw new Error('Node not found')
1074
+ const rgba = color ? hexToRgba(color) : { r: 0, g: 0, b: 0, a: 0.25 }
1075
+ if (type === 'DROP_SHADOW' || type === 'INNER_SHADOW') {
1076
+ node.effects = [
1077
+ {
1078
+ type: type as 'DROP_SHADOW' | 'INNER_SHADOW',
1079
+ color: rgba,
1080
+ offset: { x: offsetX ?? 0, y: offsetY ?? 4 },
1081
+ radius: radius ?? 8,
1082
+ spread: spread ?? 0,
1083
+ visible: true,
1084
+ blendMode: 'NORMAL'
1085
+ }
1086
+ ]
1087
+ } else if (type === 'BLUR') {
1088
+ node.effects = [
1089
+ {
1090
+ type: 'LAYER_BLUR',
1091
+ blurType: 'NORMAL',
1092
+ radius: radius ?? 8,
1093
+ visible: true
1094
+ } as BlurEffect
1095
+ ]
1096
+ }
1097
+ return serializeNode(node)
1098
+ }
1099
+
1100
+ case 'set-text': {
1101
+ const { id, text } = args as { id: string; text: string }
1102
+ const node = (await figma.getNodeByIdAsync(id)) as TextNode | null
1103
+ if (!node || node.type !== 'TEXT') throw new Error('Text node not found')
1104
+ const fontName = node.fontName as FontName
1105
+ await loadFont(fontName.family, fontName.style)
1106
+ node.characters = text
1107
+ return serializeNode(node)
1108
+ }
1109
+
1110
+ case 'import-svg': {
1111
+ const { svg, x, y, name, parentId, noFill, insertIndex } = args as {
1112
+ svg: string
1113
+ x?: number
1114
+ y?: number
1115
+ name?: string
1116
+ parentId?: string
1117
+ noFill?: boolean
1118
+ insertIndex?: number
1119
+ }
1120
+ const node = figma.createNodeFromSvg(svg)
1121
+ if (x !== undefined) node.x = x
1122
+ if (y !== undefined) node.y = y
1123
+ if (name) node.name = name
1124
+ if (noFill) node.fills = []
1125
+ await appendToParent(node, parentId, insertIndex)
1126
+ return serializeNode(node)
1127
+ }
1128
+
1129
+ case 'set-font': {
1130
+ const { id, fontFamily, fontStyle, fontSize } = args as {
1131
+ id: string
1132
+ fontFamily?: string
1133
+ fontStyle?: string
1134
+ fontSize?: number
1135
+ }
1136
+ const node = (await figma.getNodeByIdAsync(id)) as TextNode | null
1137
+ if (!node || node.type !== 'TEXT') throw new Error('Text node not found')
1138
+ const currentFont = node.fontName as FontName
1139
+ const family = fontFamily || currentFont.family
1140
+ const style = fontStyle || currentFont.style
1141
+ await loadFont(family, style)
1142
+ node.fontName = { family, style }
1143
+ if (fontSize !== undefined) node.fontSize = fontSize
1144
+ return serializeNode(node)
1145
+ }
1146
+
1147
+ case 'set-font-range': {
1148
+ const { id, start, end, family, style, size, color } = args as {
1149
+ id: string
1150
+ start: number
1151
+ end: number
1152
+ family?: string
1153
+ style?: string
1154
+ size?: number
1155
+ color?: string
1156
+ }
1157
+ const node = (await figma.getNodeByIdAsync(id)) as TextNode | null
1158
+ if (!node || node.type !== 'TEXT') throw new Error('Text node not found')
1159
+
1160
+ if (family || style) {
1161
+ const currentFont = node.getRangeFontName(start, end) as FontName
1162
+ const newFamily = family || currentFont.family
1163
+ const newStyle = style || currentFont.style
1164
+ await loadFont(newFamily, newStyle)
1165
+ node.setRangeFontName(start, end, { family: newFamily, style: newStyle })
1166
+ }
1167
+
1168
+ if (size !== undefined) {
1169
+ node.setRangeFontSize(start, end, size)
1170
+ }
1171
+
1172
+ if (color) {
1173
+ node.setRangeFills(start, end, [await createSolidPaint(color)])
1174
+ }
1175
+
1176
+ return serializeNode(node)
1177
+ }
1178
+
1179
+ case 'get-children': {
1180
+ const { id, depth } = args as { id: string; depth?: number }
1181
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1182
+ if (!node) throw new Error('Node not found')
1183
+ if (!('children' in node)) return []
1184
+ const maxDepth = depth || 1
1185
+ const serializeWithDepth = (n: SceneNode, d: number): object => {
1186
+ const base = serializeNode(n) as Record<string, unknown>
1187
+ if (d < maxDepth && 'children' in n) {
1188
+ base.children = (n as FrameNode).children.map((c) => serializeWithDepth(c, d + 1))
1189
+ }
1190
+ return base
1191
+ }
1192
+ return (node as FrameNode).children.map((c) => serializeWithDepth(c, 1))
1193
+ }
1194
+
1195
+ case 'find-by-name': {
1196
+ const {
1197
+ name,
1198
+ type,
1199
+ exact,
1200
+ limit = 100
1201
+ } = args as { name?: string; type?: string; exact?: boolean; limit?: number }
1202
+ const results: object[] = []
1203
+ const nameLower = name?.toLowerCase()
1204
+
1205
+ const searchNode = (node: SceneNode): boolean => {
1206
+ if (results.length >= limit) return false
1207
+ const nameMatch =
1208
+ !nameLower || (exact ? node.name === name : node.name.toLowerCase().includes(nameLower))
1209
+ const typeMatch = !type || node.type === type
1210
+ if (nameMatch && typeMatch) results.push(serializeNode(node))
1211
+ if ('children' in node) {
1212
+ for (const child of (node as FrameNode).children) {
1213
+ if (!searchNode(child)) return false
1214
+ }
1215
+ }
1216
+ return results.length < limit
1217
+ }
1218
+
1219
+ for (const child of figma.currentPage.children) {
1220
+ if (!searchNode(child)) break
1221
+ }
1222
+ return results
1223
+ }
1224
+
1225
+ case 'select-nodes': {
1226
+ const { ids } = args as { ids: string[] }
1227
+ const nodes = await Promise.all(ids.map((id) => figma.getNodeByIdAsync(id)))
1228
+ const validNodes = nodes.filter((n): n is SceneNode => n !== null && 'id' in n)
1229
+ figma.currentPage.selection = validNodes
1230
+ return { selected: validNodes.length }
1231
+ }
1232
+
1233
+ case 'set-constraints': {
1234
+ const { id, horizontal, vertical } = args as {
1235
+ id: string
1236
+ horizontal?: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'SCALE'
1237
+ vertical?: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'SCALE'
1238
+ }
1239
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1240
+ if (!node || !('constraints' in node)) throw new Error('Node not found')
1241
+ node.constraints = {
1242
+ horizontal: horizontal || node.constraints.horizontal,
1243
+ vertical: vertical || node.constraints.vertical
1244
+ }
1245
+ return serializeNode(node)
1246
+ }
1247
+
1248
+ case 'set-blend-mode': {
1249
+ const { id, mode } = args as { id: string; mode: BlendMode }
1250
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1251
+ if (!node || !('blendMode' in node)) throw new Error('Node not found')
1252
+ node.blendMode = mode
1253
+ return serializeNode(node)
1254
+ }
1255
+
1256
+ case 'set-auto-layout': {
1257
+ const {
1258
+ id,
1259
+ mode,
1260
+ wrap,
1261
+ itemSpacing,
1262
+ counterSpacing,
1263
+ padding,
1264
+ primaryAlign,
1265
+ counterAlign,
1266
+ sizingH,
1267
+ sizingV,
1268
+ gridColumnSizes,
1269
+ gridRowSizes,
1270
+ gridColumnGap,
1271
+ gridRowGap
1272
+ } = args as {
1273
+ id: string
1274
+ mode?: 'HORIZONTAL' | 'VERTICAL' | 'GRID' | 'NONE'
1275
+ wrap?: boolean
1276
+ itemSpacing?: number
1277
+ counterSpacing?: number
1278
+ padding?: { top: number; right: number; bottom: number; left: number }
1279
+ primaryAlign?: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN'
1280
+ counterAlign?: 'MIN' | 'CENTER' | 'MAX' | 'BASELINE'
1281
+ sizingH?: 'FIXED' | 'HUG' | 'FILL'
1282
+ sizingV?: 'FIXED' | 'HUG' | 'FILL'
1283
+ gridColumnSizes?: Array<{ type: 'FIXED' | 'FLEX' | 'HUG'; value?: number }>
1284
+ gridRowSizes?: Array<{ type: 'FIXED' | 'FLEX' | 'HUG'; value?: number }>
1285
+ gridColumnGap?: number
1286
+ gridRowGap?: number
1287
+ }
1288
+ const node = (await figma.getNodeByIdAsync(id)) as FrameNode | null
1289
+ if (!node || !('layoutMode' in node)) throw new Error('Frame not found')
1290
+ if (mode) node.layoutMode = mode
1291
+ if (wrap !== undefined) node.layoutWrap = wrap ? 'WRAP' : 'NO_WRAP'
1292
+ if (itemSpacing !== undefined) node.itemSpacing = itemSpacing
1293
+ if (counterSpacing !== undefined) node.counterAxisSpacing = counterSpacing
1294
+ if (padding) {
1295
+ node.paddingTop = padding.top
1296
+ node.paddingRight = padding.right
1297
+ node.paddingBottom = padding.bottom
1298
+ node.paddingLeft = padding.left
1299
+ }
1300
+ if (primaryAlign) node.primaryAxisAlignItems = primaryAlign
1301
+ if (counterAlign) node.counterAxisAlignItems = counterAlign
1302
+ if (sizingH) node.layoutSizingHorizontal = sizingH
1303
+ if (sizingV) node.layoutSizingVertical = sizingV
1304
+ if (gridColumnSizes) {
1305
+ node.gridColumnCount = gridColumnSizes.length
1306
+ node.gridColumnSizes = gridColumnSizes
1307
+ }
1308
+ if (gridRowSizes) {
1309
+ node.gridRowCount = gridRowSizes.length
1310
+ node.gridRowSizes = gridRowSizes
1311
+ }
1312
+ if (gridColumnGap !== undefined) node.gridColumnGap = gridColumnGap
1313
+ if (gridRowGap !== undefined) node.gridRowGap = gridRowGap
1314
+ return serializeNode(node)
1315
+ }
1316
+
1317
+ case 'set-layout-child': {
1318
+ const { id, horizontalSizing, verticalSizing, positioning, x, y } = args as {
1319
+ id: string
1320
+ horizontalSizing?: 'FIXED' | 'FILL' | 'HUG'
1321
+ verticalSizing?: 'FIXED' | 'FILL' | 'HUG'
1322
+ positioning?: 'AUTO' | 'ABSOLUTE'
1323
+ x?: number
1324
+ y?: number
1325
+ }
1326
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1327
+ if (!node) throw new Error('Node not found')
1328
+ if (horizontalSizing && 'layoutSizingHorizontal' in node) {
1329
+ ;(node as FrameNode).layoutSizingHorizontal = horizontalSizing
1330
+ }
1331
+ if (verticalSizing && 'layoutSizingVertical' in node) {
1332
+ ;(node as FrameNode).layoutSizingVertical = verticalSizing
1333
+ }
1334
+ if (positioning && 'layoutPositioning' in node) {
1335
+ ;(node as FrameNode).layoutPositioning = positioning
1336
+ }
1337
+ if (positioning === 'ABSOLUTE') {
1338
+ if (x !== undefined) node.x = x
1339
+ if (y !== undefined) node.y = y
1340
+ }
1341
+ return serializeNode(node)
1342
+ }
1343
+
1344
+ case 'set-text-properties': {
1345
+ const {
1346
+ id,
1347
+ lineHeight,
1348
+ letterSpacing,
1349
+ textAlign,
1350
+ verticalAlign,
1351
+ autoResize,
1352
+ maxLines,
1353
+ paragraphSpacing,
1354
+ paragraphIndent
1355
+ } = args as {
1356
+ id: string
1357
+ lineHeight?: number | 'auto'
1358
+ letterSpacing?: number
1359
+ textAlign?: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
1360
+ verticalAlign?: 'TOP' | 'CENTER' | 'BOTTOM'
1361
+ autoResize?: 'NONE' | 'WIDTH_AND_HEIGHT' | 'HEIGHT' | 'TRUNCATE'
1362
+ maxLines?: number
1363
+ paragraphSpacing?: number
1364
+ paragraphIndent?: number
1365
+ }
1366
+ const node = (await figma.getNodeByIdAsync(id)) as TextNode | null
1367
+ if (!node || node.type !== 'TEXT') throw new Error('Text node not found')
1368
+
1369
+ const fontName = node.fontName as FontName
1370
+ await loadFont(fontName.family, fontName.style)
1371
+
1372
+ if (lineHeight !== undefined) {
1373
+ node.lineHeight =
1374
+ lineHeight === 'auto' ? { unit: 'AUTO' } : { unit: 'PIXELS', value: lineHeight }
1375
+ }
1376
+ if (letterSpacing !== undefined) {
1377
+ node.letterSpacing = { unit: 'PIXELS', value: letterSpacing }
1378
+ }
1379
+ if (textAlign) node.textAlignHorizontal = textAlign
1380
+ if (verticalAlign) node.textAlignVertical = verticalAlign
1381
+ if (autoResize) node.textAutoResize = autoResize
1382
+ if (maxLines !== undefined) node.maxLines = maxLines
1383
+ if (paragraphSpacing !== undefined) node.paragraphSpacing = paragraphSpacing
1384
+ if (paragraphIndent !== undefined) node.paragraphIndent = paragraphIndent
1385
+ return serializeNode(node)
1386
+ }
1387
+
1388
+ case 'set-min-max': {
1389
+ const { id, minWidth, maxWidth, minHeight, maxHeight } = args as {
1390
+ id: string
1391
+ minWidth?: number
1392
+ maxWidth?: number
1393
+ minHeight?: number
1394
+ maxHeight?: number
1395
+ }
1396
+ const node = (await figma.getNodeByIdAsync(id)) as FrameNode | null
1397
+ if (!node) throw new Error('Node not found')
1398
+ if (minWidth !== undefined && 'minWidth' in node) node.minWidth = minWidth
1399
+ if (maxWidth !== undefined && 'maxWidth' in node) node.maxWidth = maxWidth
1400
+ if (minHeight !== undefined && 'minHeight' in node) node.minHeight = minHeight
1401
+ if (maxHeight !== undefined && 'maxHeight' in node) node.maxHeight = maxHeight
1402
+ return serializeNode(node)
1403
+ }
1404
+
1405
+ case 'set-rotation': {
1406
+ const { id, angle } = args as { id: string; angle: number }
1407
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1408
+ if (!node) throw new Error('Node not found')
1409
+ if ('rotation' in node) node.rotation = angle
1410
+ return serializeNode(node)
1411
+ }
1412
+
1413
+ case 'set-stroke-align': {
1414
+ const { id, align } = args as { id: string; align: 'INSIDE' | 'OUTSIDE' | 'CENTER' }
1415
+ const node = (await figma.getNodeByIdAsync(id)) as GeometryMixin | null
1416
+ if (!node || !('strokeAlign' in node)) throw new Error('Node not found')
1417
+ node.strokeAlign = align
1418
+ return serializeNode(node as BaseNode)
1419
+ }
1420
+
1421
+ // ==================== UPDATE STRUCTURE ====================
1422
+ case 'set-layout': {
1423
+ const {
1424
+ id,
1425
+ mode,
1426
+ wrap,
1427
+ clip,
1428
+ itemSpacing,
1429
+ primaryAxisAlignItems,
1430
+ counterAxisAlignItems,
1431
+ paddingLeft,
1432
+ paddingRight,
1433
+ paddingTop,
1434
+ paddingBottom,
1435
+ layoutSizingVertical,
1436
+ layoutSizingHorizontal
1437
+ } = args as {
1438
+ id: string
1439
+ mode: 'NONE' | 'HORIZONTAL' | 'VERTICAL'
1440
+ wrap?: boolean
1441
+ clip?: boolean
1442
+ itemSpacing?: number
1443
+ primaryAxisAlignItems?: 'MIN' | 'MAX' | 'CENTER' | 'SPACE_BETWEEN'
1444
+ counterAxisAlignItems?: 'MIN' | 'MAX' | 'CENTER' | 'BASELINE'
1445
+ paddingLeft?: number
1446
+ paddingRight?: number
1447
+ paddingTop?: number
1448
+ paddingBottom?: number
1449
+ layoutSizingVertical?: 'FIXED' | 'HUG' | 'FILL'
1450
+ layoutSizingHorizontal?: 'FIXED' | 'HUG' | 'FILL'
1451
+ }
1452
+ const node = (await figma.getNodeByIdAsync(id)) as FrameNode | null
1453
+ if (!node || !('layoutMode' in node)) throw new Error('Node not found')
1454
+ node.layoutMode = mode
1455
+ if (wrap !== undefined) node.layoutWrap = wrap ? 'WRAP' : 'NO_WRAP'
1456
+ if (clip !== undefined) node.clipsContent = clip
1457
+ if (itemSpacing !== undefined) node.itemSpacing = itemSpacing
1458
+ if (primaryAxisAlignItems) node.primaryAxisAlignItems = primaryAxisAlignItems
1459
+ if (counterAxisAlignItems) node.counterAxisAlignItems = counterAxisAlignItems
1460
+ if (paddingLeft !== undefined) node.paddingLeft = paddingLeft
1461
+ if (paddingRight !== undefined) node.paddingRight = paddingRight
1462
+ if (paddingTop !== undefined) node.paddingTop = paddingTop
1463
+ if (paddingBottom !== undefined) node.paddingBottom = paddingBottom
1464
+ if (layoutSizingVertical) node.layoutSizingVertical = layoutSizingVertical
1465
+ if (layoutSizingHorizontal) node.layoutSizingHorizontal = layoutSizingHorizontal
1466
+ return serializeNode(node)
1467
+ }
1468
+
1469
+ case 'set-parent-id': {
1470
+ const { id, parentId } = args as { id: string; parentId: string }
1471
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1472
+ const parent = (await figma.getNodeByIdAsync(parentId)) as (FrameNode & ChildrenMixin) | null
1473
+ if (!node || !parent) throw new Error('Node or parent not found')
1474
+ parent.appendChild(node)
1475
+ return serializeNode(node)
1476
+ }
1477
+
1478
+ case 'group-nodes': {
1479
+ const { ids, name } = args as { ids: string[]; name?: string }
1480
+ const nodes = await Promise.all(ids.map((id) => figma.getNodeByIdAsync(id)))
1481
+ const validNodes = nodes.filter((n): n is SceneNode => n !== null && 'parent' in n)
1482
+ if (validNodes.length === 0) throw new Error('No valid nodes found')
1483
+ const parent = validNodes[0].parent
1484
+ if (!parent || !('children' in parent)) throw new Error('Invalid parent')
1485
+ const group = figma.group(validNodes, parent)
1486
+ if (name) group.name = name
1487
+ return serializeNode(group)
1488
+ }
1489
+
1490
+ case 'ungroup-node': {
1491
+ const { id } = args as { id: string }
1492
+ const node = (await figma.getNodeByIdAsync(id)) as GroupNode | null
1493
+ if (!node || node.type !== 'GROUP') throw new Error('Not a group node')
1494
+ const children = figma.ungroup(node)
1495
+ return children.map(serializeNode)
1496
+ }
1497
+
1498
+ case 'flatten-nodes': {
1499
+ const { ids } = args as { ids: string[] }
1500
+ const nodes = await Promise.all(ids.map((id) => figma.getNodeByIdAsync(id)))
1501
+ const validNodes = nodes.filter((n): n is SceneNode => n !== null && 'parent' in n)
1502
+ if (validNodes.length === 0) throw new Error('No valid nodes found')
1503
+ const vector = figma.flatten(validNodes)
1504
+ return serializeNode(vector)
1505
+ }
1506
+
1507
+ // ==================== BOOLEAN OPERATIONS ====================
1508
+ case 'boolean-operation': {
1509
+ const { ids, operation } = args as {
1510
+ ids: string[]
1511
+ operation: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
1512
+ }
1513
+ const nodes = await Promise.all(ids.map((id) => figma.getNodeByIdAsync(id)))
1514
+ const validNodes = nodes.filter((n): n is SceneNode => n !== null && 'parent' in n)
1515
+ if (validNodes.length < 2) throw new Error('Need at least 2 nodes')
1516
+ const parent = validNodes[0].parent
1517
+ if (!parent || !('children' in parent)) throw new Error('Invalid parent')
1518
+ let result: BooleanOperationNode
1519
+ switch (operation) {
1520
+ case 'UNION':
1521
+ result = figma.union(validNodes, parent)
1522
+ break
1523
+ case 'SUBTRACT':
1524
+ result = figma.subtract(validNodes, parent)
1525
+ break
1526
+ case 'INTERSECT':
1527
+ result = figma.intersect(validNodes, parent)
1528
+ break
1529
+ case 'EXCLUDE':
1530
+ result = figma.exclude(validNodes, parent)
1531
+ break
1532
+ }
1533
+ return serializeNode(result)
1534
+ }
1535
+
1536
+ // ==================== INSTANCE/COMPONENT ====================
1537
+ case 'set-instance-properties': {
1538
+ const { instanceId, properties } = args as {
1539
+ instanceId: string
1540
+ properties: Record<string, unknown>
1541
+ }
1542
+ const instance = (await figma.getNodeByIdAsync(instanceId)) as InstanceNode | null
1543
+ if (!instance || instance.type !== 'INSTANCE') throw new Error('Instance not found')
1544
+ instance.setProperties(properties as { [key: string]: string | boolean })
1545
+ return serializeNode(instance)
1546
+ }
1547
+
1548
+ case 'set-node-component-property-references': {
1549
+ const { id, componentPropertyReferences } = args as {
1550
+ id: string
1551
+ componentPropertyReferences: Record<string, string>
1552
+ }
1553
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1554
+ if (!node || !('componentPropertyReferences' in node)) throw new Error('Node not found')
1555
+ for (const [key, value] of Object.entries(componentPropertyReferences)) {
1556
+ node.componentPropertyReferences = { ...node.componentPropertyReferences, [key]: value }
1557
+ }
1558
+ return serializeNode(node)
1559
+ }
1560
+
1561
+ case 'add-component-property': {
1562
+ const { componentId, name, type, defaultValue } = args as {
1563
+ componentId: string
1564
+ name: string
1565
+ type: 'BOOLEAN' | 'TEXT' | 'INSTANCE_SWAP' | 'VARIANT'
1566
+ defaultValue: string | boolean
1567
+ }
1568
+ const component = (await figma.getNodeByIdAsync(componentId)) as
1569
+ | ComponentNode
1570
+ | ComponentSetNode
1571
+ | null
1572
+ if (!component || (component.type !== 'COMPONENT' && component.type !== 'COMPONENT_SET')) {
1573
+ throw new Error('Component not found')
1574
+ }
1575
+ let parsedDefault: string | boolean = defaultValue
1576
+ if (type === 'BOOLEAN') parsedDefault = defaultValue === 'true' || defaultValue === true
1577
+ component.addComponentProperty(name, type, parsedDefault)
1578
+ return serializeNode(component)
1579
+ }
1580
+
1581
+ case 'edit-component-property': {
1582
+ const { componentId, name, defaultValue, preferredValues } = args as {
1583
+ componentId: string
1584
+ name: string
1585
+ defaultValue: string | boolean
1586
+ preferredValues?: string[]
1587
+ }
1588
+ const component = (await figma.getNodeByIdAsync(componentId)) as
1589
+ | ComponentNode
1590
+ | ComponentSetNode
1591
+ | null
1592
+ if (!component || (component.type !== 'COMPONENT' && component.type !== 'COMPONENT_SET')) {
1593
+ throw new Error('Component not found')
1594
+ }
1595
+ const props = component.componentPropertyDefinitions
1596
+ const propKey = Object.keys(props).find((k) => k === name || k.startsWith(name + '#'))
1597
+ if (!propKey) throw new Error('Property not found')
1598
+ const propDef = props[propKey]
1599
+ let parsedDefault: string | boolean = defaultValue
1600
+ if (propDef.type === 'BOOLEAN')
1601
+ parsedDefault = defaultValue === 'true' || defaultValue === true
1602
+ component.editComponentProperty(propKey, {
1603
+ name,
1604
+ defaultValue: parsedDefault,
1605
+ preferredValues: preferredValues?.map((v) => ({ type: 'COMPONENT', key: v }))
1606
+ })
1607
+ return serializeNode(component)
1608
+ }
1609
+
1610
+ case 'delete-component-property': {
1611
+ const { componentId, name } = args as { componentId: string; name: string }
1612
+ const component = (await figma.getNodeByIdAsync(componentId)) as
1613
+ | ComponentNode
1614
+ | ComponentSetNode
1615
+ | null
1616
+ if (!component || (component.type !== 'COMPONENT' && component.type !== 'COMPONENT_SET')) {
1617
+ throw new Error('Component not found')
1618
+ }
1619
+ const props = component.componentPropertyDefinitions
1620
+ const propKey = Object.keys(props).find((k) => k === name || k.startsWith(name + '#'))
1621
+ if (!propKey) throw new Error('Property not found')
1622
+ component.deleteComponentProperty(propKey)
1623
+ return serializeNode(component)
1624
+ }
1625
+
1626
+ // ==================== VIEWPORT ====================
1627
+ case 'set-viewport': {
1628
+ const { x, y, zoom } = args as { x?: number; y?: number; zoom?: number }
1629
+ if (x !== undefined && y !== undefined) {
1630
+ figma.viewport.center = { x, y }
1631
+ }
1632
+ if (zoom !== undefined) {
1633
+ figma.viewport.zoom = zoom
1634
+ }
1635
+ return { center: figma.viewport.center, zoom: figma.viewport.zoom }
1636
+ }
1637
+
1638
+ case 'zoom-to-fit': {
1639
+ const { ids } = args as { ids?: string[] }
1640
+ let nodes: SceneNode[]
1641
+ if (ids && ids.length > 0) {
1642
+ const fetched = await Promise.all(ids.map((id) => figma.getNodeByIdAsync(id)))
1643
+ nodes = fetched.filter((n): n is SceneNode => n !== null && 'absoluteBoundingBox' in n)
1644
+ } else {
1645
+ nodes = figma.currentPage.selection as SceneNode[]
1646
+ }
1647
+ if (nodes.length > 0) {
1648
+ figma.viewport.scrollAndZoomIntoView(nodes)
1649
+ }
1650
+ return { center: figma.viewport.center, zoom: figma.viewport.zoom }
1651
+ }
1652
+
1653
+ // ==================== EXPORT ====================
1654
+ case 'export-node': {
1655
+ const { id, format, scale } = args as {
1656
+ id: string
1657
+ format: 'PNG' | 'JPG' | 'SVG' | 'PDF'
1658
+ scale?: number
1659
+ }
1660
+ const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
1661
+ if (!node) throw new Error('Node not found')
1662
+ const bytes = await node.exportAsync({
1663
+ format: format,
1664
+ ...(format !== 'SVG' && format !== 'PDF' ? { scale: scale || 1 } : {})
1665
+ } as ExportSettings)
1666
+ return {
1667
+ data: figma.base64Encode(bytes),
1668
+ filename: `${node.name}.${format.toLowerCase()}`
1669
+ }
1670
+ }
1671
+
1672
+ case 'screenshot': {
1673
+ const { scale } = args as { scale?: number }
1674
+ const bounds = figma.viewport.bounds
1675
+ const frame = figma.createFrame()
1676
+ frame.name = '__screenshot_temp__'
1677
+ frame.x = bounds.x
1678
+ frame.y = bounds.y
1679
+ frame.resize(bounds.width, bounds.height)
1680
+ frame.fills = []
1681
+ frame.clipsContent = true
1682
+
1683
+ // Clone visible nodes that intersect viewport
1684
+ for (const node of figma.currentPage.children) {
1685
+ if (node.id === frame.id) continue
1686
+ if (!node.visible) continue
1687
+ if ('absoluteBoundingBox' in node && node.absoluteBoundingBox) {
1688
+ const nb = node.absoluteBoundingBox
1689
+ if (
1690
+ nb.x + nb.width > bounds.x &&
1691
+ nb.x < bounds.x + bounds.width &&
1692
+ nb.y + nb.height > bounds.y &&
1693
+ nb.y < bounds.y + bounds.height
1694
+ ) {
1695
+ const clone = node.clone()
1696
+ clone.x = node.x - bounds.x
1697
+ clone.y = node.y - bounds.y
1698
+ frame.appendChild(clone)
1699
+ }
1700
+ }
1701
+ }
1702
+
1703
+ const bytes = await frame.exportAsync({
1704
+ format: 'PNG',
1705
+ constraint: { type: 'SCALE', value: scale || 1 }
1706
+ })
1707
+ frame.remove()
1708
+ return { data: figma.base64Encode(bytes) }
1709
+ }
1710
+
1711
+ case 'export-selection': {
1712
+ const { format, scale, padding } = args as {
1713
+ format?: 'PNG' | 'JPG' | 'SVG' | 'PDF'
1714
+ scale?: number
1715
+ padding?: number
1716
+ }
1717
+ const selection = figma.currentPage.selection
1718
+ if (selection.length === 0) throw new Error('No selection')
1719
+
1720
+ // If single node, export directly
1721
+ if (selection.length === 1 && !padding) {
1722
+ const bytes = await selection[0].exportAsync({
1723
+ format: format || 'PNG',
1724
+ ...(format !== 'SVG' && format !== 'PDF' ? { scale: scale || 2 } : {})
1725
+ } as ExportSettings)
1726
+ return { data: figma.base64Encode(bytes) }
1727
+ }
1728
+
1729
+ // Multiple nodes or padding: create temp frame
1730
+ let minX = Infinity,
1731
+ minY = Infinity,
1732
+ maxX = -Infinity,
1733
+ maxY = -Infinity
1734
+ for (const node of selection) {
1735
+ if ('absoluteBoundingBox' in node && node.absoluteBoundingBox) {
1736
+ const b = node.absoluteBoundingBox
1737
+ minX = Math.min(minX, b.x)
1738
+ minY = Math.min(minY, b.y)
1739
+ maxX = Math.max(maxX, b.x + b.width)
1740
+ maxY = Math.max(maxY, b.y + b.height)
1741
+ }
1742
+ }
1743
+
1744
+ const pad = padding || 0
1745
+ const frame = figma.createFrame()
1746
+ frame.name = '__export_temp__'
1747
+ frame.x = minX - pad
1748
+ frame.y = minY - pad
1749
+ frame.resize(maxX - minX + pad * 2, maxY - minY + pad * 2)
1750
+ frame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }]
1751
+ frame.clipsContent = true
1752
+
1753
+ for (const node of selection) {
1754
+ const clone = node.clone()
1755
+ clone.x = node.x - frame.x
1756
+ clone.y = node.y - frame.y
1757
+ frame.appendChild(clone)
1758
+ }
1759
+
1760
+ const bytes = await frame.exportAsync({
1761
+ format: format || 'PNG',
1762
+ ...(format !== 'SVG' && format !== 'PDF' ? { scale: scale || 2 } : {})
1763
+ } as ExportSettings)
1764
+ frame.remove()
1765
+ return { data: figma.base64Encode(bytes) }
1766
+ }
1767
+
1768
+ // ==================== DELETE ====================
1769
+ case 'delete-node': {
1770
+ const { id } = args as { id: string }
1771
+ const node = await figma.getNodeByIdAsync(id)
1772
+ if (!node || !('remove' in node)) throw new Error('Node not found')
1773
+ node.remove()
1774
+ return { deleted: true }
1775
+ }
1776
+
1777
+ // ==================== BOUNDS ====================
1778
+ case 'node-bounds': {
1779
+ const { id } = args as { id: string }
1780
+ const node = await figma.getNodeByIdAsync(id)
1781
+ if (!node || !('x' in node)) throw new Error('Node not found')
1782
+ const sn = node as SceneNode
1783
+ return {
1784
+ x: Math.round(sn.x * 100) / 100,
1785
+ y: Math.round(sn.y * 100) / 100,
1786
+ width: Math.round(sn.width * 100) / 100,
1787
+ height: Math.round(sn.height * 100) / 100,
1788
+ centerX: Math.round((sn.x + sn.width / 2) * 100) / 100,
1789
+ centerY: Math.round((sn.y + sn.height / 2) * 100) / 100,
1790
+ right: Math.round((sn.x + sn.width) * 100) / 100,
1791
+ bottom: Math.round((sn.y + sn.height) * 100) / 100
1792
+ }
1793
+ }
1794
+
1795
+ // ==================== PATH ====================
1796
+ case 'path-get': {
1797
+ const { id } = args as { id: string }
1798
+ const node = await figma.getNodeByIdAsync(id)
1799
+ if (!node || node.type !== 'VECTOR') throw new Error('Vector node not found')
1800
+ return { paths: node.vectorPaths }
1801
+ }
1802
+
1803
+ case 'path-set': {
1804
+ const {
1805
+ id,
1806
+ path,
1807
+ windingRule = 'NONZERO'
1808
+ } = args as { id: string; path: string; windingRule?: string }
1809
+ const node = await figma.getNodeByIdAsync(id)
1810
+ if (!node || node.type !== 'VECTOR') throw new Error('Vector node not found')
1811
+ node.vectorPaths = [{ windingRule: windingRule as WindingRule, data: path }]
1812
+ return { updated: true }
1813
+ }
1814
+
1815
+ case 'path-move': {
1816
+ const { id, dx = 0, dy = 0 } = args as { id: string; dx?: number; dy?: number }
1817
+ const node = await figma.getNodeByIdAsync(id)
1818
+ if (!node || node.type !== 'VECTOR') throw new Error('Vector node not found')
1819
+
1820
+ const newPaths = node.vectorPaths.map((p) => ({
1821
+ windingRule: p.windingRule,
1822
+ data: svgPathToString(svgpath(p.data).translate(dx, dy).round(2))
1823
+ }))
1824
+ node.vectorPaths = newPaths
1825
+ return { updated: true, paths: newPaths }
1826
+ }
1827
+
1828
+ case 'path-scale': {
1829
+ const { id, factor } = args as { id: string; factor: number }
1830
+ const node = await figma.getNodeByIdAsync(id)
1831
+ if (!node || node.type !== 'VECTOR') throw new Error('Vector node not found')
1832
+
1833
+ const newPaths = node.vectorPaths.map((p) => ({
1834
+ windingRule: p.windingRule,
1835
+ data: svgPathToString(svgpath(p.data).scale(factor).round(2))
1836
+ }))
1837
+ node.vectorPaths = newPaths
1838
+ return { updated: true, paths: newPaths }
1839
+ }
1840
+
1841
+ case 'path-flip': {
1842
+ const { id, axis } = args as { id: string; axis: 'x' | 'y' }
1843
+ const node = await figma.getNodeByIdAsync(id)
1844
+ if (!node || node.type !== 'VECTOR') throw new Error('Vector node not found')
1845
+
1846
+ // Flip using scale with negative value
1847
+ const newPaths = node.vectorPaths.map((p) => ({
1848
+ windingRule: p.windingRule,
1849
+ data:
1850
+ axis === 'x'
1851
+ ? svgPathToString(svgpath(p.data).scale(-1, 1).round(2))
1852
+ : svgPathToString(svgpath(p.data).scale(1, -1).round(2))
1853
+ }))
1854
+ node.vectorPaths = newPaths
1855
+ return { updated: true, paths: newPaths }
1856
+ }
1857
+
1858
+ // ==================== QUERY ====================
1859
+ case 'query': {
1860
+ const { selector, rootId, select, limit } = args as {
1861
+ selector: string
1862
+ rootId?: string
1863
+ select?: string[]
1864
+ limit?: number
1865
+ }
1866
+ const root = rootId ? await figma.getNodeByIdAsync(rootId) : figma.currentPage
1867
+ if (!root) return { error: 'Root node not found' }
1868
+
1869
+ const nodes = queryNodes(selector, root, { limit: limit ?? 1000 })
1870
+ const fields = select || ['id', 'name', 'type']
1871
+
1872
+ return nodes.map((node) => {
1873
+ const result: Record<string, unknown> = {}
1874
+ for (const field of fields) {
1875
+ if (field in node) {
1876
+ result[field] = (node as unknown as Record<string, unknown>)[field]
1877
+ }
1878
+ }
1879
+ return result
1880
+ })
1881
+ }
1882
+
1883
+ // ==================== EVAL ====================
1884
+ case 'eval': {
1885
+ const { code } = args as { code: string }
1886
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
1887
+ // Wrap code to support top-level await
1888
+ const wrappedCode = code.trim().startsWith('return')
1889
+ ? code
1890
+ : `return (async () => { ${code} })()`
1891
+ const fn = new AsyncFunction('figma', wrappedCode)
1892
+ return await fn(figma)
1893
+ }
1894
+
1895
+ // ==================== LAYOUT ====================
1896
+ case 'trigger-layout': {
1897
+ // Fix TEXT nodes and trigger auto-layout recalculation
1898
+ // Multiplayer protocol doesn't auto-size text or trigger layout engine
1899
+ interface PendingInstance {
1900
+ componentSetName: string
1901
+ variantName: string
1902
+ parentGUID: { sessionID: number; localID: number }
1903
+ position: string
1904
+ x: number
1905
+ y: number
1906
+ }
1907
+ interface PendingGridLayout {
1908
+ nodeGUID: { sessionID: number; localID: number }
1909
+ gridTemplateColumns?: string
1910
+ gridTemplateRows?: string
1911
+ }
1912
+ const { nodeId, pendingComponentSetInstances, pendingGridLayouts } = args as {
1913
+ nodeId: string
1914
+ pendingComponentSetInstances?: PendingInstance[]
1915
+ pendingGridLayouts?: PendingGridLayout[]
1916
+ }
1917
+ // Multiplayer nodes may not be immediately visible
1918
+ const root = await retry(() => figma.getNodeByIdAsync(nodeId), 10, 100, 'linear')
1919
+ if (!root) return null
1920
+
1921
+ // Create ComponentSet instances via Plugin API (multiplayer can't link them correctly)
1922
+ if (pendingComponentSetInstances && pendingComponentSetInstances.length > 0) {
1923
+ for (const pending of pendingComponentSetInstances) {
1924
+ // Find the ComponentSet by name
1925
+ const findComponentSet = (node: SceneNode): ComponentSetNode | null => {
1926
+ if (node.type === 'COMPONENT_SET' && node.name === pending.componentSetName) {
1927
+ return node
1928
+ }
1929
+ if ('children' in node) {
1930
+ for (const child of node.children) {
1931
+ const found = findComponentSet(child)
1932
+ if (found) return found
1933
+ }
1934
+ }
1935
+ return null
1936
+ }
1937
+
1938
+ const componentSet = findComponentSet(root as SceneNode)
1939
+ if (!componentSet) continue
1940
+
1941
+ // Find the variant component by name
1942
+ const variantComp = componentSet.children.find(
1943
+ (c) => c.type === 'COMPONENT' && c.name === pending.variantName
1944
+ ) as ComponentNode | undefined
1945
+ if (!variantComp) continue
1946
+
1947
+ // Create instance
1948
+ const instance = variantComp.createInstance()
1949
+ instance.x = pending.x
1950
+ instance.y = pending.y
1951
+
1952
+ // Find parent node
1953
+ const parentId = `${pending.parentGUID.sessionID}:${pending.parentGUID.localID}`
1954
+ const parent = await figma.getNodeByIdAsync(parentId)
1955
+ if (parent && 'appendChild' in parent) {
1956
+ ;(parent as FrameNode).appendChild(instance)
1957
+ }
1958
+ }
1959
+ }
1960
+
1961
+ // Apply pending Grid layouts
1962
+ if (pendingGridLayouts && pendingGridLayouts.length > 0) {
1963
+ for (const grid of pendingGridLayouts) {
1964
+ const nodeId = `${grid.nodeGUID.sessionID}:${grid.nodeGUID.localID}`
1965
+ const node = await figma.getNodeByIdAsync(nodeId)
1966
+ if (!node || node.type !== 'FRAME') continue
1967
+
1968
+ const frame = node as FrameNode
1969
+ if (frame.layoutMode !== 'GRID') {
1970
+ frame.layoutMode = 'GRID'
1971
+ }
1972
+
1973
+ // Parse grid template syntax: "100px 1fr auto" → [{type, value}, ...]
1974
+ const parseGridTemplate = (
1975
+ template: string
1976
+ ): Array<{ type: 'FIXED' | 'FLEX' | 'HUG'; value: number }> => {
1977
+ return template
1978
+ .split(/\s+/)
1979
+ .filter(Boolean)
1980
+ .map((part) => {
1981
+ if (part.endsWith('px')) {
1982
+ return { type: 'FIXED' as const, value: parseFloat(part) }
1983
+ } else if (part.endsWith('fr')) {
1984
+ return { type: 'FLEX' as const, value: parseFloat(part) || 1 }
1985
+ } else if (part === 'auto' || part === 'hug') {
1986
+ return { type: 'HUG' as const, value: 1 }
1987
+ } else {
1988
+ return { type: 'FIXED' as const, value: parseFloat(part) || 100 }
1989
+ }
1990
+ })
1991
+ }
1992
+
1993
+ // Parse templates first to get counts
1994
+ const colSizes = grid.gridTemplateColumns
1995
+ ? parseGridTemplate(grid.gridTemplateColumns)
1996
+ : null
1997
+ const rowSizes = grid.gridTemplateRows ? parseGridTemplate(grid.gridTemplateRows) : null
1998
+
1999
+ // Set counts first (Figma requires this before setting sizes)
2000
+ if (colSizes && colSizes.length > 0) {
2001
+ frame.gridColumnCount = colSizes.length
2002
+ }
2003
+ if (rowSizes && rowSizes.length > 0) {
2004
+ frame.gridRowCount = rowSizes.length
2005
+ }
2006
+
2007
+ // Now set sizes
2008
+ if (colSizes && colSizes.length > 0) {
2009
+ frame.gridColumnSizes = colSizes
2010
+ }
2011
+ if (rowSizes && rowSizes.length > 0) {
2012
+ frame.gridRowSizes = rowSizes
2013
+ }
2014
+ }
2015
+ }
2016
+
2017
+ // First pass: fix children (bottom-up for correct sizing)
2018
+ const fixRecursive = async (node: SceneNode) => {
2019
+ // Process children first (bottom-up)
2020
+ if ('children' in node) {
2021
+ for (const child of node.children) {
2022
+ await fixRecursive(child)
2023
+ }
2024
+ }
2025
+
2026
+ // Fix TEXT nodes: reload characters to trigger auto-resize
2027
+ if (node.type === 'TEXT' && node.textAutoResize !== 'NONE') {
2028
+ try {
2029
+ await figma.loadFontAsync(node.fontName as FontName)
2030
+ const chars = node.characters
2031
+ node.characters = ''
2032
+ node.characters = chars
2033
+ } catch {
2034
+ // Font not available, skip
2035
+ }
2036
+ }
2037
+
2038
+ // Fix auto-layout frames
2039
+ if ('layoutMode' in node && node.layoutMode !== 'NONE') {
2040
+ const frame = node as FrameNode
2041
+ const needsPrimaryRecalc = frame.primaryAxisSizingMode === 'AUTO'
2042
+ const needsCounterRecalc = frame.counterAxisSizingMode === 'AUTO'
2043
+ if (needsPrimaryRecalc || needsCounterRecalc) {
2044
+ // Re-apply AUTO sizing to trigger recalculation
2045
+ // Temporarily set to FIXED, then back to AUTO forces Figma to recalc
2046
+ if (needsPrimaryRecalc) {
2047
+ frame.primaryAxisSizingMode = 'FIXED'
2048
+ frame.primaryAxisSizingMode = 'AUTO'
2049
+ }
2050
+ if (needsCounterRecalc) {
2051
+ frame.counterAxisSizingMode = 'FIXED'
2052
+ frame.counterAxisSizingMode = 'AUTO'
2053
+ }
2054
+ }
2055
+ }
2056
+ }
2057
+
2058
+ await fixRecursive(root as SceneNode)
2059
+ return { triggered: true }
2060
+ }
2061
+
2062
+ // ==================== VARIABLES ====================
2063
+ case 'get-variables': {
2064
+ const { type, simple } = args as { type?: string; simple?: boolean }
2065
+ const variables = await figma.variables.getLocalVariablesAsync(
2066
+ type as VariableResolvedDataType | undefined
2067
+ )
2068
+ // Simple mode returns only id and name (for variable registry)
2069
+ if (simple) {
2070
+ return variables.map((v) => ({ id: v.id, name: v.name }))
2071
+ }
2072
+ return variables.map((v) => serializeVariable(v))
2073
+ }
2074
+
2075
+ case 'get-variable': {
2076
+ const { id } = args as { id: string }
2077
+ const variable = await figma.variables.getVariableByIdAsync(id)
2078
+ if (!variable) throw new Error('Variable not found')
2079
+ return serializeVariable(variable)
2080
+ }
2081
+
2082
+ case 'create-variable': {
2083
+ const { name, collectionId, type, value } = args as {
2084
+ name: string
2085
+ collectionId: string
2086
+ type: string
2087
+ value?: string
2088
+ }
2089
+ const collection = await figma.variables.getVariableCollectionByIdAsync(collectionId)
2090
+ if (!collection) throw new Error('Collection not found')
2091
+ const variable = figma.variables.createVariable(
2092
+ name,
2093
+ collection,
2094
+ type as VariableResolvedDataType
2095
+ )
2096
+ if (value !== undefined && collection.modes.length > 0) {
2097
+ const modeId = collection.modes[0].modeId
2098
+ variable.setValueForMode(modeId, parseVariableValue(value, type))
2099
+ }
2100
+ return serializeVariable(variable)
2101
+ }
2102
+
2103
+ case 'set-variable-value': {
2104
+ const { id, modeId, value } = args as { id: string; modeId: string; value: string }
2105
+ const variable = await figma.variables.getVariableByIdAsync(id)
2106
+ if (!variable) throw new Error('Variable not found')
2107
+ variable.setValueForMode(modeId, parseVariableValue(value, variable.resolvedType))
2108
+ return serializeVariable(variable)
2109
+ }
2110
+
2111
+ case 'delete-variable': {
2112
+ const { id } = args as { id: string }
2113
+ const variable = await figma.variables.getVariableByIdAsync(id)
2114
+ if (!variable) throw new Error('Variable not found')
2115
+ variable.remove()
2116
+ return { deleted: true }
2117
+ }
2118
+
2119
+ case 'bind-variable': {
2120
+ const { nodeId, field, variableId } = args as {
2121
+ nodeId: string
2122
+ field: string
2123
+ variableId: string
2124
+ }
2125
+ const node = (await figma.getNodeByIdAsync(nodeId)) as SceneNode | null
2126
+ if (!node) throw new Error('Node not found')
2127
+ const variable = await figma.variables.getVariableByIdAsync(variableId)
2128
+ if (!variable) throw new Error('Variable not found')
2129
+ if ('setBoundVariable' in node) {
2130
+ ;(node as any).setBoundVariable(field, variable)
2131
+ } else {
2132
+ throw new Error('Node does not support variable binding')
2133
+ }
2134
+ return serializeNode(node)
2135
+ }
2136
+
2137
+ case 'bind-fill-variable': {
2138
+ const {
2139
+ nodeId,
2140
+ variableId,
2141
+ paintIndex = 0
2142
+ } = args as { nodeId: string; variableId: string; paintIndex?: number }
2143
+ const node = (await figma.getNodeByIdAsync(nodeId)) as SceneNode | null
2144
+ if (!node) throw new Error('Node not found')
2145
+ if (!('fills' in node)) throw new Error('Node does not have fills')
2146
+ const variable = await figma.variables.getVariableByIdAsync(variableId)
2147
+ if (!variable) throw new Error('Variable not found')
2148
+ const fills = (node as GeometryMixin).fills as Paint[]
2149
+ if (!fills[paintIndex]) throw new Error('Paint not found at index ' + paintIndex)
2150
+ const newFill = figma.variables.setBoundVariableForPaint(fills[paintIndex], 'color', variable)
2151
+ ;(node as GeometryMixin).fills = [
2152
+ ...fills.slice(0, paintIndex),
2153
+ newFill,
2154
+ ...fills.slice(paintIndex + 1)
2155
+ ]
2156
+ return serializeNode(node)
2157
+ }
2158
+
2159
+ case 'bind-stroke-variable': {
2160
+ const {
2161
+ nodeId,
2162
+ variableId,
2163
+ paintIndex = 0
2164
+ } = args as { nodeId: string; variableId: string; paintIndex?: number }
2165
+ const node = (await figma.getNodeByIdAsync(nodeId)) as SceneNode | null
2166
+ if (!node) throw new Error('Node not found')
2167
+ if (!('strokes' in node)) throw new Error('Node does not have strokes')
2168
+ const variable = await figma.variables.getVariableByIdAsync(variableId)
2169
+ if (!variable) throw new Error('Variable not found')
2170
+ const strokes = (node as GeometryMixin).strokes as Paint[]
2171
+ if (!strokes[paintIndex]) throw new Error('Paint not found at index ' + paintIndex)
2172
+ const newStroke = figma.variables.setBoundVariableForPaint(
2173
+ strokes[paintIndex],
2174
+ 'color',
2175
+ variable
2176
+ )
2177
+ ;(node as GeometryMixin).strokes = [
2178
+ ...strokes.slice(0, paintIndex),
2179
+ newStroke,
2180
+ ...strokes.slice(paintIndex + 1)
2181
+ ]
2182
+ return serializeNode(node)
2183
+ }
2184
+
2185
+ // ==================== VARIABLE COLLECTIONS ====================
2186
+ case 'get-variable-collections': {
2187
+ const collections = await figma.variables.getLocalVariableCollectionsAsync()
2188
+ return collections.map((c) => serializeCollection(c))
2189
+ }
2190
+
2191
+ case 'get-variable-collection': {
2192
+ const { id } = args as { id: string }
2193
+ const collection = await figma.variables.getVariableCollectionByIdAsync(id)
2194
+ if (!collection) throw new Error('Collection not found')
2195
+ return serializeCollection(collection)
2196
+ }
2197
+
2198
+ case 'create-variable-collection': {
2199
+ const { name } = args as { name: string }
2200
+ const collection = figma.variables.createVariableCollection(name)
2201
+ return serializeCollection(collection)
2202
+ }
2203
+
2204
+ case 'delete-variable-collection': {
2205
+ const { id } = args as { id: string }
2206
+ const collection = await figma.variables.getVariableCollectionByIdAsync(id)
2207
+ if (!collection) throw new Error('Collection not found')
2208
+ collection.remove()
2209
+ return { deleted: true }
2210
+ }
2211
+
2212
+ // ==================== CONNECTORS ====================
2213
+ case 'create-connector': {
2214
+ const {
2215
+ fromId,
2216
+ toId,
2217
+ fromMagnet,
2218
+ toMagnet,
2219
+ lineType,
2220
+ startCap,
2221
+ endCap,
2222
+ stroke,
2223
+ strokeWeight,
2224
+ cornerRadius
2225
+ } = args as {
2226
+ fromId: string
2227
+ toId: string
2228
+ fromMagnet?: string
2229
+ toMagnet?: string
2230
+ lineType?: 'STRAIGHT' | 'ELBOWED' | 'CURVED'
2231
+ startCap?: string
2232
+ endCap?: string
2233
+ stroke?: string
2234
+ strokeWeight?: number
2235
+ cornerRadius?: number
2236
+ }
2237
+
2238
+ const fromNode = await figma.getNodeByIdAsync(fromId)
2239
+ const toNode = await figma.getNodeByIdAsync(toId)
2240
+ if (!fromNode || !('absoluteBoundingBox' in fromNode))
2241
+ throw new Error('From node not found or invalid')
2242
+ if (!toNode || !('absoluteBoundingBox' in toNode))
2243
+ throw new Error('To node not found or invalid')
2244
+
2245
+ const connector = figma.createConnector()
2246
+ connector.connectorStart = {
2247
+ endpointNodeId: fromId,
2248
+ magnet: (fromMagnet as ConnectorMagnet) || 'AUTO'
2249
+ }
2250
+ connector.connectorEnd = {
2251
+ endpointNodeId: toId,
2252
+ magnet: (toMagnet as ConnectorMagnet) || 'AUTO'
2253
+ }
2254
+
2255
+ if (lineType) connector.connectorLineType = lineType
2256
+ if (startCap) connector.connectorStartStrokeCap = startCap as ConnectorStrokeCap
2257
+ if (endCap) connector.connectorEndStrokeCap = endCap as ConnectorStrokeCap
2258
+ if (cornerRadius !== undefined) connector.cornerRadius = cornerRadius
2259
+ if (strokeWeight !== undefined) connector.strokeWeight = strokeWeight
2260
+ if (stroke) {
2261
+ const hex = stroke.replace('#', '')
2262
+ const r = parseInt(hex.slice(0, 2), 16) / 255
2263
+ const g = parseInt(hex.slice(2, 4), 16) / 255
2264
+ const b = parseInt(hex.slice(4, 6), 16) / 255
2265
+ connector.strokes = [{ type: 'SOLID', color: { r, g, b } }]
2266
+ }
2267
+
2268
+ return serializeNode(connector)
2269
+ }
2270
+
2271
+ case 'get-connector': {
2272
+ const { id } = args as { id: string }
2273
+ const node = await figma.getNodeByIdAsync(id)
2274
+ if (!node || node.type !== 'CONNECTOR') throw new Error('Connector not found')
2275
+
2276
+ const connector = node as ConnectorNode
2277
+ const fromNode = await figma.getNodeByIdAsync(connector.connectorStart.endpointNodeId)
2278
+ const toNode = await figma.getNodeByIdAsync(connector.connectorEnd.endpointNodeId)
2279
+
2280
+ const stroke = connector.strokes[0]
2281
+ const strokeHex =
2282
+ stroke && stroke.type === 'SOLID'
2283
+ ? '#' +
2284
+ [stroke.color.r, stroke.color.g, stroke.color.b]
2285
+ .map((c) =>
2286
+ Math.round(c * 255)
2287
+ .toString(16)
2288
+ .padStart(2, '0')
2289
+ )
2290
+ .join('')
2291
+ .toUpperCase()
2292
+ : undefined
2293
+
2294
+ return {
2295
+ id: connector.id,
2296
+ name: connector.name,
2297
+ fromNode: {
2298
+ id: connector.connectorStart.endpointNodeId,
2299
+ name: fromNode?.name || 'Unknown',
2300
+ magnet: connector.connectorStart.magnet
2301
+ },
2302
+ toNode: {
2303
+ id: connector.connectorEnd.endpointNodeId,
2304
+ name: toNode?.name || 'Unknown',
2305
+ magnet: connector.connectorEnd.magnet
2306
+ },
2307
+ lineType: connector.connectorLineType,
2308
+ startCap: connector.connectorStartStrokeCap,
2309
+ endCap: connector.connectorEndStrokeCap,
2310
+ stroke: strokeHex,
2311
+ strokeWeight: connector.strokeWeight,
2312
+ cornerRadius: connector.cornerRadius
2313
+ }
2314
+ }
2315
+
2316
+ case 'set-connector': {
2317
+ const {
2318
+ id,
2319
+ fromId,
2320
+ toId,
2321
+ fromMagnet,
2322
+ toMagnet,
2323
+ lineType,
2324
+ startCap,
2325
+ endCap,
2326
+ stroke,
2327
+ strokeWeight,
2328
+ cornerRadius
2329
+ } = args as {
2330
+ id: string
2331
+ fromId?: string
2332
+ toId?: string
2333
+ fromMagnet?: string
2334
+ toMagnet?: string
2335
+ lineType?: 'STRAIGHT' | 'ELBOWED' | 'CURVED'
2336
+ startCap?: string
2337
+ endCap?: string
2338
+ stroke?: string
2339
+ strokeWeight?: number
2340
+ cornerRadius?: number
2341
+ }
2342
+
2343
+ const node = await figma.getNodeByIdAsync(id)
2344
+ if (!node || node.type !== 'CONNECTOR') throw new Error('Connector not found')
2345
+
2346
+ const connector = node as ConnectorNode
2347
+
2348
+ if (fromId) {
2349
+ connector.connectorStart = {
2350
+ endpointNodeId: fromId,
2351
+ magnet: (fromMagnet as ConnectorMagnet) || connector.connectorStart.magnet
2352
+ }
2353
+ } else if (fromMagnet) {
2354
+ connector.connectorStart = {
2355
+ ...connector.connectorStart,
2356
+ magnet: fromMagnet as ConnectorMagnet
2357
+ }
2358
+ }
2359
+
2360
+ if (toId) {
2361
+ connector.connectorEnd = {
2362
+ endpointNodeId: toId,
2363
+ magnet: (toMagnet as ConnectorMagnet) || connector.connectorEnd.magnet
2364
+ }
2365
+ } else if (toMagnet) {
2366
+ connector.connectorEnd = {
2367
+ ...connector.connectorEnd,
2368
+ magnet: toMagnet as ConnectorMagnet
2369
+ }
2370
+ }
2371
+
2372
+ if (lineType) connector.connectorLineType = lineType
2373
+ if (startCap) connector.connectorStartStrokeCap = startCap as ConnectorStrokeCap
2374
+ if (endCap) connector.connectorEndStrokeCap = endCap as ConnectorStrokeCap
2375
+ if (cornerRadius !== undefined) connector.cornerRadius = cornerRadius
2376
+ if (strokeWeight !== undefined) connector.strokeWeight = strokeWeight
2377
+ if (stroke) {
2378
+ const hex = stroke.replace('#', '')
2379
+ const r = parseInt(hex.slice(0, 2), 16) / 255
2380
+ const g = parseInt(hex.slice(2, 4), 16) / 255
2381
+ const b = parseInt(hex.slice(4, 6), 16) / 255
2382
+ connector.strokes = [{ type: 'SOLID', color: { r, g, b } }]
2383
+ }
2384
+
2385
+ return serializeNode(connector)
2386
+ }
2387
+
2388
+ case 'list-connectors': {
2389
+ const { fromId, toId } = args as { fromId?: string; toId?: string }
2390
+ const page = figma.currentPage
2391
+ const connectors: ConnectorNode[] = []
2392
+
2393
+ function findConnectors(node: BaseNode) {
2394
+ if (node.type === 'CONNECTOR') {
2395
+ const c = node as ConnectorNode
2396
+ if (fromId && c.connectorStart.endpointNodeId !== fromId) return
2397
+ if (toId && c.connectorEnd.endpointNodeId !== toId) return
2398
+ connectors.push(c)
2399
+ }
2400
+ if ('children' in node) {
2401
+ for (const child of (node as ChildrenMixin).children) {
2402
+ findConnectors(child)
2403
+ }
2404
+ }
2405
+ }
2406
+
2407
+ findConnectors(page)
2408
+
2409
+ const results = await Promise.all(
2410
+ connectors.map(async (c) => {
2411
+ const fromNode = await figma.getNodeByIdAsync(c.connectorStart.endpointNodeId)
2412
+ const toNode = await figma.getNodeByIdAsync(c.connectorEnd.endpointNodeId)
2413
+ const stroke = c.strokes[0]
2414
+ const strokeHex =
2415
+ stroke && stroke.type === 'SOLID'
2416
+ ? '#' +
2417
+ [stroke.color.r, stroke.color.g, stroke.color.b]
2418
+ .map((v) =>
2419
+ Math.round(v * 255)
2420
+ .toString(16)
2421
+ .padStart(2, '0')
2422
+ )
2423
+ .join('')
2424
+ .toUpperCase()
2425
+ : undefined
2426
+
2427
+ return {
2428
+ id: c.id,
2429
+ name: c.name,
2430
+ fromNode: {
2431
+ id: c.connectorStart.endpointNodeId,
2432
+ name: fromNode?.name || 'Unknown',
2433
+ magnet: c.connectorStart.magnet
2434
+ },
2435
+ toNode: {
2436
+ id: c.connectorEnd.endpointNodeId,
2437
+ name: toNode?.name || 'Unknown',
2438
+ magnet: c.connectorEnd.magnet
2439
+ },
2440
+ lineType: c.connectorLineType,
2441
+ stroke: strokeHex
2442
+ }
2443
+ })
2444
+ )
2445
+
2446
+ return results
2447
+ }
2448
+
2449
+ default:
2450
+ throw new Error(`Unknown command: ${command}`)
2451
+ }
2452
+ }
2453
+
2454
+ async function appendToParent(node: SceneNode, parentId?: string, insertIndex?: number) {
2455
+ if (parentId) {
2456
+ const parent = await retry(
2457
+ () => figma.getNodeByIdAsync(parentId) as Promise<(FrameNode & ChildrenMixin) | null>,
2458
+ 10,
2459
+ 50
2460
+ )
2461
+ if (parent && 'appendChild' in parent) {
2462
+ if (insertIndex !== undefined && 'insertChild' in parent) {
2463
+ parent.insertChild(insertIndex, node)
2464
+ } else {
2465
+ parent.appendChild(node)
2466
+ }
2467
+ return
2468
+ }
2469
+ console.warn(`Parent ${parentId} not found after retries, appending to page`)
2470
+ }
2471
+ figma.currentPage.appendChild(node)
2472
+ }
2473
+
2474
+ function serializeNode(node: BaseNode): object {
2475
+ const base: Record<string, unknown> = {
2476
+ id: node.id,
2477
+ name: node.name,
2478
+ type: node.type
2479
+ }
2480
+ if (node.parent && node.parent.type !== 'PAGE') {
2481
+ base.parentId = node.parent.id
2482
+ }
2483
+ if ('x' in node) base.x = Math.round(node.x)
2484
+ if ('y' in node) base.y = Math.round(node.y)
2485
+ if ('width' in node) base.width = Math.round(node.width)
2486
+ if ('height' in node) base.height = Math.round(node.height)
2487
+
2488
+ // Only include non-default values
2489
+ if ('opacity' in node && node.opacity !== 1) base.opacity = node.opacity
2490
+ if ('visible' in node && !node.visible) base.visible = false
2491
+ if ('locked' in node && node.locked) base.locked = true
2492
+
2493
+ // Serialize fills/strokes compactly
2494
+ if ('fills' in node && Array.isArray(node.fills) && node.fills.length > 0) {
2495
+ base.fills = node.fills.map(serializePaint)
2496
+ }
2497
+ if ('strokes' in node && Array.isArray(node.strokes) && node.strokes.length > 0) {
2498
+ base.strokes = node.strokes.map(serializePaint)
2499
+ }
2500
+ if ('strokeWeight' in node && typeof node.strokeWeight === 'number' && node.strokeWeight > 0) {
2501
+ base.strokeWeight = node.strokeWeight
2502
+ }
2503
+ if ('cornerRadius' in node && typeof node.cornerRadius === 'number' && node.cornerRadius > 0) {
2504
+ base.cornerRadius = node.cornerRadius
2505
+ }
2506
+
2507
+ if ('componentPropertyDefinitions' in node) {
2508
+ try {
2509
+ base.componentPropertyDefinitions = node.componentPropertyDefinitions
2510
+ } catch {
2511
+ // Variant components throw when accessing componentPropertyDefinitions
2512
+ }
2513
+ }
2514
+ if ('componentProperties' in node) {
2515
+ base.componentProperties = node.componentProperties
2516
+ }
2517
+
2518
+ // Layout properties for frames
2519
+ if ('layoutMode' in node && node.layoutMode !== 'NONE') {
2520
+ base.layoutMode = node.layoutMode
2521
+ if ('itemSpacing' in node) base.itemSpacing = node.itemSpacing
2522
+ if (
2523
+ 'paddingLeft' in node &&
2524
+ (node.paddingLeft || node.paddingRight || node.paddingTop || node.paddingBottom)
2525
+ ) {
2526
+ base.padding = {
2527
+ left: node.paddingLeft,
2528
+ right: node.paddingRight,
2529
+ top: node.paddingTop,
2530
+ bottom: node.paddingBottom
2531
+ }
2532
+ }
2533
+ // Grid layout properties
2534
+ if (node.layoutMode === 'GRID') {
2535
+ const gridNode = node as FrameNode
2536
+ if (gridNode.gridColumnGap !== undefined) base.gridColumnGap = gridNode.gridColumnGap
2537
+ if (gridNode.gridRowGap !== undefined) base.gridRowGap = gridNode.gridRowGap
2538
+ if (gridNode.gridColumnCount !== undefined) base.gridColumnCount = gridNode.gridColumnCount
2539
+ if (gridNode.gridRowCount !== undefined) base.gridRowCount = gridNode.gridRowCount
2540
+ if (gridNode.gridColumnSizes?.length > 0) base.gridColumnSizes = gridNode.gridColumnSizes
2541
+ if (gridNode.gridRowSizes?.length > 0) base.gridRowSizes = gridNode.gridRowSizes
2542
+ }
2543
+ }
2544
+
2545
+ // Text properties
2546
+ if (node.type === 'TEXT') {
2547
+ const textNode = node as TextNode
2548
+ base.characters = textNode.characters
2549
+ if (typeof textNode.fontSize === 'number') base.fontSize = textNode.fontSize
2550
+ if (typeof textNode.fontName === 'object') {
2551
+ base.fontFamily = textNode.fontName.family
2552
+ base.fontStyle = textNode.fontName.style
2553
+ }
2554
+ }
2555
+
2556
+ // Children count for containers
2557
+ if ('children' in node) {
2558
+ base.childCount = (node as ChildrenMixin).children.length
2559
+ }
2560
+
2561
+ return base
2562
+ }
2563
+
2564
+ function serializePaint(paint: Paint): object {
2565
+ if (paint.type === 'SOLID') {
2566
+ const result: Record<string, unknown> = {
2567
+ type: 'SOLID',
2568
+ color: rgbToHex(paint.color)
2569
+ }
2570
+ if (paint.opacity !== undefined && paint.opacity !== 1) result.opacity = paint.opacity
2571
+ return result
2572
+ }
2573
+ if (paint.type === 'IMAGE') {
2574
+ return { type: 'IMAGE', imageHash: paint.imageHash, scaleMode: paint.scaleMode }
2575
+ }
2576
+ if (
2577
+ paint.type === 'GRADIENT_LINEAR' ||
2578
+ paint.type === 'GRADIENT_RADIAL' ||
2579
+ paint.type === 'GRADIENT_ANGULAR' ||
2580
+ paint.type === 'GRADIENT_DIAMOND'
2581
+ ) {
2582
+ return {
2583
+ type: paint.type,
2584
+ stops: paint.gradientStops.map((s) => ({ color: rgbToHex(s.color), position: s.position }))
2585
+ }
2586
+ }
2587
+ return { type: paint.type }
2588
+ }
2589
+
2590
+ function rgbToHex(color: RGB): string {
2591
+ const r = Math.round(color.r * 255)
2592
+ .toString(16)
2593
+ .padStart(2, '0')
2594
+ const g = Math.round(color.g * 255)
2595
+ .toString(16)
2596
+ .padStart(2, '0')
2597
+ const b = Math.round(color.b * 255)
2598
+ .toString(16)
2599
+ .padStart(2, '0')
2600
+ return `#${r}${g}${b}`.toUpperCase()
2601
+ }
2602
+
2603
+ function expandHex(hex: string): string {
2604
+ const clean = hex.replace('#', '')
2605
+ if (clean.length === 3) {
2606
+ return clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2]
2607
+ }
2608
+ if (clean.length === 4) {
2609
+ return clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2] + clean[3] + clean[3]
2610
+ }
2611
+ return clean
2612
+ }
2613
+
2614
+ function hexToRgb(hex: string): RGB {
2615
+ const clean = expandHex(hex)
2616
+ return {
2617
+ r: parseInt(clean.slice(0, 2), 16) / 255,
2618
+ g: parseInt(clean.slice(2, 4), 16) / 255,
2619
+ b: parseInt(clean.slice(4, 6), 16) / 255
2620
+ }
2621
+ }
2622
+
2623
+ /**
2624
+ * Parse color string - supports hex and variable references (var:Name or $Name)
2625
+ */
2626
+ function parsestring(color: string): { hex?: string; variable?: string } {
2627
+ const varMatch = color.match(/^(?:var:|[$])(.+)$/)
2628
+ if (varMatch) {
2629
+ return { variable: varMatch[1] }
2630
+ }
2631
+ return { hex: color }
2632
+ }
2633
+
2634
+ /**
2635
+ * Get hex color from color string (for sync operations, ignores variables)
2636
+ */
2637
+ function getHexColor(color: string): string {
2638
+ const parsed = parsestring(color)
2639
+ return parsed.hex || '#000000'
2640
+ }
2641
+
2642
+ /**
2643
+ * Create a solid paint from color string (hex or var:Name/$Name)
2644
+ */
2645
+ async function createSolidPaint(color: string): Promise<SolidPaint> {
2646
+ const parsed = parsestring(color)
2647
+
2648
+ if (parsed.hex) {
2649
+ return { type: 'SOLID', color: hexToRgb(parsed.hex) }
2650
+ }
2651
+
2652
+ // Variable reference
2653
+ const variables = await figma.variables.getLocalVariablesAsync('COLOR')
2654
+ const variable = variables.find((v) => v.name === parsed.variable)
2655
+
2656
+ if (!variable) {
2657
+ console.warn(`Variable "${parsed.variable}" not found, using black`)
2658
+ return { type: 'SOLID', color: { r: 0, g: 0, b: 0 } }
2659
+ }
2660
+
2661
+ const paint: SolidPaint = {
2662
+ type: 'SOLID',
2663
+ color: { r: 0, g: 0, b: 0 } // Will be overridden by variable
2664
+ }
2665
+
2666
+ return figma.variables.setBoundVariableForPaint(paint, 'color', variable)
2667
+ }
2668
+
2669
+ function hexToRgba(hex: string): RGBA {
2670
+ const clean = expandHex(hex)
2671
+ const hasAlpha = clean.length === 8
2672
+ return {
2673
+ r: parseInt(clean.slice(0, 2), 16) / 255,
2674
+ g: parseInt(clean.slice(2, 4), 16) / 255,
2675
+ b: parseInt(clean.slice(4, 6), 16) / 255,
2676
+ a: hasAlpha ? parseInt(clean.slice(6, 8), 16) / 255 : 1
2677
+ }
2678
+ }
2679
+
2680
+ function serializeVariable(v: Variable): object {
2681
+ return {
2682
+ id: v.id,
2683
+ name: v.name,
2684
+ type: v.resolvedType,
2685
+ collectionId: v.variableCollectionId,
2686
+ description: v.description || undefined,
2687
+ valuesByMode: Object.fromEntries(
2688
+ Object.entries(v.valuesByMode).map(([modeId, value]) => [
2689
+ modeId,
2690
+ serializeVariableValue(value, v.resolvedType)
2691
+ ])
2692
+ )
2693
+ }
2694
+ }
2695
+
2696
+ function serializeCollection(c: VariableCollection): object {
2697
+ return {
2698
+ id: c.id,
2699
+ name: c.name,
2700
+ modes: c.modes.map((m) => ({ modeId: m.modeId, name: m.name })),
2701
+ variableIds: c.variableIds
2702
+ }
2703
+ }
2704
+
2705
+ function serializeVariableValue(value: VariableValue, type: string): unknown {
2706
+ if (type === 'COLOR' && typeof value === 'object' && 'r' in value) {
2707
+ return rgbToHex(value as RGB)
2708
+ }
2709
+ if (typeof value === 'object' && 'type' in value && (value as any).type === 'VARIABLE_ALIAS') {
2710
+ return { alias: (value as VariableAlias).id }
2711
+ }
2712
+ return value
2713
+ }
2714
+
2715
+ function parseVariableValue(value: string, type: string): VariableValue {
2716
+ switch (type) {
2717
+ case 'COLOR':
2718
+ return hexToRgb(value)
2719
+ case 'FLOAT':
2720
+ return parseFloat(value)
2721
+ case 'BOOLEAN':
2722
+ return value === 'true'
2723
+ case 'STRING':
2724
+ default:
2725
+ return value
2726
+ }
2727
+ }
2728
+
2729
+ // Convert svgpath segments to string with spaces (Figma requires spaces between commands)
2730
+ function svgPathToString(sp: ReturnType<typeof svgpath>): string {
2731
+ return sp.segments.map((seg: (string | number)[]) => seg.join(' ')).join(' ')
2732
+ }
2733
+
2734
+ // Expose RPC for CDP injection
2735
+ declare const window: Window & { __figmaRpc?: typeof rpcHandler }
2736
+
2737
+ async function rpcHandler(command: string, args?: unknown): Promise<unknown> {
2738
+ if (!allPagesLoaded && NEEDS_ALL_PAGES.has(command)) {
2739
+ await figma.loadAllPagesAsync()
2740
+ allPagesLoaded = true
2741
+ }
2742
+ return handleCommand(command, args)
2743
+ }
2744
+
2745
+ if (typeof window !== 'undefined') {
2746
+ window.__figmaRpc = rpcHandler
2747
+ }