conductor-figma 1.0.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,755 @@
1
+ // ═══════════════════════════════════════════
2
+ // CONDUCTOR v3 — Figma Plugin
3
+ // ═══════════════════════════════════════════
4
+
5
+ figma.showUI(__html__, { width: 320, height: 200, themeColors: true })
6
+
7
+ let ws = null
8
+ const PORT = 3055
9
+
10
+ function connect() {
11
+ figma.ui.postMessage({ type: 'connect', port: PORT })
12
+ }
13
+
14
+ figma.ui.onmessage = async (msg) => {
15
+ if (msg.type === 'ws-message') {
16
+ try {
17
+ const { id, command, data } = msg.data
18
+ const result = await executeCommand(command, data)
19
+ figma.ui.postMessage({ type: 'ws-send', data: JSON.stringify({ id, result }) })
20
+ } catch (e) {
21
+ const { id } = msg.data
22
+ figma.ui.postMessage({ type: 'ws-send', data: JSON.stringify({ id, error: e.message }) })
23
+ }
24
+ } else if (msg.type === 'connected') {
25
+ figma.notify('✓ Conductor connected', { timeout: 2000 })
26
+ } else if (msg.type === 'disconnected') {
27
+ figma.notify('Conductor disconnected', { timeout: 2000 })
28
+ }
29
+ }
30
+
31
+ connect()
32
+
33
+ // ─── Helpers ───
34
+ function hexToRGB(hex) {
35
+ hex = hex.replace('#', '')
36
+ const hasAlpha = hex.length === 8
37
+ return {
38
+ r: parseInt(hex.slice(0, 2), 16) / 255,
39
+ g: parseInt(hex.slice(2, 4), 16) / 255,
40
+ b: parseInt(hex.slice(4, 6), 16) / 255,
41
+ a: hasAlpha ? parseInt(hex.slice(6, 8), 16) / 255 : 1,
42
+ }
43
+ }
44
+
45
+ function solidPaint(hex, opacity) {
46
+ const c = hexToRGB(hex)
47
+ return [{ type: 'SOLID', color: { r: c.r, g: c.g, b: c.b }, opacity: opacity !== undefined ? opacity : c.a }]
48
+ }
49
+
50
+ function getNode(id) {
51
+ if (!id) return null
52
+ const resolved = id.startsWith('$') ? resolveRef(id) : id
53
+ return figma.getNodeById(resolved)
54
+ }
55
+
56
+ const refMap = {}
57
+ function storeRef(idx, nodeId) { refMap['$' + idx] = nodeId }
58
+ function resolveRef(ref) {
59
+ const match = ref.match(/^\$(\d+)\.id$/)
60
+ return match ? refMap['$' + match[1]] : ref
61
+ }
62
+
63
+ async function loadFont(family, style) {
64
+ try { await figma.loadFontAsync({ family: family || 'Inter', style: style || 'Regular' }) }
65
+ catch { await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }) }
66
+ }
67
+
68
+ function serializeNode(node, depth) {
69
+ if (!node || depth < 0) return null
70
+ const base = { id: node.id, name: node.name, type: node.type }
71
+ if (node.width !== undefined) { base.width = Math.round(node.width); base.height = Math.round(node.height) }
72
+ if (node.x !== undefined) { base.x = Math.round(node.x); base.y = Math.round(node.y) }
73
+ if ('children' in node && depth > 0) {
74
+ base.children = node.children.map(c => serializeNode(c, depth - 1)).filter(Boolean)
75
+ }
76
+ if ('fills' in node && Array.isArray(node.fills)) {
77
+ base.fills = node.fills.map(f => {
78
+ if (f.type === 'SOLID') return { type: 'SOLID', color: rgbToHex(f.color), opacity: f.opacity }
79
+ return { type: f.type }
80
+ })
81
+ }
82
+ if (node.type === 'TEXT') {
83
+ base.characters = node.characters
84
+ base.fontSize = node.fontSize
85
+ }
86
+ return base
87
+ }
88
+
89
+ function rgbToHex(c) {
90
+ return '#' + [c.r, c.g, c.b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('')
91
+ }
92
+
93
+ function nodeInfo(node) {
94
+ const info = serializeNode(node, 0)
95
+ if ('layoutMode' in node) {
96
+ info.layoutMode = node.layoutMode
97
+ info.paddingTop = node.paddingTop; info.paddingRight = node.paddingRight
98
+ info.paddingBottom = node.paddingBottom; info.paddingLeft = node.paddingLeft
99
+ info.itemSpacing = node.itemSpacing
100
+ info.primaryAxisAlignItems = node.primaryAxisAlignItems
101
+ info.counterAxisAlignItems = node.counterAxisAlignItems
102
+ }
103
+ if ('cornerRadius' in node) info.cornerRadius = node.cornerRadius
104
+ if ('opacity' in node) info.opacity = node.opacity
105
+ if ('effects' in node) info.effects = node.effects
106
+ if ('strokes' in node) info.strokes = node.strokes
107
+ if ('constraints' in node) info.constraints = node.constraints
108
+ if (node.type === 'TEXT') {
109
+ info.fontName = node.fontName
110
+ info.textAlignHorizontal = node.textAlignHorizontal
111
+ info.lineHeight = node.lineHeight
112
+ info.letterSpacing = node.letterSpacing
113
+ }
114
+ return info
115
+ }
116
+
117
+ // ═══ Command Router ═══
118
+ async function executeCommand(cmd, data) {
119
+ switch (cmd) {
120
+ // ─── CREATE ───
121
+ case 'create_frame': {
122
+ const frame = figma.createFrame()
123
+ frame.name = data.name || 'Frame'
124
+ if (data.width) frame.resize(data.width, data.height || data.width)
125
+ frame.layoutMode = data.direction || 'VERTICAL'
126
+ frame.primaryAxisSizingMode = data.primaryAxisSizingMode === 'FILL' ? 'FIXED' : (data.primaryAxisSizingMode || 'HUG')
127
+ frame.counterAxisSizingMode = data.counterAxisSizingMode || 'HUG'
128
+ if (data.paddingTop !== undefined) frame.paddingTop = data.paddingTop
129
+ if (data.paddingRight !== undefined) frame.paddingRight = data.paddingRight
130
+ if (data.paddingBottom !== undefined) frame.paddingBottom = data.paddingBottom
131
+ if (data.paddingLeft !== undefined) frame.paddingLeft = data.paddingLeft
132
+ if (data.gap !== undefined) frame.itemSpacing = data.gap
133
+ if (data.fill) frame.fills = solidPaint(data.fill)
134
+ if (data.cornerRadius) frame.cornerRadius = data.cornerRadius
135
+ if (data.primaryAxisAlignItems) frame.primaryAxisAlignItems = data.primaryAxisAlignItems
136
+ if (data.counterAxisAlignItems) frame.counterAxisAlignItems = data.counterAxisAlignItems
137
+ if (data.x !== undefined) { frame.x = data.x; frame.y = data.y || 0 }
138
+ if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(frame) }
139
+ return { id: frame.id, name: frame.name, type: 'FRAME' }
140
+ }
141
+
142
+ case 'create_text': {
143
+ const fn = data.fontName || { family: data.fontFamily || 'Inter', style: data.fontWeight || 'Regular' }
144
+ await loadFont(fn.family, fn.style)
145
+ const text = figma.createText()
146
+ text.fontName = fn
147
+ text.characters = (data.text || '').replace(/\\n/g, '\n')
148
+ text.fontSize = data.fontSize || 16
149
+ if (data.color) text.fills = solidPaint(data.color)
150
+ if (data.textAlignHorizontal) text.textAlignHorizontal = data.textAlignHorizontal
151
+ if (data.textAlignVertical) text.textAlignVertical = data.textAlignVertical
152
+ if (data.lineHeight) {
153
+ text.lineHeight = data.lineHeight > 5 ? { value: data.lineHeight, unit: 'PIXELS' } : { value: data.lineHeight * 100, unit: 'PERCENT' }
154
+ }
155
+ if (data.letterSpacing) text.letterSpacing = { value: data.letterSpacing, unit: 'PIXELS' }
156
+ if (data.maxWidth) { text.textAutoResize = 'HEIGHT'; text.resize(data.maxWidth, text.height) }
157
+ if (data.textCase) text.textCase = data.textCase
158
+ if (data.textDecoration) text.textDecoration = data.textDecoration
159
+ if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(text) }
160
+ return { id: text.id, name: text.name, type: 'TEXT' }
161
+ }
162
+
163
+ case 'create_rectangle': {
164
+ const rect = figma.createRectangle()
165
+ rect.name = data.name || 'Rectangle'
166
+ rect.resize(data.width, data.height)
167
+ if (data.fill) rect.fills = solidPaint(data.fill)
168
+ if (data.cornerRadius) rect.cornerRadius = data.cornerRadius
169
+ if (data.opacity !== undefined) rect.opacity = data.opacity
170
+ if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(rect) }
171
+ return { id: rect.id, name: rect.name, type: 'RECTANGLE' }
172
+ }
173
+
174
+ case 'create_ellipse': {
175
+ const el = figma.createEllipse()
176
+ el.name = data.name || 'Ellipse'
177
+ el.resize(data.width, data.height)
178
+ if (data.fill) el.fills = solidPaint(data.fill)
179
+ if (data.opacity !== undefined) el.opacity = data.opacity
180
+ if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(el) }
181
+ return { id: el.id, name: el.name, type: 'ELLIPSE' }
182
+ }
183
+
184
+ case 'create_line': {
185
+ const line = figma.createLine()
186
+ line.name = data.name || 'Line'
187
+ line.resize(data.length || 100, 0)
188
+ if (data.direction === 'VERTICAL') line.rotation = 90
189
+ line.strokes = solidPaint(data.strokeColor || '#ffffff', 1)
190
+ line.strokeWeight = data.strokeWeight || 1
191
+ if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(line) }
192
+ return { id: line.id, name: line.name, type: 'LINE' }
193
+ }
194
+
195
+ case 'create_svg_node': {
196
+ const node = figma.createNodeFromSvg(data.svg)
197
+ if (data.name) node.name = data.name
198
+ if (data.width && data.height) node.resize(data.width, data.height)
199
+ else if (data.width) { const scale = data.width / node.width; node.resize(data.width, node.height * scale) }
200
+ if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(node) }
201
+ return { id: node.id, name: node.name, type: node.type }
202
+ }
203
+
204
+ case 'create_icon': {
205
+ const svg = data._svg
206
+ const node = figma.createNodeFromSvg(svg)
207
+ node.name = data.icon || 'Icon'
208
+ node.resize(data._size, data._size)
209
+ if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(node) }
210
+ return { id: node.id, name: node.name, type: node.type }
211
+ }
212
+
213
+ case 'create_component': {
214
+ let comp
215
+ if (data.fromNodeId) {
216
+ const source = getNode(data.fromNodeId)
217
+ if (!source) throw new Error('Node not found: ' + data.fromNodeId)
218
+ comp = figma.createComponentFromNode(source)
219
+ } else {
220
+ comp = figma.createComponent()
221
+ if (data.width && data.height) comp.resize(data.width, data.height)
222
+ }
223
+ comp.name = data.name
224
+ if (data.description) comp.description = data.description
225
+ if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(comp) }
226
+ return { id: comp.id, name: comp.name, type: 'COMPONENT' }
227
+ }
228
+
229
+ case 'create_component_instance': {
230
+ const comp = getNode(data.componentId)
231
+ if (!comp || comp.type !== 'COMPONENT') throw new Error('Not a component: ' + data.componentId)
232
+ const inst = comp.createInstance()
233
+ if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(inst) }
234
+ return { id: inst.id, name: inst.name, type: 'INSTANCE' }
235
+ }
236
+
237
+ // ─── MODIFY ───
238
+ case 'modify_node': {
239
+ const node = getNode(data.nodeId)
240
+ if (!node) throw new Error('Node not found: ' + data.nodeId)
241
+ if (data.x !== undefined) node.x = data.x
242
+ if (data.y !== undefined) node.y = data.y
243
+ if (data.width !== undefined && data.height !== undefined) node.resize(data.width, data.height)
244
+ if (data.name) node.name = data.name
245
+ if (data.visible !== undefined) node.visible = data.visible
246
+ if (data.locked !== undefined) node.locked = data.locked
247
+ if (data.opacity !== undefined) node.opacity = data.opacity
248
+ if (data.rotation !== undefined) node.rotation = data.rotation
249
+ if (data.cornerRadius !== undefined && 'cornerRadius' in node) node.cornerRadius = data.cornerRadius
250
+ if (data.fill && 'fills' in node) node.fills = solidPaint(data.fill)
251
+ return { id: node.id, name: node.name, modified: true }
252
+ }
253
+
254
+ case 'set_fill': {
255
+ const node = getNode(data.nodeId)
256
+ if (!node || !('fills' in node)) throw new Error('Cannot set fill on: ' + data.nodeId)
257
+ if (data._fill) {
258
+ if (data._fill.type === 'SOLID') node.fills = [data._fill]
259
+ else node.fills = [data._fill]
260
+ } else if (data.color) {
261
+ node.fills = solidPaint(data.color, data.opacity)
262
+ }
263
+ return { id: node.id, fill: 'set' }
264
+ }
265
+
266
+ case 'set_stroke': {
267
+ const node = getNode(data.nodeId)
268
+ if (!node || !('strokes' in node)) throw new Error('Cannot set stroke on: ' + data.nodeId)
269
+ node.strokes = solidPaint(data.color || '#ffffff', data.opacity)
270
+ node.strokeWeight = data.weight || 1
271
+ if (data.align) node.strokeAlign = data.align
272
+ if (data.dashPattern) node.dashPattern = data.dashPattern
273
+ return { id: node.id, stroke: 'set' }
274
+ }
275
+
276
+ case 'set_effects': {
277
+ const node = getNode(data.nodeId)
278
+ if (!node || !('effects' in node)) throw new Error('Cannot set effects on: ' + data.nodeId)
279
+ const effects = []
280
+ if (data.shadow) {
281
+ const s = data.shadow
282
+ effects.push({
283
+ type: 'DROP_SHADOW', visible: true,
284
+ color: { ...hexToRGB(s.color || '#00000040'), a: 0.25 },
285
+ offset: { x: s.offsetX || s.offset?.x || 0, y: s.offsetY || s.offset?.y || 4 },
286
+ radius: s.blur || 8, spread: s.spread || 0,
287
+ })
288
+ }
289
+ if (data.blur) effects.push({ type: 'LAYER_BLUR', visible: true, radius: data.blur })
290
+ if (data.backgroundBlur) effects.push({ type: 'BACKGROUND_BLUR', visible: true, radius: data.backgroundBlur })
291
+ node.effects = effects
292
+ return { id: node.id, effects: effects.length }
293
+ }
294
+
295
+ case 'set_auto_layout': {
296
+ const node = getNode(data.nodeId)
297
+ if (!node || node.type !== 'FRAME') throw new Error('Not a frame: ' + data.nodeId)
298
+ if (data.direction) node.layoutMode = data.direction
299
+ if (data.paddingTop !== undefined) node.paddingTop = data.paddingTop
300
+ if (data.paddingRight !== undefined) node.paddingRight = data.paddingRight
301
+ if (data.paddingBottom !== undefined) node.paddingBottom = data.paddingBottom
302
+ if (data.paddingLeft !== undefined) node.paddingLeft = data.paddingLeft
303
+ if (data.gap !== undefined) node.itemSpacing = data.gap
304
+ if (data.primaryAxisAlignItems) node.primaryAxisAlignItems = data.primaryAxisAlignItems
305
+ if (data.counterAxisAlignItems) node.counterAxisAlignItems = data.counterAxisAlignItems
306
+ if (data.primaryAxisSizingMode) node.primaryAxisSizingMode = data.primaryAxisSizingMode === 'FILL' ? 'FIXED' : data.primaryAxisSizingMode
307
+ if (data.counterAxisSizingMode) node.counterAxisSizingMode = data.counterAxisSizingMode === 'FILL' ? 'FIXED' : data.counterAxisSizingMode
308
+ return { id: node.id, layout: 'set' }
309
+ }
310
+
311
+ case 'delete_node': {
312
+ const node = getNode(data.nodeId)
313
+ if (!node) throw new Error('Node not found')
314
+ const name = node.name
315
+ node.remove()
316
+ return { deleted: name }
317
+ }
318
+
319
+ case 'move_to_parent': {
320
+ const node = getNode(data.nodeId)
321
+ const parent = getNode(data.parentId)
322
+ if (!node || !parent) throw new Error('Node or parent not found')
323
+ if ('appendChild' in parent) {
324
+ if (data.index !== undefined) parent.insertChild(data.index, node)
325
+ else parent.appendChild(node)
326
+ }
327
+ return { id: node.id, parent: parent.id }
328
+ }
329
+
330
+ case 'duplicate_node': {
331
+ const node = getNode(data.nodeId)
332
+ if (!node) throw new Error('Node not found')
333
+ const copies = []
334
+ for (let i = 0; i < (data.count || 1); i++) {
335
+ const copy = node.clone()
336
+ if (data.offsetX || data.offsetY) { copy.x = node.x + (data.offsetX || 0) * (i + 1); copy.y = node.y + (data.offsetY || 0) * (i + 1) }
337
+ copies.push({ id: copy.id, name: copy.name })
338
+ }
339
+ return { copies }
340
+ }
341
+
342
+ case 'rename_node': {
343
+ const node = getNode(data.nodeId)
344
+ if (!node) throw new Error('Node not found')
345
+ node.name = data.name
346
+ return { id: node.id, name: data.name }
347
+ }
348
+
349
+ case 'set_visibility': {
350
+ const node = getNode(data.nodeId)
351
+ if (!node) throw new Error('Node not found')
352
+ node.visible = data.visible
353
+ return { id: node.id, visible: data.visible }
354
+ }
355
+
356
+ case 'set_constraints': {
357
+ const node = getNode(data.nodeId)
358
+ if (!node) throw new Error('Node not found')
359
+ node.constraints = {
360
+ horizontal: data.horizontal || node.constraints.horizontal,
361
+ vertical: data.vertical || node.constraints.vertical,
362
+ }
363
+ return { id: node.id, constraints: node.constraints }
364
+ }
365
+
366
+ case 'resize_node': {
367
+ const node = getNode(data.nodeId)
368
+ if (!node) throw new Error('Node not found')
369
+ if (data.scale) { node.resize(node.width * data.scale, node.height * data.scale) }
370
+ else { node.resize(data.width || node.width, data.height || node.height) }
371
+ return { id: node.id, width: Math.round(node.width), height: Math.round(node.height) }
372
+ }
373
+
374
+ case 'set_corner_radius': {
375
+ const node = getNode(data.nodeId)
376
+ if (!node || !('cornerRadius' in node)) throw new Error('Cannot set radius')
377
+ if (data.radius !== undefined) node.cornerRadius = data.radius
378
+ if (data.topLeft !== undefined) { node.topLeftRadius = data.topLeft; node.topRightRadius = data.topRight || 0; node.bottomRightRadius = data.bottomRight || 0; node.bottomLeftRadius = data.bottomLeft || 0 }
379
+ return { id: node.id, cornerRadius: node.cornerRadius }
380
+ }
381
+
382
+ case 'set_opacity': {
383
+ const node = getNode(data.nodeId)
384
+ if (!node) throw new Error('Node not found')
385
+ node.opacity = data.opacity
386
+ return { id: node.id, opacity: data.opacity }
387
+ }
388
+
389
+ case 'set_rotation': {
390
+ const node = getNode(data.nodeId)
391
+ if (!node) throw new Error('Node not found')
392
+ node.rotation = data.angle
393
+ return { id: node.id, rotation: data.angle }
394
+ }
395
+
396
+ case 'set_clip_content': {
397
+ const node = getNode(data.nodeId)
398
+ if (!node) throw new Error('Node not found')
399
+ node.clipsContent = data.clip
400
+ return { id: node.id, clipsContent: data.clip }
401
+ }
402
+
403
+ case 'flatten_node': {
404
+ const node = getNode(data.nodeId)
405
+ if (!node) throw new Error('Node not found')
406
+ const flat = figma.flatten([node])
407
+ return { id: flat.id, name: flat.name }
408
+ }
409
+
410
+ case 'set_layout_sizing': {
411
+ const node = getNode(data.nodeId)
412
+ if (!node) throw new Error('Node not found')
413
+ if (data.horizontal) node.layoutSizingHorizontal = data.horizontal
414
+ if (data.vertical) node.layoutSizingVertical = data.vertical
415
+ return { id: node.id }
416
+ }
417
+
418
+ case 'group_nodes': {
419
+ const nodes = data.nodeIds.map(id => getNode(id)).filter(Boolean)
420
+ if (nodes.length < 2) throw new Error('Need at least 2 nodes to group')
421
+ const group = figma.group(nodes, nodes[0].parent)
422
+ if (data.name) group.name = data.name
423
+ return { id: group.id, name: group.name }
424
+ }
425
+
426
+ case 'ungroup_nodes': {
427
+ const node = getNode(data.nodeId)
428
+ if (!node || node.type !== 'GROUP') throw new Error('Not a group')
429
+ const parent = node.parent
430
+ const children = [...node.children]
431
+ for (const child of children) parent.appendChild(child)
432
+ node.remove()
433
+ return { ungrouped: children.length }
434
+ }
435
+
436
+ // ─── VECTOR ───
437
+ case 'boolean_operation': {
438
+ const nodes = data.nodeIds.map(id => getNode(id)).filter(Boolean)
439
+ if (nodes.length < 2) throw new Error('Need at least 2 nodes')
440
+ const ops = { UNION: 'UNION', SUBTRACT: 'SUBTRACT', INTERSECT: 'INTERSECT', EXCLUDE: 'EXCLUDE' }
441
+ const result = figma.union(nodes, nodes[0].parent) // Note: actual API varies
442
+ return { id: result.id, operation: data.operation }
443
+ }
444
+
445
+ case 'create_divider': {
446
+ const rect = figma.createRectangle()
447
+ rect.name = data.name || 'Divider'
448
+ if (data.direction === 'VERTICAL') rect.resize(data.thickness || 1, data.length || 100)
449
+ else rect.resize(data.length || 100, data.thickness || 1)
450
+ rect.fills = solidPaint(data.color || '#1e1e3a')
451
+ if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(rect) }
452
+ return { id: rect.id, name: rect.name }
453
+ }
454
+
455
+ // ─── READ ───
456
+ case 'get_selection': {
457
+ return figma.currentPage.selection.map(n => nodeInfo(n))
458
+ }
459
+
460
+ case 'get_page_structure': {
461
+ const page = data.pageId ? figma.getNodeById(data.pageId) : figma.currentPage
462
+ return serializeNode(page, data.depth || 3)
463
+ }
464
+
465
+ case 'get_node_info': {
466
+ const node = getNode(data.nodeId)
467
+ if (!node) throw new Error('Node not found')
468
+ return nodeInfo(node)
469
+ }
470
+
471
+ case 'get_nodes_info': {
472
+ return data.nodeIds.map(id => { const n = getNode(id); return n ? nodeInfo(n) : null }).filter(Boolean)
473
+ }
474
+
475
+ case 'find_nodes': {
476
+ const scope = data.withinId ? getNode(data.withinId) : figma.currentPage
477
+ if (!scope) throw new Error('Scope not found')
478
+ let results = scope.findAll ? scope.findAll(n => {
479
+ if (data.type && n.type !== data.type) return false
480
+ if (data.query && !n.name.toLowerCase().includes(data.query.toLowerCase())) return false
481
+ return true
482
+ }) : []
483
+ return results.slice(0, 50).map(n => ({ id: n.id, name: n.name, type: n.type }))
484
+ }
485
+
486
+ case 'get_local_styles': {
487
+ const paint = figma.getLocalPaintStyles().map(s => ({ id: s.id, name: s.name, type: 'PAINT' }))
488
+ const text = figma.getLocalTextStyles().map(s => ({ id: s.id, name: s.name, type: 'TEXT', fontSize: s.fontSize }))
489
+ const effect = figma.getLocalEffectStyles().map(s => ({ id: s.id, name: s.name, type: 'EFFECT' }))
490
+ return { paint, text, effect, total: paint.length + text.length + effect.length }
491
+ }
492
+
493
+ case 'list_components': {
494
+ const page = data.pageId ? figma.getNodeById(data.pageId) : figma.currentPage
495
+ const comps = page.findAll(n => n.type === 'COMPONENT')
496
+ return comps.map(c => ({ id: c.id, name: c.name, description: c.description || '' }))
497
+ }
498
+
499
+ case 'list_pages': {
500
+ return figma.root.children.map(p => ({ id: p.id, name: p.name, childCount: p.children.length }))
501
+ }
502
+
503
+ case 'get_document_info': {
504
+ return { name: figma.root.name, pages: figma.root.children.map(p => ({ id: p.id, name: p.name })) }
505
+ }
506
+
507
+ case 'set_selection': {
508
+ const nodes = data.nodeIds.map(id => getNode(id)).filter(Boolean)
509
+ figma.currentPage.selection = nodes
510
+ if (data.zoomToFit !== false && nodes.length) figma.viewport.scrollAndZoomIntoView(nodes)
511
+ return { selected: nodes.length }
512
+ }
513
+
514
+ case 'set_focus': {
515
+ const node = getNode(data.nodeId)
516
+ if (!node) throw new Error('Node not found')
517
+ figma.currentPage.selection = [node]
518
+ figma.viewport.scrollAndZoomIntoView([node])
519
+ return { focused: node.id }
520
+ }
521
+
522
+ case 'list_available_fonts': {
523
+ const fonts = await figma.listAvailableFontsAsync()
524
+ const families = [...new Set(fonts.map(f => f.fontName.family))].sort()
525
+ return { count: families.length, families: families.slice(0, 100) }
526
+ }
527
+
528
+ case 'get_selection_colors': {
529
+ const colors = new Set()
530
+ for (const node of figma.currentPage.selection) {
531
+ if ('fills' in node && Array.isArray(node.fills)) {
532
+ node.fills.forEach(f => { if (f.type === 'SOLID') colors.add(rgbToHex(f.color)) })
533
+ }
534
+ }
535
+ return [...colors]
536
+ }
537
+
538
+ case 'measure_distance': {
539
+ const n1 = getNode(data.nodeId1), n2 = getNode(data.nodeId2)
540
+ if (!n1 || !n2) throw new Error('Node not found')
541
+ const dx = Math.abs(n1.absoluteTransform[0][2] - n2.absoluteTransform[0][2])
542
+ const dy = Math.abs(n1.absoluteTransform[1][2] - n2.absoluteTransform[1][2])
543
+ return { horizontal: Math.round(dx), vertical: Math.round(dy), diagonal: Math.round(Math.sqrt(dx*dx + dy*dy)) }
544
+ }
545
+
546
+ // ─── EXPORT ───
547
+ case 'export_as_svg': {
548
+ const node = getNode(data.nodeId)
549
+ if (!node) throw new Error('Node not found')
550
+ const svg = await node.exportAsync({ format: 'SVG_STRING' })
551
+ return { svg: typeof svg === 'string' ? svg : new TextDecoder().decode(svg) }
552
+ }
553
+
554
+ case 'export_as_png': {
555
+ const node = getNode(data.nodeId)
556
+ if (!node) throw new Error('Node not found')
557
+ const bytes = await node.exportAsync({ format: 'PNG', constraint: { type: 'SCALE', value: data.scale || 2 } })
558
+ return { size: bytes.byteLength, format: 'PNG', scale: data.scale || 2 }
559
+ }
560
+
561
+ case 'screenshot': {
562
+ if (data.nodeId) {
563
+ const node = getNode(data.nodeId)
564
+ if (!node) throw new Error('Node not found')
565
+ const bytes = await node.exportAsync({ format: 'PNG', constraint: { type: 'SCALE', value: data.scale || 2 } })
566
+ return { size: bytes.byteLength }
567
+ }
568
+ return { message: 'Viewport screenshot not available in plugin API' }
569
+ }
570
+
571
+ // ─── VARIABLES ───
572
+ case 'create_variable_collection': {
573
+ const collection = figma.variables.createVariableCollection(data.name)
574
+ if (data.modes && data.modes.length > 1) {
575
+ for (let i = 1; i < data.modes.length; i++) collection.addMode(data.modes[i])
576
+ collection.renameMode(collection.modes[0].modeId, data.modes[0])
577
+ }
578
+ return { id: collection.id, name: collection.name, modes: collection.modes }
579
+ }
580
+
581
+ case 'create_variable': {
582
+ const coll = figma.variables.getVariableCollectionById(data.collectionId)
583
+ if (!coll) throw new Error('Collection not found')
584
+ const typeMap = { COLOR: 'COLOR', FLOAT: 'FLOAT', STRING: 'STRING', BOOLEAN: 'BOOLEAN' }
585
+ const variable = figma.variables.createVariable(data.name, coll, typeMap[data.type] || 'STRING')
586
+ // Set default value
587
+ if (data.type === 'COLOR') variable.setValueForMode(coll.defaultModeId, hexToRGB(data.value))
588
+ else if (data.type === 'FLOAT') variable.setValueForMode(coll.defaultModeId, parseFloat(data.value))
589
+ else if (data.type === 'BOOLEAN') variable.setValueForMode(coll.defaultModeId, data.value === 'true')
590
+ else variable.setValueForMode(coll.defaultModeId, data.value)
591
+ return { id: variable.id, name: variable.name }
592
+ }
593
+
594
+ case 'get_variables': {
595
+ const collections = figma.variables.getLocalVariableCollections()
596
+ return collections.map(c => ({
597
+ id: c.id, name: c.name, modes: c.modes,
598
+ variables: c.variableIds.map(vid => {
599
+ const v = figma.variables.getVariableById(vid)
600
+ return v ? { id: v.id, name: v.name, type: v.resolvedType } : null
601
+ }).filter(Boolean)
602
+ }))
603
+ }
604
+
605
+ case 'bind_variable': {
606
+ const node = getNode(data.nodeId)
607
+ const variable = figma.variables.getVariableById(data.variableId)
608
+ if (!node || !variable) throw new Error('Node or variable not found')
609
+ node.setBoundVariable(data.property, variable)
610
+ return { bound: true, property: data.property, variable: variable.name }
611
+ }
612
+
613
+ // ─── BATCH ───
614
+ case 'batch_rename': {
615
+ const results = []
616
+ for (let i = 0; i < data.nodeIds.length; i++) {
617
+ const node = getNode(data.nodeIds[i])
618
+ if (!node) continue
619
+ const name = data.pattern.replace('{n}', String((data.startNumber || 1) + i)).replace('{name}', node.name)
620
+ node.name = name
621
+ results.push({ id: node.id, name })
622
+ }
623
+ return { renamed: results.length, results }
624
+ }
625
+
626
+ case 'batch_style': {
627
+ let count = 0
628
+ for (const id of data.nodeIds) {
629
+ const node = getNode(id)
630
+ if (!node) continue
631
+ if (data.changes.fill && 'fills' in node) node.fills = solidPaint(data.changes.fill)
632
+ if (data.changes.opacity !== undefined) node.opacity = data.changes.opacity
633
+ if (data.changes.cornerRadius !== undefined && 'cornerRadius' in node) node.cornerRadius = data.changes.cornerRadius
634
+ count++
635
+ }
636
+ return { styled: count }
637
+ }
638
+
639
+ case 'batch_replace_text': {
640
+ const scope = data.nodeId ? getNode(data.nodeId) : figma.currentPage
641
+ const textNodes = scope.findAll(n => n.type === 'TEXT')
642
+ let count = 0
643
+ for (const node of textNodes) {
644
+ if (node.characters.includes(data.find) || (!data.matchCase && node.characters.toLowerCase().includes(data.find.toLowerCase()))) {
645
+ await loadFont(node.fontName.family, node.fontName.style)
646
+ const regex = new RegExp(data.find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), data.matchCase ? 'g' : 'gi')
647
+ node.characters = node.characters.replace(regex, data.replace)
648
+ count++
649
+ }
650
+ }
651
+ return { replaced: count }
652
+ }
653
+
654
+ case 'batch_replace_color': {
655
+ const scope = data.nodeId ? getNode(data.nodeId) : figma.currentPage
656
+ const findRgb = hexToRGB(data.find)
657
+ let count = 0
658
+ scope.findAll(n => {
659
+ if ('fills' in n && Array.isArray(n.fills)) {
660
+ const newFills = n.fills.map(f => {
661
+ if (f.type === 'SOLID' && Math.abs(f.color.r - findRgb.r) < 0.02 && Math.abs(f.color.g - findRgb.g) < 0.02 && Math.abs(f.color.b - findRgb.b) < 0.02) {
662
+ count++
663
+ return { ...f, color: hexToRGB(data.replace) }
664
+ }
665
+ return f
666
+ })
667
+ n.fills = newFills
668
+ }
669
+ return false
670
+ })
671
+ return { replaced: count }
672
+ }
673
+
674
+ case 'batch_delete': {
675
+ let count = 0
676
+ for (const id of data.nodeIds) {
677
+ const node = getNode(id)
678
+ if (node) { node.remove(); count++ }
679
+ }
680
+ return { deleted: count }
681
+ }
682
+
683
+ case 'batch_set_visibility': {
684
+ for (const id of data.nodeIds) {
685
+ const node = getNode(id)
686
+ if (node) node.visible = data.visible
687
+ }
688
+ return { updated: data.nodeIds.length }
689
+ }
690
+
691
+ case 'select_all_by_type': {
692
+ const scope = data.withinId ? getNode(data.withinId) : figma.currentPage
693
+ const nodes = scope.findAll(n => n.type === data.type)
694
+ figma.currentPage.selection = nodes
695
+ return { selected: nodes.length }
696
+ }
697
+
698
+ case 'clean_hidden_layers': {
699
+ const scope = getNode(data.nodeId)
700
+ if (!scope) throw new Error('Node not found')
701
+ const hidden = scope.findAll(n => !n.visible)
702
+ if (data.dryRun) return { wouldDelete: hidden.length, nodes: hidden.map(n => ({ id: n.id, name: n.name })) }
703
+ hidden.forEach(n => n.remove())
704
+ return { deleted: hidden.length }
705
+ }
706
+
707
+ // ─── DESIGN SYSTEM (handled partially client-side) ───
708
+ case 'scan_design_system': {
709
+ const page = data.pageId ? figma.getNodeById(data.pageId) : figma.currentPage
710
+ const colors = new Set(), fonts = new Set(), radii = new Set(), spacings = new Set()
711
+ page.findAll(n => {
712
+ if ('fills' in n && Array.isArray(n.fills)) n.fills.forEach(f => { if (f.type === 'SOLID') colors.add(rgbToHex(f.color)) })
713
+ if (n.type === 'TEXT') fonts.add(n.fontName.family + ' ' + n.fontName.style)
714
+ if ('cornerRadius' in n && typeof n.cornerRadius === 'number') radii.add(n.cornerRadius)
715
+ if ('itemSpacing' in n) spacings.add(n.itemSpacing)
716
+ if ('paddingTop' in n) { spacings.add(n.paddingTop); spacings.add(n.paddingRight); spacings.add(n.paddingBottom); spacings.add(n.paddingLeft) }
717
+ return false
718
+ })
719
+ return {
720
+ colors: [...colors].sort(),
721
+ fonts: [...fonts].sort(),
722
+ radii: [...radii].sort((a,b) => a-b),
723
+ spacings: [...spacings].sort((a,b) => a-b),
724
+ }
725
+ }
726
+
727
+ case 'detect_inconsistencies': {
728
+ const scope = data.nodeId ? getNode(data.nodeId) : figma.currentPage
729
+ const issues = []
730
+ scope.findAll(n => {
731
+ // Check spacing off grid
732
+ if ('paddingTop' in n) {
733
+ ;['paddingTop','paddingRight','paddingBottom','paddingLeft','itemSpacing'].forEach(prop => {
734
+ if (n[prop] % 4 !== 0 && n[prop] !== 0) issues.push({ node: n.name, id: n.id, issue: `${prop} (${n[prop]}px) not on 4px grid` })
735
+ })
736
+ }
737
+ // Check generic names
738
+ if (/^(Frame|Group|Rectangle|Ellipse|Line)\s*\d*$/.test(n.name)) {
739
+ issues.push({ node: n.name, id: n.id, issue: 'Generic layer name' })
740
+ }
741
+ return false
742
+ })
743
+ return { issues: issues.slice(0, 100), total: issues.length }
744
+ }
745
+
746
+ case 'check_naming': {
747
+ const scope = getNode(data.nodeId) || figma.currentPage
748
+ const bad = scope.findAll(n => /^(Frame|Group|Rectangle|Ellipse|Line|Vector|Text)\s*\d*$/.test(n.name))
749
+ return { genericNames: bad.length, nodes: bad.slice(0, 50).map(n => ({ id: n.id, name: n.name, type: n.type })) }
750
+ }
751
+
752
+ default:
753
+ throw new Error(`Unknown command: ${cmd}. Conductor v3 has 150+ tools — check the docs.`)
754
+ }
755
+ }