figma-console-mcp 1.15.3 → 1.15.4

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.
@@ -3,7 +3,12 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <style>
6
- * { box-sizing: border-box; margin: 0; padding: 0; }
6
+ * {
7
+ box-sizing: border-box;
8
+ margin: 0;
9
+ padding: 0;
10
+ }
11
+
7
12
  body {
8
13
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
9
14
  font-size: 11px;
@@ -16,6 +21,7 @@
16
21
  padding: 6px 8px;
17
22
  user-select: none;
18
23
  }
24
+
19
25
  .bridge-status {
20
26
  display: flex;
21
27
  align-items: center;
@@ -26,234 +32,1205 @@
26
32
  border-radius: 4px;
27
33
  white-space: nowrap;
28
34
  }
35
+
29
36
  .status-indicator {
30
37
  width: 8px;
31
38
  height: 8px;
32
39
  border-radius: 50%;
33
40
  flex-shrink: 0;
41
+ }
42
+
43
+ .status-indicator.loading {
34
44
  background: #f5a623;
35
45
  animation: pulse 1.5s ease-in-out infinite;
36
46
  }
47
+
48
+ .status-indicator.active {
49
+ background: #18a957;
50
+ box-shadow: 0 0 6px rgba(24, 169, 87, 0.6);
51
+ animation: glow 2s ease-in-out infinite;
52
+ }
53
+
54
+ .status-indicator.error {
55
+ background: #f24822;
56
+ }
57
+
37
58
  @keyframes pulse {
38
59
  0%, 100% { opacity: 0.4; }
39
60
  50% { opacity: 1; }
40
61
  }
62
+
63
+ @keyframes glow {
64
+ 0%, 100% { box-shadow: 0 0 4px rgba(24, 169, 87, 0.4); }
65
+ 50% { box-shadow: 0 0 8px rgba(24, 169, 87, 0.8); }
66
+ }
67
+
68
+ .status-text {
69
+ font-weight: 500;
70
+ letter-spacing: 0.2px;
71
+ }
72
+
73
+ .status-text .label {
74
+ color: var(--figma-color-text-secondary, rgba(255, 255, 255, 0.7));
75
+ }
76
+
77
+ .status-text .state {
78
+ color: var(--figma-color-text, rgba(255, 255, 255, 0.9));
79
+ margin-left: 4px;
80
+ }
81
+
82
+ /* Cloud Mode toggle */
83
+ .cloud-section {
84
+ display: none;
85
+ width: 100%;
86
+ }
87
+
88
+ .cloud-section.visible {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 6px;
92
+ margin-top: 6px;
93
+ }
94
+
95
+ .cloud-toggle {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 3px;
99
+ cursor: pointer;
100
+ font-size: 10px;
101
+ color: var(--figma-color-text-secondary, rgba(255, 255, 255, 0.5));
102
+ user-select: none;
103
+ margin-top: 2px;
104
+ }
105
+
106
+ .cloud-toggle:hover {
107
+ color: var(--figma-color-text, rgba(255, 255, 255, 0.9));
108
+ }
109
+
110
+ .cloud-toggle .chevron {
111
+ display: inline-block;
112
+ font-size: 8px;
113
+ transition: transform 0.15s ease;
114
+ }
115
+
116
+ .cloud-toggle.expanded .chevron {
117
+ transform: rotate(90deg);
118
+ }
119
+
120
+ .cloud-input-row input {
121
+ width: 100%;
122
+ background: var(--figma-color-bg, #2c2c2c);
123
+ border: 1px solid var(--figma-color-border, #4a4a4a);
124
+ border-radius: 3px;
125
+ color: var(--figma-color-text, rgba(255, 255, 255, 0.9));
126
+ font-family: monospace;
127
+ font-size: 12px;
128
+ padding: 4px 6px;
129
+ text-transform: uppercase;
130
+ letter-spacing: 3px;
131
+ text-align: center;
132
+ box-sizing: border-box;
133
+ }
134
+
135
+ .cloud-input-row input::placeholder {
136
+ text-transform: none;
137
+ letter-spacing: normal;
138
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
139
+ font-size: 10px;
140
+ color: var(--figma-color-text-secondary, rgba(255, 255, 255, 0.3));
141
+ }
142
+
143
+ .cloud-input-row button {
144
+ width: 100%;
145
+ background: var(--figma-color-bg-brand, #0d99ff);
146
+ color: #fff;
147
+ border: none;
148
+ border-radius: 3px;
149
+ font-size: 10px;
150
+ font-weight: 500;
151
+ padding: 5px 8px;
152
+ cursor: pointer;
153
+ }
154
+
155
+ .cloud-input-row button:disabled {
156
+ opacity: 0.5;
157
+ cursor: default;
158
+ }
159
+
160
+ .cloud-status {
161
+ font-size: 9px;
162
+ color: var(--figma-color-text-secondary, rgba(255, 255, 255, 0.5));
163
+ text-align: center;
164
+ }
165
+
166
+ .cloud-status.connected {
167
+ color: #18a957;
168
+ }
169
+
170
+ .cloud-status.error {
171
+ color: #f24822;
172
+ }
173
+
174
+ /* Light theme support */
41
175
  @media (prefers-color-scheme: light) {
42
- body { background: #f5f5f5; color: #333; }
43
- .bridge-status { background: #fff; border-color: #e5e5e5; }
176
+ body {
177
+ background: #f5f5f5;
178
+ color: #333;
179
+ }
180
+ .bridge-status {
181
+ background: #fff;
182
+ border-color: #e5e5e5;
183
+ }
184
+ .status-text .label {
185
+ color: #666;
186
+ }
187
+ .status-text .state {
188
+ color: #333;
189
+ }
44
190
  }
45
191
  </style>
46
192
  </head>
47
193
  <body>
48
- <div class="bridge-status">
49
- <div class="status-indicator" id="dot"></div>
50
- <div id="msg">MCP loading…</div>
194
+ <div style="display: flex; flex-direction: column; align-items: center; width: 100%;">
195
+ <div class="bridge-status" id="status-container">
196
+ <div class="status-indicator loading" id="status-dot"></div>
197
+ <div class="status-text">
198
+ <span class="label">MCP</span>
199
+ <span class="state" id="status-state">connecting</span>
200
+ </div>
201
+ </div>
202
+
203
+ <div class="cloud-toggle" id="cloud-toggle" onclick="toggleCloudSection()">
204
+ <span class="chevron">▶</span> Cloud Mode
205
+ </div>
206
+
207
+ <div class="cloud-section" id="cloud-section">
208
+ <div class="cloud-input-row">
209
+ <input type="text" id="cloud-code" maxlength="6" placeholder="Pairing code" autocomplete="off" />
210
+ </div>
211
+ <div class="cloud-input-row">
212
+ <button id="cloud-btn" onclick="cloudConnect()">Connect</button>
213
+ </div>
214
+ <div class="cloud-status" id="cloud-status"></div>
215
+ </div>
51
216
  </div>
52
217
 
53
218
  <script>
54
- // =========================================================================
55
- // FIGMA CONSOLE MCP BOOTLOADER (v3)
56
- //
57
- // This is a thin, permanent loader. Figma caches plugin files aggressively,
58
- // so this file is designed to NEVER need updating.
59
- //
60
- // Boot sequence:
61
- // 1. Scan ports 9223-9232 via WebSocket to find the MCP server.
62
- // 2. Request the full UI HTML from the server via WebSocket message.
63
- // 3. Send the HTML to code.js via postMessage.
64
- // 4. code.js calls figma.showUI(freshHtml) — replacing this bootloader
65
- // with the always-up-to-date full plugin UI.
66
- //
67
- // No redirects, no cross-origin, no CSP issues. The HTML is passed as a
68
- // string through the standard figma.showUI() API.
69
- //
70
- // Users import this manifest ONCE. All future updates are served dynamically.
71
- // =========================================================================
72
-
73
- var PORTS = [];
74
- for (var p = 9223; p <= 9232; p++) PORTS.push(p);
75
-
76
- var dot = document.getElementById('dot');
77
- var msg = document.getElementById('msg');
78
-
79
- function setStatus(text) { msg.textContent = text; }
80
- function setError(text) {
81
- dot.style.background = '#f24822';
82
- dot.style.animation = 'none';
83
- msg.textContent = text;
219
+ // ============================================================================
220
+ // GLOBAL STATE - Data storage for Puppeteer/MCP access
221
+ // ============================================================================
222
+ window.__figmaVariablesData = null;
223
+ window.__figmaVariablesReady = false;
224
+ window.__figmaComponentData = null;
225
+ window.__figmaComponentRequests = new Map();
226
+ window.__figmaPendingRequests = new Map();
227
+
228
+ let requestIdCounter = 0;
229
+
230
+ // UI update helper
231
+ function updateStatus(state, isActive, isError) {
232
+ const dot = document.getElementById('status-dot');
233
+ const stateText = document.getElementById('status-state');
234
+
235
+ dot.className = 'status-indicator ' + (isError ? 'error' : (isActive ? 'active' : 'loading'));
236
+ stateText.textContent = state;
84
237
  }
85
238
 
86
- // Minimum server version that supports GET_PLUGIN_UI bootloader protocol
87
- var MIN_SERVER_VERSION = '1.14.0';
239
+ // ============================================================================
240
+ // COMMAND INFRASTRUCTURE - Generic plugin command sender
241
+ // ============================================================================
242
+ window.sendPluginCommand = (type, params, timeoutMs) => {
243
+ timeoutMs = timeoutMs || 15000;
244
+ return new Promise((resolve, reject) => {
245
+ const requestId = type.toLowerCase() + '_' + (++requestIdCounter) + '_' + Date.now();
246
+
247
+ const timeoutId = setTimeout(() => {
248
+ if (window.__figmaPendingRequests.has(requestId)) {
249
+ window.__figmaPendingRequests.delete(requestId);
250
+ reject(new Error(type + ' request timed out after ' + timeoutMs + 'ms'));
251
+ }
252
+ }, timeoutMs);
253
+
254
+ window.__figmaPendingRequests.set(requestId, { resolve: resolve, reject: reject, type: type, timeoutId: timeoutId });
255
+
256
+ var message = { type: type, requestId: requestId };
257
+ for (var key in params) {
258
+ if (params.hasOwnProperty(key)) {
259
+ message[key] = params[key];
260
+ }
261
+ }
262
+
263
+ parent.postMessage({ pluginMessage: message }, '*');
264
+ console.log('[MCP Bridge] Sent:', type);
265
+ });
266
+ };
88
267
 
89
- function versionAtLeast(version, minimum) {
90
- if (!version || !minimum) return false;
91
- var v = version.split('.').map(Number);
92
- var m = minimum.split('.').map(Number);
93
- for (var i = 0; i < 3; i++) {
94
- if ((v[i] || 0) > (m[i] || 0)) return true;
95
- if ((v[i] || 0) < (m[i] || 0)) return false;
268
+ // ============================================================================
269
+ // VARIABLE OPERATIONS
270
+ // ============================================================================
271
+
272
+ window.executeCode = (code, timeout) => {
273
+ return window.sendPluginCommand('EXECUTE_CODE', { code: code, timeout: timeout || 5000 }, (timeout || 5000) + 2000)
274
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
275
+ };
276
+
277
+ window.updateVariable = (variableId, modeId, value) => {
278
+ return window.sendPluginCommand('UPDATE_VARIABLE', { variableId: variableId, modeId: modeId, value: value })
279
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
280
+ };
281
+
282
+ window.createVariable = (name, collectionId, resolvedType, options) => {
283
+ var params = { name: name, collectionId: collectionId, resolvedType: resolvedType };
284
+ if (options) {
285
+ if (options.valuesByMode) params.valuesByMode = options.valuesByMode;
286
+ if (options.description) params.description = options.description;
287
+ if (options.scopes) params.scopes = options.scopes;
96
288
  }
97
- return true; // equal
98
- }
289
+ return window.sendPluginCommand('CREATE_VARIABLE', params)
290
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
291
+ };
292
+
293
+ window.createVariableCollection = (name, options) => {
294
+ var params = { name: name };
295
+ if (options) {
296
+ if (options.initialModeName) params.initialModeName = options.initialModeName;
297
+ if (options.additionalModes) params.additionalModes = options.additionalModes;
298
+ }
299
+ return window.sendPluginCommand('CREATE_VARIABLE_COLLECTION', params)
300
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
301
+ };
302
+
303
+ window.deleteVariable = (variableId) => {
304
+ return window.sendPluginCommand('DELETE_VARIABLE', { variableId: variableId })
305
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
306
+ };
307
+
308
+ window.deleteVariableCollection = (collectionId) => {
309
+ return window.sendPluginCommand('DELETE_VARIABLE_COLLECTION', { collectionId: collectionId })
310
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
311
+ };
312
+
313
+ window.renameVariable = (variableId, newName) => {
314
+ return window.sendPluginCommand('RENAME_VARIABLE', { variableId: variableId, newName: newName })
315
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
316
+ };
317
+
318
+ window.setVariableDescription = (variableId, description) => {
319
+ return window.sendPluginCommand('SET_VARIABLE_DESCRIPTION', { variableId: variableId, description: description })
320
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
321
+ };
322
+
323
+ window.addMode = (collectionId, modeName) => {
324
+ return window.sendPluginCommand('ADD_MODE', { collectionId: collectionId, modeName: modeName })
325
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
326
+ };
327
+
328
+ window.renameMode = (collectionId, modeId, newName) => {
329
+ return window.sendPluginCommand('RENAME_MODE', { collectionId: collectionId, modeId: modeId, newName: newName })
330
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
331
+ };
332
+
333
+ window.refreshVariables = () => {
334
+ return window.sendPluginCommand('REFRESH_VARIABLES', {}, 300000)
335
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
336
+ };
337
+
338
+ // ============================================================================
339
+ // COMPONENT OPERATIONS
340
+ // ============================================================================
341
+
342
+ window.getLocalComponents = () => {
343
+ return window.sendPluginCommand('GET_LOCAL_COMPONENTS', {}, 300000)
344
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
345
+ };
346
+
347
+ window.instantiateComponent = (componentKey, options) => {
348
+ var params = { componentKey: componentKey };
349
+ if (options) {
350
+ if (options.nodeId) params.nodeId = options.nodeId;
351
+ if (options.position) params.position = options.position;
352
+ if (options.size) params.size = options.size;
353
+ if (options.overrides) params.overrides = options.overrides;
354
+ if (options.variant) params.variant = options.variant;
355
+ if (options.parentId) params.parentId = options.parentId;
356
+ }
357
+ return window.sendPluginCommand('INSTANTIATE_COMPONENT', params)
358
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
359
+ };
360
+
361
+ window.requestComponentData = (nodeId) => {
362
+ return new Promise((resolve, reject) => {
363
+ const requestId = 'component_' + (++requestIdCounter) + '_' + Date.now();
364
+ window.__figmaComponentRequests.set(requestId, { resolve: resolve, reject: reject });
365
+ parent.postMessage({ pluginMessage: { type: 'GET_COMPONENT', requestId: requestId, nodeId: nodeId } }, '*');
366
+ setTimeout(() => {
367
+ if (window.__figmaComponentRequests.has(requestId)) {
368
+ window.__figmaComponentRequests.delete(requestId);
369
+ reject(new Error('Component request timed out'));
370
+ }
371
+ }, 10000);
372
+ });
373
+ };
374
+
375
+ // ============================================================================
376
+ // NEW: COMPONENT PROPERTY MANAGEMENT
377
+ // ============================================================================
378
+
379
+ // Set component/node description
380
+ window.setNodeDescription = (nodeId, description, descriptionMarkdown) => {
381
+ return window.sendPluginCommand('SET_NODE_DESCRIPTION', {
382
+ nodeId: nodeId,
383
+ description: description,
384
+ descriptionMarkdown: descriptionMarkdown
385
+ }).catch(function(err) { return { success: false, error: err.message || String(err) }; });
386
+ };
387
+
388
+ // Add a component property (BOOLEAN, TEXT, INSTANCE_SWAP, VARIANT)
389
+ // Note: We use 'propertyType' instead of 'type' to avoid collision with message type field
390
+ window.addComponentProperty = (nodeId, propertyName, type, defaultValue, options) => {
391
+ var params = { nodeId: nodeId, propertyName: propertyName, propertyType: type, defaultValue: defaultValue };
392
+ if (options) {
393
+ if (options.preferredValues) params.preferredValues = options.preferredValues;
394
+ }
395
+ return window.sendPluginCommand('ADD_COMPONENT_PROPERTY', params)
396
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
397
+ };
398
+
399
+ // Edit an existing component property
400
+ window.editComponentProperty = (nodeId, propertyName, newValue) => {
401
+ return window.sendPluginCommand('EDIT_COMPONENT_PROPERTY', {
402
+ nodeId: nodeId,
403
+ propertyName: propertyName,
404
+ newValue: newValue
405
+ }).catch(function(err) { return { success: false, error: err.message || String(err) }; });
406
+ };
407
+
408
+ // Delete a component property
409
+ window.deleteComponentProperty = (nodeId, propertyName) => {
410
+ return window.sendPluginCommand('DELETE_COMPONENT_PROPERTY', {
411
+ nodeId: nodeId,
412
+ propertyName: propertyName
413
+ }).catch(function(err) { return { success: false, error: err.message || String(err) }; });
414
+ };
415
+
416
+ // ============================================================================
417
+ // NEW: NODE MANIPULATION
418
+ // ============================================================================
419
+
420
+ // Resize any node
421
+ window.resizeNode = (nodeId, width, height, withConstraints) => {
422
+ return window.sendPluginCommand('RESIZE_NODE', {
423
+ nodeId: nodeId,
424
+ width: width,
425
+ height: height,
426
+ withConstraints: withConstraints !== false
427
+ }).catch(function(err) { return { success: false, error: err.message || String(err) }; });
428
+ };
429
+
430
+ // Move/position a node
431
+ window.moveNode = (nodeId, x, y) => {
432
+ return window.sendPluginCommand('MOVE_NODE', { nodeId: nodeId, x: x, y: y })
433
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
434
+ };
435
+
436
+ // Set node fills (colors)
437
+ window.setNodeFills = (nodeId, fills) => {
438
+ return window.sendPluginCommand('SET_NODE_FILLS', { nodeId: nodeId, fills: fills })
439
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
440
+ };
441
+
442
+ // Set node strokes
443
+ window.setNodeStrokes = (nodeId, strokes, strokeWeight) => {
444
+ var params = { nodeId: nodeId, strokes: strokes };
445
+ if (strokeWeight !== undefined) params.strokeWeight = strokeWeight;
446
+ return window.sendPluginCommand('SET_NODE_STROKES', params)
447
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
448
+ };
449
+
450
+ // Set node opacity
451
+ window.setNodeOpacity = (nodeId, opacity) => {
452
+ return window.sendPluginCommand('SET_NODE_OPACITY', { nodeId: nodeId, opacity: opacity })
453
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
454
+ };
455
+
456
+ // Set node corner radius
457
+ window.setNodeCornerRadius = (nodeId, radius) => {
458
+ return window.sendPluginCommand('SET_NODE_CORNER_RADIUS', { nodeId: nodeId, radius: radius })
459
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
460
+ };
461
+
462
+ // Clone a node
463
+ window.cloneNode = (nodeId) => {
464
+ return window.sendPluginCommand('CLONE_NODE', { nodeId: nodeId })
465
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
466
+ };
467
+
468
+ // Delete a node
469
+ window.deleteNode = (nodeId) => {
470
+ return window.sendPluginCommand('DELETE_NODE', { nodeId: nodeId })
471
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
472
+ };
473
+
474
+ // Rename a node
475
+ window.renameNode = (nodeId, newName) => {
476
+ return window.sendPluginCommand('RENAME_NODE', { nodeId: nodeId, newName: newName })
477
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
478
+ };
479
+
480
+ // Set text content (for text nodes)
481
+ window.setTextContent = (nodeId, text, options) => {
482
+ var params = { nodeId: nodeId, text: text };
483
+ if (options) {
484
+ if (options.fontSize) params.fontSize = options.fontSize;
485
+ if (options.fontWeight) params.fontWeight = options.fontWeight;
486
+ if (options.fontFamily) params.fontFamily = options.fontFamily;
487
+ }
488
+ return window.sendPluginCommand('SET_TEXT_CONTENT', params)
489
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
490
+ };
491
+
492
+ // Create a new node as child
493
+ window.createChildNode = (parentId, nodeType, properties) => {
494
+ return window.sendPluginCommand('CREATE_CHILD_NODE', {
495
+ parentId: parentId,
496
+ nodeType: nodeType,
497
+ properties: properties || {}
498
+ }).catch(function(err) { return { success: false, error: err.message || String(err) }; });
499
+ };
500
+
501
+ // ============================================================================
502
+ // NEW: SCREENSHOT & INSTANCE PROPERTIES (Fix for visual validation loop)
503
+ // ============================================================================
504
+
505
+ // Capture screenshot using plugin's exportAsync (reads current plugin state, not cloud)
506
+ // This solves the race condition where REST API screenshots show stale state
507
+ window.captureScreenshot = (nodeId, options) => {
508
+ var params = { nodeId: nodeId };
509
+ if (options) {
510
+ if (options.format) params.format = options.format; // PNG, JPG, SVG
511
+ if (options.scale) params.scale = options.scale; // 1, 2, 4, etc.
512
+ }
513
+ return window.sendPluginCommand('CAPTURE_SCREENSHOT', params, 30000)
514
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
515
+ };
516
+
517
+ // Set image fill on nodes — decodes base64 in browser context (atob available here)
518
+ // then sends raw bytes to plugin where figma.createImage() is called
519
+ window.setImageFill = (nodeIds, imageData, scaleMode) => {
520
+ // Decode base64 to Uint8Array in browser context where atob() is available
521
+ var binaryStr = atob(imageData);
522
+ var bytes = new Uint8Array(binaryStr.length);
523
+ for (var i = 0; i < binaryStr.length; i++) {
524
+ bytes[i] = binaryStr.charCodeAt(i);
525
+ }
526
+ // Send as plain Array (postMessage can't always transfer typed arrays cleanly)
527
+ return window.sendPluginCommand('SET_IMAGE_FILL', {
528
+ nodeIds: Array.isArray(nodeIds) ? nodeIds : [nodeIds],
529
+ imageBytes: Array.from(bytes),
530
+ scaleMode: scaleMode || 'FILL'
531
+ }, 60000)
532
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
533
+ };
534
+
535
+ // Set component instance properties (TEXT, BOOLEAN, INSTANCE_SWAP, VARIANT)
536
+ // This is the correct way to update component instances vs direct text node editing
537
+ window.setInstanceProperties = (nodeId, properties) => {
538
+ return window.sendPluginCommand('SET_INSTANCE_PROPERTIES', {
539
+ nodeId: nodeId,
540
+ properties: properties
541
+ }).catch(function(err) { return { success: false, error: err.message || String(err) }; });
542
+ };
543
+
544
+ // Lint design for accessibility and quality issues
545
+ window.lintDesign = (nodeId, rules, maxDepth, maxFindings) => {
546
+ var params = {};
547
+ if (nodeId) params.nodeId = nodeId;
548
+ if (rules) params.rules = rules;
549
+ if (maxDepth !== undefined) params.maxDepth = maxDepth;
550
+ if (maxFindings !== undefined) params.maxFindings = maxFindings;
551
+ return window.sendPluginCommand('LINT_DESIGN', params, 120000)
552
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
553
+ };
554
+
555
+ // ============================================================================
556
+ // WEBSOCKET BRIDGE CLIENT - Fallback transport when CDP is unavailable
557
+ // ============================================================================
558
+ (function() {
559
+ // Port range for multi-instance support (matches server's port-discovery.ts)
560
+ var WS_PORT_RANGE_START = 9223;
561
+ var WS_PORT_RANGE_END = 9232;
562
+
563
+ // Multi-connection state: plugin connects to ALL active MCP servers
564
+ // so that every Claude tab/CLI instance gets Figma access.
565
+ var activeConnections = []; // Array of { port, ws }
566
+ var wsReconnectDelay = 500;
567
+ var wsMaxReconnectDelay = 5000;
568
+ var wsReconnectAttempts = 0;
569
+ var wsMaxReconnectAttempts = 50;
570
+ var isScanning = false;
571
+
572
+ // Backward-compat: ws and wsConnected reflect "at least one connection"
573
+ var ws = null;
574
+ var wsPort = null;
575
+ var wsConnected = false;
99
576
 
100
- /**
101
- * Scan all ports for a live MCP server that supports the bootloader protocol.
102
- * Connects via WebSocket, waits for SERVER_HELLO, checks version supports
103
- * GET_PLUGIN_UI. Skips old servers that don't support the protocol.
104
- */
105
- function findServer() {
106
- setStatus('MCP scanning…');
107
-
108
- return new Promise(function(resolve) {
109
- var found = false;
110
- var pending = PORTS.length;
111
- // Collect all candidates, pick the best one when all scans complete
112
- var candidates = [];
113
-
114
- PORTS.forEach(function(port) {
577
+ // Method-to-function mapping
578
+ var methodMap = {
579
+ 'EXECUTE_CODE': function(params) { return window.executeCode(params.code, params.timeout); },
580
+ 'UPDATE_VARIABLE': function(params) { return window.updateVariable(params.variableId, params.modeId, params.value); },
581
+ 'CREATE_VARIABLE': function(params) { return window.createVariable(params.name, params.collectionId, params.resolvedType, params); },
582
+ 'DELETE_VARIABLE': function(params) { return window.deleteVariable(params.variableId); },
583
+ 'DELETE_VARIABLE_COLLECTION': function(params) { return window.deleteVariableCollection(params.collectionId); },
584
+ 'RENAME_VARIABLE': function(params) { return window.renameVariable(params.variableId, params.newName); },
585
+ 'SET_VARIABLE_DESCRIPTION': function(params) { return window.setVariableDescription(params.variableId, params.description); },
586
+ 'ADD_MODE': function(params) { return window.addMode(params.collectionId, params.modeName); },
587
+ 'RENAME_MODE': function(params) { return window.renameMode(params.collectionId, params.modeId, params.newName); },
588
+ 'REFRESH_VARIABLES': function() { return window.refreshVariables(); },
589
+ 'CREATE_VARIABLE_COLLECTION': function(params) { return window.createVariableCollection(params.name, params); },
590
+ 'GET_LOCAL_COMPONENTS': function() { return window.getLocalComponents(); },
591
+ 'INSTANTIATE_COMPONENT': function(params) { return window.instantiateComponent(params.componentKey, params); },
592
+ 'GET_COMPONENT': function(params) { return window.requestComponentData(params.nodeId); },
593
+ 'SET_NODE_DESCRIPTION': function(params) { return window.setNodeDescription(params.nodeId, params.description, params.descriptionMarkdown); },
594
+ 'ADD_COMPONENT_PROPERTY': function(params) { return window.addComponentProperty(params.nodeId, params.propertyName, params.propertyType, params.defaultValue, params); },
595
+ 'EDIT_COMPONENT_PROPERTY': function(params) { return window.editComponentProperty(params.nodeId, params.propertyName, params.newValue); },
596
+ 'DELETE_COMPONENT_PROPERTY': function(params) { return window.deleteComponentProperty(params.nodeId, params.propertyName); },
597
+ 'RESIZE_NODE': function(params) { return window.resizeNode(params.nodeId, params.width, params.height, params.withConstraints); },
598
+ 'MOVE_NODE': function(params) { return window.moveNode(params.nodeId, params.x, params.y); },
599
+ 'SET_NODE_FILLS': function(params) { return window.setNodeFills(params.nodeId, params.fills); },
600
+ 'SET_NODE_STROKES': function(params) { return window.setNodeStrokes(params.nodeId, params.strokes, params.strokeWeight); },
601
+ 'SET_NODE_OPACITY': function(params) { return window.setNodeOpacity(params.nodeId, params.opacity); },
602
+ 'SET_NODE_CORNER_RADIUS': function(params) { return window.setNodeCornerRadius(params.nodeId, params.radius); },
603
+ 'CLONE_NODE': function(params) { return window.cloneNode(params.nodeId); },
604
+ 'DELETE_NODE': function(params) { return window.deleteNode(params.nodeId); },
605
+ 'RENAME_NODE': function(params) { return window.renameNode(params.nodeId, params.newName); },
606
+ 'SET_TEXT_CONTENT': function(params) { return window.setTextContent(params.nodeId, params.text, params); },
607
+ 'CREATE_CHILD_NODE': function(params) { return window.createChildNode(params.parentId, params.nodeType, params.properties); },
608
+ 'CAPTURE_SCREENSHOT': function(params) { return window.captureScreenshot(params.nodeId, params); },
609
+ 'SET_IMAGE_FILL': function(params) { return window.setImageFill(params.nodeIds || params.nodeId, params.imageData, params.scaleMode); },
610
+ 'SET_INSTANCE_PROPERTIES': function(params) { return window.setInstanceProperties(params.nodeId, params.properties); },
611
+ 'LINT_DESIGN': function(params) { return window.lintDesign(params.nodeId, params.rules, params.maxDepth, params.maxFindings); },
612
+ 'GET_VARIABLES_DATA': function() {
613
+ // Return the cached variables data directly
614
+ if (window.__figmaVariablesReady && window.__figmaVariablesData) {
615
+ return Promise.resolve(window.__figmaVariablesData);
616
+ }
617
+ return Promise.reject(new Error('Variables data not ready. Make sure the Desktop Bridge plugin has loaded.'));
618
+ },
619
+ 'GET_FILE_INFO': function() {
620
+ return window.sendPluginCommand('GET_FILE_INFO', {});
621
+ },
622
+ 'CLEAR_CONSOLE': function() {
623
+ // Console buffer is maintained server-side; this is a no-op ack
624
+ return Promise.resolve({ cleared: true });
625
+ },
626
+ 'RELOAD_UI': function() {
627
+ return window.sendPluginCommand('RELOAD_UI', {});
628
+ }
629
+ };
630
+
631
+ /**
632
+ * Check if we already have a connection to a specific port.
633
+ */
634
+ function isPortConnected(port) {
635
+ for (var i = 0; i < activeConnections.length; i++) {
636
+ if (activeConnections[i].port === port && activeConnections[i].ws.readyState === 1) {
637
+ return true;
638
+ }
639
+ }
640
+ return false;
641
+ }
642
+
643
+ /**
644
+ * Remove a connection from the active list and update compat state.
645
+ */
646
+ function removeConnection(port) {
647
+ activeConnections = activeConnections.filter(function(c) { return c.port !== port; });
648
+ updateCompatState();
649
+ }
650
+
651
+ /**
652
+ * Update backward-compat variables (ws, wsPort, wsConnected).
653
+ */
654
+ function updateCompatState() {
655
+ var live = activeConnections.filter(function(c) { return c.ws.readyState === 1; });
656
+ wsConnected = live.length > 0;
657
+ if (live.length > 0) {
658
+ ws = live[0].ws;
659
+ wsPort = live[0].port;
660
+ } else {
661
+ ws = null;
662
+ wsPort = null;
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Initialize a new connection to a server: send FILE_INFO, variables, attach handlers.
668
+ */
669
+ function initializeConnection(connWs, port) {
670
+ // Forward cached variables if available
671
+ if (window.__figmaVariablesReady && window.__figmaVariablesData) {
672
+ connWs.send(JSON.stringify({
673
+ type: 'VARIABLES_DATA',
674
+ data: window.__figmaVariablesData
675
+ }));
676
+ }
677
+
678
+ // Proactively report file identity to this server
679
+ window.sendPluginCommand('GET_FILE_INFO', {})
680
+ .then(function(info) {
681
+ if (connWs.readyState === 1 && info && info.success !== false) {
682
+ connWs.send(JSON.stringify({ type: 'FILE_INFO', data: info.fileInfo || info }));
683
+ }
684
+ })
685
+ .catch(function() { /* non-critical */ });
686
+ }
687
+
688
+ /**
689
+ * Scan the port range and connect to ALL active MCP servers.
690
+ * Each server (e.g., Chat tab on 9223, Code tab on 9224) gets its own
691
+ * independent WebSocket connection so every Claude instance has Figma access.
692
+ * Falls back to retry with backoff if no servers found at all.
693
+ */
694
+ // Maximum scan attempts on initial load (prevents infinite error loops).
695
+ // After connecting, disconnect-triggered retries have their own limit.
696
+ var initialScanAttempts = 0;
697
+ var MAX_INITIAL_SCANS = 3;
698
+
699
+ function wsScanAndConnect() {
700
+ if (isScanning) return;
701
+ isScanning = true;
702
+
703
+ var portsToTry = [];
704
+ for (var p = WS_PORT_RANGE_START; p <= WS_PORT_RANGE_END; p++) {
705
+ if (!isPortConnected(p)) portsToTry.push(p);
706
+ }
707
+
708
+ if (portsToTry.length === 0) { isScanning = false; return; }
709
+
710
+ console.log('[MCP Bridge] Scanning ports ' + WS_PORT_RANGE_START + '-' + WS_PORT_RANGE_END + ' for MCP servers...');
711
+
712
+ var foundAny = false;
713
+ var pending = portsToTry.length;
714
+
715
+ portsToTry.forEach(function(port) {
115
716
  try {
116
- var ws = new WebSocket('ws://localhost:' + port);
117
- var gotHello = false;
717
+ var testWs = new WebSocket('ws://localhost:' + port);
118
718
 
119
- // Connection timeout close if we can't even connect in 3s
120
- var connectTimeout = setTimeout(function() {
121
- if (!gotHello) { try { ws.close(); } catch(e) {} }
719
+ var timeout = setTimeout(function() {
720
+ if (testWs.readyState !== 1) testWs.close();
122
721
  }, 3000);
123
722
 
124
- ws.onopen = function() {
125
- clearTimeout(connectTimeout);
126
- // Connected — now wait for SERVER_HELLO, but not forever.
127
- // If the server doesn't send it within 2s, it's not our server.
128
- var helloTimeout = setTimeout(function() {
129
- if (!gotHello) { try { ws.close(); } catch(e) {} }
130
- }, 2000);
131
-
132
- ws.onmessage = function(event) {
133
- try {
134
- var data = JSON.parse(event.data);
135
- if (data.type === 'SERVER_HELLO' && data.data) {
136
- gotHello = true;
137
- clearTimeout(helloTimeout);
138
- var ver = data.data.serverVersion || '0.0.0';
139
- if (versionAtLeast(ver, MIN_SERVER_VERSION) && !found) {
140
- found = true;
141
- candidates.forEach(function(c) { try { c.ws.close(); } catch(e) {} });
142
- resolve({ port: port, ws: ws, serverInfo: data.data });
143
- } else {
144
- candidates.push({ port: port, ws: ws, version: ver });
145
- ws.close();
146
- }
147
- }
148
- } catch (e) { /* ignore parse errors */ }
149
- };
723
+ testWs.onopen = function() {
724
+ clearTimeout(timeout);
725
+ foundAny = true;
726
+ activeConnections.push({ port: port, ws: testWs });
727
+ updateCompatState();
728
+ console.log('[MCP Bridge] WebSocket connected to port ' + port + ' (' + activeConnections.length + ' server(s) total)');
729
+ attachWsHandlers(testWs, port);
730
+ initializeConnection(testWs, port);
731
+ pending--;
732
+ if (pending <= 0) {
733
+ isScanning = false;
734
+ wsReconnectDelay = 500;
735
+ wsReconnectAttempts = 0;
736
+ }
150
737
  };
151
738
 
152
- ws.onerror = function() { clearTimeout(connectTimeout); };
739
+ testWs.onerror = function() {
740
+ clearTimeout(timeout);
741
+ };
153
742
 
154
- ws.onclose = function() {
743
+ testWs.onclose = function() {
155
744
  clearTimeout(timeout);
156
745
  pending--;
157
- if (pending <= 0 && !found) {
158
- // No server with bootloader support found.
159
- // If there are old servers, resolve with the best candidate
160
- // so the bootloader can fall back to legacy behavior.
161
- if (candidates.length > 0) {
162
- resolve({ port: candidates[0].port, ws: null, serverInfo: null, legacy: true });
163
- } else {
164
- resolve(null);
746
+ if (pending <= 0) {
747
+ isScanning = false;
748
+ // Retry with backoff if no servers found, up to MAX_INITIAL_SCANS
749
+ if (!foundAny && activeConnections.length === 0) {
750
+ initialScanAttempts++;
751
+ if (initialScanAttempts < MAX_INITIAL_SCANS) {
752
+ var delay = 3000 * initialScanAttempts; // 3s, 6s
753
+ console.log('[MCP Bridge] No servers found, retry ' + initialScanAttempts + '/' + MAX_INITIAL_SCANS + ' in ' + (delay/1000) + 's');
754
+ setTimeout(wsScanAndConnect, delay);
755
+ } else {
756
+ console.log('[MCP Bridge] No MCP servers found after ' + MAX_INITIAL_SCANS + ' scans. Restart plugin to retry.');
757
+ }
165
758
  }
166
759
  }
167
760
  };
168
761
  } catch (e) {
169
762
  pending--;
170
- if (pending <= 0 && !found) resolve(null);
171
763
  }
172
764
  });
173
- });
174
- }
765
+ }
175
766
 
176
- /**
177
- * Request the full UI HTML from the server via WebSocket.
178
- * The server responds with a GET_PLUGIN_UI_RESULT message containing the HTML.
179
- */
180
- function requestFullUI(ws) {
181
- return new Promise(function(resolve, reject) {
182
- var timeout = setTimeout(function() {
183
- reject(new Error('Timeout fetching UI'));
184
- }, 10000);
767
+ /**
768
+ * Reconnect a specific port that disconnected.
769
+ * Tries the same port first (server may have just restarted),
770
+ * then does a full rescan to pick up any new servers.
771
+ */
772
+ function wsReconnectPort(port) {
773
+ try {
774
+ var testWs = new WebSocket('ws://localhost:' + port);
775
+ var timeout = setTimeout(function() {
776
+ if (testWs.readyState !== 1) testWs.close();
777
+ }, 2000);
778
+
779
+ testWs.onopen = function() {
780
+ clearTimeout(timeout);
781
+ activeConnections.push({ port: port, ws: testWs });
782
+ updateCompatState();
783
+ console.log('[MCP Bridge] Reconnected to port ' + port + ' (' + activeConnections.length + ' server(s) total)');
784
+ attachWsHandlers(testWs, port);
785
+ initializeConnection(testWs, port);
786
+ };
787
+
788
+ testWs.onerror = function() {
789
+ clearTimeout(timeout);
790
+ };
791
+ } catch (e) {
792
+ // Port gone — no further scanning to avoid console spam
793
+ }
794
+ }
185
795
 
186
- var origHandler = ws.onmessage;
187
- ws.onmessage = function(event) {
796
+ /**
797
+ * Attach message/close/error handlers to an established WebSocket connection.
798
+ */
799
+ function attachWsHandlers(activeWs, port) {
800
+ activeWs.onmessage = function(event) {
188
801
  try {
189
- var data = JSON.parse(event.data);
190
- if (data.type === 'PLUGIN_UI_CONTENT' && data.html) {
191
- clearTimeout(timeout);
192
- ws.close();
193
- resolve(data.html);
802
+ var message = JSON.parse(event.data);
803
+
804
+ // Handle server identity messages
805
+ if (message.type === 'SERVER_HELLO' && message.data) {
806
+ console.log('[MCP Bridge] Connected to server on port ' + message.data.port + ' (PID: ' + message.data.pid + ', v' + message.data.serverVersion + ')');
807
+ var conn = activeConnections.find(function(c) { return c.ws === activeWs; });
808
+ if (conn) conn.serverInfo = message.data;
809
+ return;
810
+ }
811
+
812
+ if (!message.id || !message.method) {
813
+ console.log('[MCP Bridge] WS:' + port + ': Ignoring malformed message');
814
+ return;
815
+ }
816
+
817
+ var handler = methodMap[message.method];
818
+ if (!handler) {
819
+ activeWs.send(JSON.stringify({ id: message.id, error: 'Unknown method: ' + message.method }));
820
+ return;
194
821
  }
195
- } catch (e) { /* ignore */ }
196
- // Pass through other messages to original handler
197
- if (origHandler) origHandler.call(ws, event);
822
+
823
+ // Call the handler (returns a Promise) and send back the result
824
+ Promise.resolve(handler(message.params || {}))
825
+ .then(function(result) {
826
+ if (activeWs.readyState === 1) {
827
+ activeWs.send(JSON.stringify({ id: message.id, result: result }));
828
+ }
829
+ })
830
+ .catch(function(err) {
831
+ if (activeWs.readyState === 1) {
832
+ activeWs.send(JSON.stringify({ id: message.id, error: err.message || String(err) }));
833
+ }
834
+ });
835
+ } catch (e) {
836
+ console.error('[MCP Bridge] WS:' + port + ': Failed to process message:', e);
837
+ }
198
838
  };
199
839
 
200
- // Request the full UI HTML
201
- ws.send(JSON.stringify({ type: 'GET_PLUGIN_UI' }));
202
- });
203
- }
840
+ activeWs.onclose = function(event) {
841
+ removeConnection(port);
842
+ console.log('[MCP Bridge] WebSocket disconnected from port ' + port + ' (' + activeConnections.length + ' server(s) remaining)');
843
+
844
+ // If replaced by same file reconnecting (e.g., plugin reloaded), stop
845
+ var wasReplaced = (event.code === 1000 && (
846
+ event.reason === 'Replaced by new connection' ||
847
+ event.reason === 'Replaced by same file reconnection'
848
+ ));
849
+ if (wasReplaced) {
850
+ console.log('[MCP Bridge] WebSocket:' + port + ': replaced by newer connection, stopping reconnect');
851
+ return;
852
+ }
204
853
 
205
- /**
206
- * Send the fresh HTML to code.js, which will call figma.showUI(html).
207
- */
208
- function loadFreshUI(html) {
209
- parent.postMessage({
210
- pluginMessage: {
211
- type: 'BOOT_LOAD_UI',
212
- html: html
854
+ // Retry the specific port with limited attempts (no full rescan)
855
+ wsReconnectAttempts++;
856
+ if (wsReconnectAttempts <= 5) {
857
+ var delay = Math.min(1000 * wsReconnectAttempts, 5000);
858
+ setTimeout(function() { wsReconnectPort(port); }, delay);
859
+ }
860
+ };
861
+
862
+ activeWs.onerror = function() {
863
+ // onclose will fire after this, triggering reconnect
864
+ };
865
+ }
866
+
867
+ /**
868
+ * Broadcast a message to ALL active WebSocket connections.
869
+ * Events like variable changes, selections, document changes need to
870
+ * reach every MCP server instance so they all have current state.
871
+ */
872
+ function broadcastToAll(message) {
873
+ var json = JSON.stringify(message);
874
+ activeConnections.forEach(function(conn) {
875
+ if (conn.ws.readyState === 1) {
876
+ try { conn.ws.send(json); } catch(e) { /* ignore send errors */ }
877
+ }
878
+ });
879
+ }
880
+
881
+ // Forward VARIABLES_DATA to all connected MCP servers
882
+ window.__wsForwardVariables = function(data) {
883
+ if (wsConnected) {
884
+ broadcastToAll({ type: 'VARIABLES_DATA', data: data });
885
+ }
886
+ };
887
+
888
+ // Forward DOCUMENT_CHANGE events to all servers for cache invalidation
889
+ window.__wsForwardDocumentChange = function(data) {
890
+ if (wsConnected) {
891
+ broadcastToAll({ type: 'DOCUMENT_CHANGE', data: data });
892
+ }
893
+ };
894
+
895
+ // Forward CONSOLE_CAPTURE events to all servers for console monitoring
896
+ window.__wsForwardConsoleCapture = function(data) {
897
+ if (wsConnected) {
898
+ broadcastToAll({ type: 'CONSOLE_CAPTURE', data: data });
899
+ }
900
+ };
901
+
902
+ // Forward SELECTION_CHANGE events to all servers for selection tracking
903
+ window.__wsForwardSelectionChange = function(data) {
904
+ if (wsConnected) {
905
+ broadcastToAll({ type: 'SELECTION_CHANGE', data: data });
906
+ }
907
+ };
908
+
909
+ // Forward PAGE_CHANGE events to all servers for page tracking
910
+ window.__wsForwardPageChange = function(data) {
911
+ if (wsConnected) {
912
+ broadcastToAll({ type: 'PAGE_CHANGE', data: data });
913
+ }
914
+ };
915
+
916
+ // Expose functions for Cloud Mode to add connections to the same pool
917
+ window.__wsAddCloudConnection = function(cloudWs, label, onDisconnect) {
918
+ activeConnections.push({ port: label, ws: cloudWs });
919
+ updateCompatState();
920
+ attachWsHandlers(cloudWs, label);
921
+ // Chain cloud disconnect callback after attachWsHandlers' onclose
922
+ if (onDisconnect) {
923
+ var origOnClose = cloudWs.onclose;
924
+ cloudWs.onclose = function(event) {
925
+ if (origOnClose) origOnClose.call(cloudWs, event);
926
+ onDisconnect();
927
+ };
213
928
  }
214
- }, '*');
929
+ initializeConnection(cloudWs, label);
930
+ };
931
+
932
+ window.__wsGetActiveConnections = function() { return activeConnections; };
933
+
934
+ // Single scan on load — no periodic rescanning to avoid console spam.
935
+ // If no server found, retries up to MAX_INITIAL_SCANS times then stops.
936
+ // On disconnect, retries the specific port up to 5 times then stops.
937
+ wsScanAndConnect();
938
+ })();
939
+
940
+ // ============================================================================
941
+ // CLOUD MODE — Connect to remote relay via pairing code
942
+ // ============================================================================
943
+ var cloudWs = null;
944
+ var CLOUD_RELAY_HOST = 'wss://figma-console-mcp.southleft.com';
945
+
946
+ function toggleCloudSection() {
947
+ var section = document.getElementById('cloud-section');
948
+ var toggle = document.getElementById('cloud-toggle');
949
+ var isExpanding = !section.classList.contains('visible');
950
+ section.classList.toggle('visible');
951
+ toggle.classList.toggle('expanded');
952
+
953
+ // Resize plugin window to fit content
954
+ var height = isExpanding ? 130 : 50;
955
+ parent.postMessage({ pluginMessage: { type: 'RESIZE_UI', width: 140, height: height } }, '*');
215
956
  }
216
957
 
217
- /**
218
- * Main boot sequence.
219
- */
220
- var attempts = 0;
221
- var MAX_ATTEMPTS = 5;
222
-
223
- function boot() {
224
- findServer().then(function(result) {
225
- if (result && result.legacy) {
226
- // Old server found — can't use bootloader protocol.
227
- // Tell code.js to fall back to the cached full UI.
228
- setStatus('MCP v' + (result.serverInfo ? result.serverInfo.serverVersion : 'old'));
229
- parent.postMessage({
230
- pluginMessage: { type: 'BOOT_FALLBACK', port: result.port }
231
- }, '*');
232
- } else if (result && result.ws) {
233
- setStatus('MCP found (port ' + result.port + ')');
234
-
235
- // Fetch fresh UI HTML from server via WebSocket
236
- requestFullUI(result.ws).then(function(html) {
237
- setStatus('Loading UI…');
238
- loadFreshUI(html);
239
- }).catch(function(err) {
240
- setError('UI load error');
241
- try { result.ws.close(); } catch(e) {}
242
- });
243
- } else {
244
- attempts++;
245
- if (attempts < MAX_ATTEMPTS) {
246
- var delay = 2000 * attempts;
247
- setStatus('Retry ' + attempts + '/' + MAX_ATTEMPTS + '…');
248
- setTimeout(boot, delay);
958
+ function resetCloudUI() {
959
+ var statusEl = document.getElementById('cloud-status');
960
+ var btn = document.getElementById('cloud-btn');
961
+ if (statusEl) {
962
+ statusEl.textContent = 'Disconnected';
963
+ statusEl.className = 'cloud-status';
964
+ }
965
+ if (btn) {
966
+ btn.disabled = false;
967
+ btn.textContent = 'Connect';
968
+ btn.onclick = cloudConnect;
969
+ }
970
+ cloudWs = null;
971
+ }
972
+
973
+ function cloudConnect() {
974
+ var codeInput = document.getElementById('cloud-code');
975
+ var btn = document.getElementById('cloud-btn');
976
+ var statusEl = document.getElementById('cloud-status');
977
+ var code = (codeInput.value || '').trim().toUpperCase();
978
+
979
+ if (!code || code.length < 4) {
980
+ statusEl.textContent = 'Enter pairing code';
981
+ statusEl.className = 'cloud-status error';
982
+ return;
983
+ }
984
+
985
+ btn.disabled = true;
986
+ statusEl.textContent = 'Connecting...';
987
+ statusEl.className = 'cloud-status';
988
+
989
+ // Close existing cloud connection if any
990
+ if (cloudWs && cloudWs.readyState <= 1) {
991
+ cloudWs.close();
992
+ }
993
+
994
+ try {
995
+ cloudWs = new WebSocket(CLOUD_RELAY_HOST + '/ws/pair?code=' + code);
996
+
997
+ cloudWs.onopen = function() {
998
+ statusEl.textContent = 'Connected to cloud relay';
999
+ statusEl.className = 'cloud-status connected';
1000
+ btn.disabled = false;
1001
+ btn.textContent = 'Disconnect';
1002
+ btn.onclick = cloudDisconnect;
1003
+
1004
+ // Add to the shared connection pool (uses same handlers as localhost).
1005
+ // Pass resetCloudUI as disconnect callback — attachWsHandlers overwrites
1006
+ // onclose, so this callback ensures cloud UI resets on server-initiated close.
1007
+ if (window.__wsAddCloudConnection) {
1008
+ window.__wsAddCloudConnection(cloudWs, 'cloud', resetCloudUI);
1009
+ }
1010
+
1011
+ // Persist cloud config via code.js clientStorage
1012
+ parent.postMessage({ pluginMessage: {
1013
+ type: 'STORE_CLOUD_CONFIG',
1014
+ code: code
1015
+ }}, '*');
1016
+ };
1017
+
1018
+ cloudWs.onerror = function() {
1019
+ statusEl.textContent = 'Connection failed — check code';
1020
+ statusEl.className = 'cloud-status error';
1021
+ btn.disabled = false;
1022
+ };
1023
+
1024
+ // Note: onclose here handles pre-connection close (e.g., bad code).
1025
+ // After onopen, attachWsHandlers overwrites this — resetCloudUI callback
1026
+ // handles post-connection close instead.
1027
+ cloudWs.onclose = function(event) {
1028
+ resetCloudUI();
1029
+ };
1030
+ } catch (e) {
1031
+ statusEl.textContent = 'Failed: ' + e.message;
1032
+ statusEl.className = 'cloud-status error';
1033
+ btn.disabled = false;
1034
+ }
1035
+ }
1036
+
1037
+ function cloudDisconnect() {
1038
+ if (cloudWs) {
1039
+ cloudWs.close();
1040
+ }
1041
+ // Reset UI immediately — don't rely on onclose (may be overwritten)
1042
+ resetCloudUI();
1043
+ }
1044
+
1045
+ // ============================================================================
1046
+ // MESSAGE HANDLER - Process responses from plugin worker
1047
+ // ============================================================================
1048
+ window.onmessage = (event) => {
1049
+ const msg = event.data.pluginMessage;
1050
+ if (!msg) return;
1051
+
1052
+ // Generic result handler
1053
+ const handleResult = (resultType, dataKey) => {
1054
+ const request = window.__figmaPendingRequests.get(msg.requestId);
1055
+ if (request) {
1056
+ if (request.timeoutId) clearTimeout(request.timeoutId);
1057
+ if (msg.success) {
1058
+ var result = { success: true };
1059
+ if (dataKey && msg[dataKey] !== undefined) result[dataKey] = msg[dataKey];
1060
+ if (msg.data !== undefined) result.data = msg.data;
1061
+ if (msg.oldName !== undefined) result.oldName = msg.oldName;
1062
+ if (msg.instance !== undefined) result.instance = msg.instance;
1063
+ request.resolve(result);
249
1064
  } else {
250
- setError('No MCP server found');
1065
+ request.resolve({ success: false, error: msg.error || 'Unknown error' });
251
1066
  }
1067
+ window.__figmaPendingRequests.delete(msg.requestId);
252
1068
  }
253
- });
254
- }
1069
+ };
1070
+
1071
+ // Handle message types
1072
+ switch (msg.type) {
1073
+ case 'VARIABLES_DATA':
1074
+ window.__figmaVariablesData = msg.data;
1075
+ window.__figmaVariablesReady = true;
1076
+ updateStatus('ready', true, false);
1077
+ console.log('[MCP Bridge] Active - ' + (msg.data.variables?.length || 0) + ' vars');
1078
+ // Forward to WebSocket client if connected
1079
+ if (window.__wsForwardVariables) window.__wsForwardVariables(msg.data);
1080
+ break;
1081
+
1082
+ case 'COMPONENT_DATA':
1083
+ window.__figmaComponentData = msg.data;
1084
+ var req = window.__figmaComponentRequests.get(msg.requestId);
1085
+ if (req) { req.resolve(msg.data); window.__figmaComponentRequests.delete(msg.requestId); }
1086
+ break;
1087
+
1088
+ case 'COMPONENT_ERROR':
1089
+ var req2 = window.__figmaComponentRequests.get(msg.requestId);
1090
+ if (req2) { req2.reject(new Error(msg.error)); window.__figmaComponentRequests.delete(msg.requestId); }
1091
+ break;
1092
+
1093
+ case 'ERROR':
1094
+ window.__figmaVariablesReady = false;
1095
+ updateStatus('error', false, true);
1096
+ console.error('[MCP Bridge] Error:', msg.error);
1097
+ break;
1098
+
1099
+ // Variable operations
1100
+ case 'EXECUTE_CODE_RESULT':
1101
+ handleResult('EXECUTE_CODE', 'result');
1102
+ break;
1103
+ case 'UPDATE_VARIABLE_RESULT':
1104
+ handleResult('UPDATE_VARIABLE', 'variable');
1105
+ break;
1106
+ case 'CREATE_VARIABLE_RESULT':
1107
+ handleResult('CREATE_VARIABLE', 'variable');
1108
+ break;
1109
+ case 'CREATE_VARIABLE_COLLECTION_RESULT':
1110
+ handleResult('CREATE_VARIABLE_COLLECTION', 'collection');
1111
+ break;
1112
+ case 'DELETE_VARIABLE_RESULT':
1113
+ handleResult('DELETE_VARIABLE', 'deleted');
1114
+ break;
1115
+ case 'DELETE_VARIABLE_COLLECTION_RESULT':
1116
+ handleResult('DELETE_VARIABLE_COLLECTION', 'deleted');
1117
+ break;
1118
+ case 'REFRESH_VARIABLES_RESULT':
1119
+ handleResult('REFRESH_VARIABLES', null);
1120
+ break;
1121
+ case 'RENAME_VARIABLE_RESULT':
1122
+ handleResult('RENAME_VARIABLE', 'variable');
1123
+ break;
1124
+ case 'SET_VARIABLE_DESCRIPTION_RESULT':
1125
+ handleResult('SET_VARIABLE_DESCRIPTION', 'variable');
1126
+ break;
1127
+ case 'ADD_MODE_RESULT':
1128
+ handleResult('ADD_MODE', 'collection');
1129
+ break;
1130
+ case 'RENAME_MODE_RESULT':
1131
+ handleResult('RENAME_MODE', 'collection');
1132
+ break;
1133
+ case 'GET_LOCAL_COMPONENTS_RESULT':
1134
+ handleResult('GET_LOCAL_COMPONENTS', null);
1135
+ break;
1136
+ case 'INSTANTIATE_COMPONENT_RESULT':
1137
+ handleResult('INSTANTIATE_COMPONENT', 'instance');
1138
+ break;
1139
+
1140
+ // NEW: Component property operations
1141
+ case 'SET_NODE_DESCRIPTION_RESULT':
1142
+ handleResult('SET_NODE_DESCRIPTION', 'node');
1143
+ break;
1144
+ case 'ADD_COMPONENT_PROPERTY_RESULT':
1145
+ handleResult('ADD_COMPONENT_PROPERTY', 'propertyName');
1146
+ break;
1147
+ case 'EDIT_COMPONENT_PROPERTY_RESULT':
1148
+ handleResult('EDIT_COMPONENT_PROPERTY', 'propertyName');
1149
+ break;
1150
+ case 'DELETE_COMPONENT_PROPERTY_RESULT':
1151
+ handleResult('DELETE_COMPONENT_PROPERTY', null);
1152
+ break;
1153
+
1154
+ // NEW: Node manipulation operations
1155
+ case 'RESIZE_NODE_RESULT':
1156
+ handleResult('RESIZE_NODE', 'node');
1157
+ break;
1158
+ case 'MOVE_NODE_RESULT':
1159
+ handleResult('MOVE_NODE', 'node');
1160
+ break;
1161
+ case 'SET_NODE_FILLS_RESULT':
1162
+ handleResult('SET_NODE_FILLS', 'node');
1163
+ break;
1164
+ case 'SET_NODE_STROKES_RESULT':
1165
+ handleResult('SET_NODE_STROKES', 'node');
1166
+ break;
1167
+ case 'SET_NODE_OPACITY_RESULT':
1168
+ handleResult('SET_NODE_OPACITY', 'node');
1169
+ break;
1170
+ case 'SET_NODE_CORNER_RADIUS_RESULT':
1171
+ handleResult('SET_NODE_CORNER_RADIUS', 'node');
1172
+ break;
1173
+ case 'CLONE_NODE_RESULT':
1174
+ handleResult('CLONE_NODE', 'node');
1175
+ break;
1176
+ case 'DELETE_NODE_RESULT':
1177
+ handleResult('DELETE_NODE', 'deleted');
1178
+ break;
1179
+ case 'RENAME_NODE_RESULT':
1180
+ handleResult('RENAME_NODE', 'node');
1181
+ break;
1182
+ case 'SET_TEXT_CONTENT_RESULT':
1183
+ handleResult('SET_TEXT_CONTENT', 'node');
1184
+ break;
1185
+ case 'CREATE_CHILD_NODE_RESULT':
1186
+ handleResult('CREATE_CHILD_NODE', 'node');
1187
+ break;
255
1188
 
256
- boot();
1189
+ // NEW: Screenshot and instance properties (visual validation loop fix)
1190
+ case 'CAPTURE_SCREENSHOT_RESULT':
1191
+ handleResult('CAPTURE_SCREENSHOT', 'image');
1192
+ break;
1193
+ case 'SET_IMAGE_FILL_RESULT':
1194
+ handleResult('SET_IMAGE_FILL', 'imageHash');
1195
+ break;
1196
+ case 'SET_INSTANCE_PROPERTIES_RESULT':
1197
+ handleResult('SET_INSTANCE_PROPERTIES', 'instance');
1198
+ break;
1199
+ case 'LINT_DESIGN_RESULT':
1200
+ handleResult('LINT_DESIGN', 'data');
1201
+ break;
1202
+
1203
+ // File info
1204
+ case 'GET_FILE_INFO_RESULT':
1205
+ handleResult('GET_FILE_INFO', 'fileInfo');
1206
+ break;
1207
+
1208
+ // Plugin UI reload
1209
+ case 'RELOAD_UI_RESULT':
1210
+ handleResult('RELOAD_UI', null);
1211
+ break;
1212
+
1213
+ // Document change events (for cache invalidation via WebSocket)
1214
+ case 'DOCUMENT_CHANGE':
1215
+ if (window.__wsForwardDocumentChange) window.__wsForwardDocumentChange(msg.data);
1216
+ break;
1217
+
1218
+ // Console capture events (for console monitoring via WebSocket)
1219
+ case 'CONSOLE_CAPTURE':
1220
+ if (window.__wsForwardConsoleCapture) window.__wsForwardConsoleCapture(msg);
1221
+ break;
1222
+
1223
+ // Selection change events (for selection tracking via WebSocket)
1224
+ case 'SELECTION_CHANGE':
1225
+ if (window.__wsForwardSelectionChange) window.__wsForwardSelectionChange(msg.data);
1226
+ break;
1227
+
1228
+ // Page change events (for page tracking via WebSocket)
1229
+ case 'PAGE_CHANGE':
1230
+ if (window.__wsForwardPageChange) window.__wsForwardPageChange(msg.data);
1231
+ break;
1232
+ }
1233
+ };
257
1234
  </script>
258
1235
  </body>
259
1236
  </html>