@atezer/figma-mcp-bridge 1.7.29 → 1.9.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +403 -0
  2. package/README.md +4 -3
  3. package/agents/_orchestrator-protocol.md +185 -0
  4. package/agents/ds-auditor.md +73 -22
  5. package/agents/screen-builder.md +60 -22
  6. package/agents/token-syncer.md +63 -19
  7. package/dist/core/code-warnings.d.ts +38 -0
  8. package/dist/core/code-warnings.d.ts.map +1 -0
  9. package/dist/core/code-warnings.js +191 -0
  10. package/dist/core/code-warnings.js.map +1 -0
  11. package/dist/core/device-presets.d.ts +49 -0
  12. package/dist/core/device-presets.d.ts.map +1 -0
  13. package/dist/core/device-presets.js +141 -0
  14. package/dist/core/device-presets.js.map +1 -0
  15. package/dist/core/instructions.d.ts +4 -2
  16. package/dist/core/instructions.d.ts.map +1 -1
  17. package/dist/core/instructions.js +239 -29
  18. package/dist/core/instructions.js.map +1 -1
  19. package/dist/core/plugin-bridge-connector.d.ts +32 -0
  20. package/dist/core/plugin-bridge-connector.d.ts.map +1 -1
  21. package/dist/core/plugin-bridge-connector.js +31 -2
  22. package/dist/core/plugin-bridge-connector.js.map +1 -1
  23. package/dist/core/plugin-bridge-server.d.ts +8 -0
  24. package/dist/core/plugin-bridge-server.d.ts.map +1 -1
  25. package/dist/core/plugin-bridge-server.js +27 -2
  26. package/dist/core/plugin-bridge-server.js.map +1 -1
  27. package/dist/core/response-guard.d.ts +23 -0
  28. package/dist/core/response-guard.d.ts.map +1 -1
  29. package/dist/core/response-guard.js +113 -0
  30. package/dist/core/response-guard.js.map +1 -1
  31. package/dist/core/version.d.ts +1 -1
  32. package/dist/core/version.d.ts.map +1 -1
  33. package/dist/core/version.js +1 -1
  34. package/dist/core/version.js.map +1 -1
  35. package/dist/local-plugin-only.d.ts.map +1 -1
  36. package/dist/local-plugin-only.js +504 -85
  37. package/dist/local-plugin-only.js.map +1 -1
  38. package/f-mcp-plugin/code.js +514 -29
  39. package/f-mcp-plugin/ui.html +62 -6
  40. package/package.json +1 -1
  41. package/skills/SKILL_INDEX.md +13 -1
  42. package/skills/apply-figma-design-system/SKILL.md +37 -0
  43. package/skills/audit-figma-design-system/SKILL.md +38 -0
  44. package/skills/code-design-mapper/SKILL.md +37 -0
  45. package/skills/design-token-pipeline/SKILL.md +44 -0
  46. package/skills/figma-canvas-ops/SKILL.md +200 -240
  47. package/skills/fmcp-ds-audit-orchestrator/SKILL.md +205 -0
  48. package/skills/fmcp-intent-router/SKILL.md +574 -0
  49. package/skills/fmcp-project-rules/SKILL.md +9 -5
  50. package/skills/fmcp-screen-orchestrator/SKILL.md +166 -0
  51. package/skills/fmcp-screen-recipes/SKILL.md +528 -0
  52. package/skills/fmcp-token-sync-orchestrator/SKILL.md +198 -0
  53. package/skills/generate-figma-library/SKILL.md +38 -0
  54. package/skills/generate-figma-screen/SKILL.md +382 -19
  55. package/skills/implement-design/SKILL.md +32 -0
  56. package/skills/inspiration-intake/SKILL.md +220 -0
  57. package/skills/visual-qa-compare/SKILL.md +33 -0
@@ -4,6 +4,10 @@
4
4
  // Uses postMessage to communicate with UI, bypassing worker sandbox limitations
5
5
  // Puppeteer can access UI iframe's window context to retrieve data
6
6
 
7
+ // v1.8.0+: Plugin version reported in WebSocket "ready" handshake.
8
+ // Keep in sync with package.json and src/core/version.ts.
9
+ var FMCP_PLUGIN_VERSION = '1.8.2';
10
+
7
11
  // Console log buffer for figma_get_console_logs (no CDP)
8
12
  var __consoleLogBuffer = [];
9
13
  var __consoleLogLimit = 200;
@@ -1091,19 +1095,22 @@ figma.ui.onmessage = async (msg) => {
1091
1095
  height: node.height
1092
1096
  };
1093
1097
 
1094
- // Get property definitions for non-variant components
1098
+ // Get property definitions for non-variant components (capped at MAX_PROPERTIES)
1095
1099
  if (!isPartOfSet && node.componentPropertyDefinitions) {
1096
1100
  data.properties = [];
1097
1101
  var propDefs = node.componentPropertyDefinitions;
1098
- for (var propName in propDefs) {
1099
- if (propDefs.hasOwnProperty(propName)) {
1100
- var propDef = propDefs[propName];
1101
- data.properties.push({
1102
- name: propName,
1103
- type: propDef.type,
1104
- defaultValue: propDef.defaultValue
1105
- });
1106
- }
1102
+ var propDefKeys = Object.keys(propDefs);
1103
+ if (propDefKeys.length > MAX_PROPERTIES) {
1104
+ propDefKeys = propDefKeys.slice(0, MAX_PROPERTIES);
1105
+ }
1106
+ for (var pi = 0; pi < propDefKeys.length; pi++) {
1107
+ var propName = propDefKeys[pi];
1108
+ var propDef = propDefs[propName];
1109
+ data.properties.push({
1110
+ name: propName,
1111
+ type: propDef.type,
1112
+ defaultValue: propDef.defaultValue
1113
+ });
1107
1114
  }
1108
1115
  }
1109
1116
 
@@ -1111,13 +1118,16 @@ figma.ui.onmessage = async (msg) => {
1111
1118
  }
1112
1119
 
1113
1120
  // Helper to extract component set data with all variants
1121
+ var MAX_VARIANTS = 50;
1114
1122
  function extractComponentSetData(node) {
1115
1123
  var variantAxes = {};
1116
1124
  var variants = [];
1125
+ var totalChildCount = node.children ? node.children.length : 0;
1117
1126
 
1118
- // Parse variant properties from children names
1119
- if (node.children) {
1120
- node.children.forEach(function(child) {
1127
+ // Parse variant properties from children names (capped at MAX_VARIANTS to prevent bridge timeout)
1128
+ var processChildren = (totalChildCount <= MAX_VARIANTS) ? node.children : (node.children ? node.children.slice(0, MAX_VARIANTS) : []);
1129
+ if (processChildren) {
1130
+ processChildren.forEach(function(child) {
1121
1131
  if (child.type === 'COMPONENT') {
1122
1132
  // Parse variant name (e.g., "Size=md, State=default")
1123
1133
  var variantProps = {};
@@ -1163,6 +1173,21 @@ figma.ui.onmessage = async (msg) => {
1163
1173
  }
1164
1174
  }
1165
1175
 
1176
+ var MAX_PROPERTIES = 100;
1177
+ var propList = [];
1178
+ if (node.componentPropertyDefinitions) {
1179
+ var allPropKeys = Object.keys(node.componentPropertyDefinitions);
1180
+ var propKeys = allPropKeys.length <= MAX_PROPERTIES ? allPropKeys : allPropKeys.slice(0, MAX_PROPERTIES);
1181
+ propList = propKeys.map(function(propName) {
1182
+ var propDef = node.componentPropertyDefinitions[propName];
1183
+ return {
1184
+ name: propName,
1185
+ type: propDef.type,
1186
+ defaultValue: propDef.defaultValue
1187
+ };
1188
+ });
1189
+ }
1190
+
1166
1191
  return {
1167
1192
  key: node.key,
1168
1193
  nodeId: node.id,
@@ -1172,14 +1197,9 @@ figma.ui.onmessage = async (msg) => {
1172
1197
  variantAxes: axes,
1173
1198
  variants: variants,
1174
1199
  defaultVariant: variants.length > 0 ? variants[0] : null,
1175
- properties: node.componentPropertyDefinitions ? Object.keys(node.componentPropertyDefinitions).map(function(propName) {
1176
- var propDef = node.componentPropertyDefinitions[propName];
1177
- return {
1178
- name: propName,
1179
- type: propDef.type,
1180
- defaultValue: propDef.defaultValue
1181
- };
1182
- }) : []
1200
+ properties: propList,
1201
+ _totalVariantCount: totalChildCount,
1202
+ _truncated: totalChildCount > MAX_VARIANTS
1183
1203
  };
1184
1204
  }
1185
1205
 
@@ -1304,7 +1324,10 @@ figma.ui.onmessage = async (msg) => {
1304
1324
  if (assetTypes.indexOf('components') >= 0) {
1305
1325
  var components = [];
1306
1326
  var componentSets = [];
1327
+ var libraryComponents = []; // v1.8.0+: discovered from existing instances
1328
+ var seenLibKeys = {}; // dedupe library component/set keys
1307
1329
  var hitLimit = false;
1330
+
1308
1331
  function extractComponentData(node, fromSet) {
1309
1332
  return {
1310
1333
  id: node.id,
@@ -1323,6 +1346,45 @@ figma.ui.onmessage = async (msg) => {
1323
1346
  variantCount: node.children ? node.children.length : 0
1324
1347
  };
1325
1348
  }
1349
+
1350
+ // v1.8.0: Library components discovered from INSTANCE nodes via mainComponent.key.
1351
+ // The Figma Plugin API does not expose a list-library-components endpoint,
1352
+ // so we walk existing instances in the file and collect their library keys.
1353
+ // This finds SUI/DS components that have been used at least once in the file.
1354
+ async function extractFromInstance(inst) {
1355
+ if (libraryComponents.length >= limit) return;
1356
+ try {
1357
+ var mc = inst.mainComponent || (inst.getMainComponentAsync ? await inst.getMainComponentAsync() : null);
1358
+ if (!mc) return;
1359
+ var isSet = mc.parent && mc.parent.type === 'COMPONENT_SET';
1360
+ var node = isSet ? mc.parent : mc;
1361
+ var key = node.key;
1362
+ if (!key || seenLibKeys[key]) return;
1363
+ // Filter: prefer remote (library) components — local file components are already
1364
+ // captured by the local-component scan below.
1365
+ var isRemote = !!node.remote;
1366
+ if (!isRemote) return;
1367
+ var nname = (node.name || '').toLowerCase();
1368
+ var ndesc = (node.description || '').toLowerCase();
1369
+ var match = !q || nname.indexOf(q) >= 0 || ndesc.indexOf(q) >= 0;
1370
+ if (!match) return;
1371
+ seenLibKeys[key] = true;
1372
+ libraryComponents.push({
1373
+ name: node.name,
1374
+ key: key,
1375
+ description: node.description || null,
1376
+ type: isSet ? 'COMPONENT_SET' : 'COMPONENT',
1377
+ variantCount: isSet && node.children ? node.children.length : null,
1378
+ libraryName: (node.documentationLinks && node.documentationLinks[0]) || null,
1379
+ source: 'instance-discovery',
1380
+ sampleInstanceId: inst.id,
1381
+ sampleVariantName: isSet ? mc.name : null
1382
+ });
1383
+ } catch (e) {
1384
+ // Some library components may not be importable — skip silently
1385
+ }
1386
+ }
1387
+
1326
1388
  async function processNodeList(nodes) {
1327
1389
  for (var i = 0; i < nodes.length && !hitLimit; i++) {
1328
1390
  if (components.length + componentSets.length >= limit) {
@@ -1344,12 +1406,23 @@ figma.ui.onmessage = async (msg) => {
1344
1406
  }
1345
1407
  }
1346
1408
  }
1409
+
1410
+ async function scanInstancesOnPage(page) {
1411
+ if (!page || !page.findAllWithCriteria) return;
1412
+ var instances = page.findAllWithCriteria({ types: ['INSTANCE'] }) || [];
1413
+ for (var i = 0; i < instances.length && libraryComponents.length < limit; i++) {
1414
+ await extractFromInstance(instances[i]);
1415
+ }
1416
+ }
1417
+
1347
1418
  if (currentPageOnly) {
1348
1419
  var page = figma.currentPage;
1349
1420
  if (page) {
1350
1421
  if (page.loadAsync) await page.loadAsync();
1351
1422
  var pn = page.findAllWithCriteria ? page.findAllWithCriteria({ types: ['COMPONENT', 'COMPONENT_SET'] }) : [];
1352
1423
  await processNodeList(pn);
1424
+ // v1.8.0: also scan instances on current page for library component keys
1425
+ await scanInstancesOnPage(page);
1353
1426
  }
1354
1427
  } else {
1355
1428
  await figma.loadAllPagesAsync();
@@ -1359,13 +1432,21 @@ figma.ui.onmessage = async (msg) => {
1359
1432
  if (pg && pg.loadAsync) await pg.loadAsync();
1360
1433
  var pageNodes = pg && pg.findAllWithCriteria ? pg.findAllWithCriteria({ types: ['COMPONENT', 'COMPONENT_SET'] }) : [];
1361
1434
  await processNodeList(pageNodes);
1435
+ // v1.8.0: scan instances across all pages
1436
+ await scanInstancesOnPage(pg);
1362
1437
  }
1363
1438
  }
1364
1439
  out.components = components;
1365
1440
  out.componentSets = componentSets;
1441
+ out.libraryComponents = libraryComponents; // v1.8.0+
1366
1442
  out.truncatedByLimit = hitLimit;
1367
1443
  out.currentPageOnly = currentPageOnly;
1368
- out.notes.push('Components are from the current file (local + published keys via component.key). Full remote catalog may require REST API.');
1444
+ if (libraryComponents.length > 0) {
1445
+ out.notes.push('libraryComponents: discovered ' + libraryComponents.length + ' remote library component(s) via existing instance scan. Use these keys with figma_instantiate_component.');
1446
+ } else if (assetTypes.indexOf('components') >= 0) {
1447
+ out.notes.push('No library components found via instance scan. Either no DS instances exist in this file yet, or all instances are local. Hint: place one DS instance manually first, then re-run.');
1448
+ }
1449
+ out.notes.push('Components are from the current file (local + published keys via component.key).');
1369
1450
  }
1370
1451
 
1371
1452
  figma.ui.postMessage({
@@ -2105,6 +2186,399 @@ figma.ui.onmessage = async (msg) => {
2105
2186
  }
2106
2187
  }
2107
2188
 
2189
+ // ============================================================================
2190
+ // CLONE_SCREEN_TO_DEVICE (v1.8.1+) - HIGH-LEVEL: Clone screen and adapt to device
2191
+ //
2192
+ // Clones the source node, preserves library instances + bound variables,
2193
+ // resizes the root to target dimensions (handling auto-layout correctly),
2194
+ // positions the clone (auto or explicit), and returns the new node ID.
2195
+ // ============================================================================
2196
+ else if (msg.type === 'CLONE_SCREEN_TO_DEVICE') {
2197
+ // v1.8.2: Track clonedNode outside try so cleanup can run on error
2198
+ var clonedNode = null;
2199
+ try {
2200
+ console.log('🌉 [F-MCP] Clone screen to device:', msg.sourceNodeId, '→', msg.targetDeviceName);
2201
+
2202
+ var sourceNode = await figma.getNodeByIdAsync(msg.sourceNodeId);
2203
+ if (!sourceNode) throw new Error('Source node not found: ' + msg.sourceNodeId);
2204
+ if (!('clone' in sourceNode)) {
2205
+ throw new Error('Source node type ' + sourceNode.type + ' does not support cloning');
2206
+ }
2207
+
2208
+ // v1.8.2: Warn if source is too large (will likely timeout)
2209
+ var sourceChildCount = 0;
2210
+ if ('children' in sourceNode && sourceNode.children) {
2211
+ sourceChildCount = sourceNode.children.length;
2212
+ }
2213
+ if (sourceChildCount > 20) {
2214
+ console.warn('🌉 [F-MCP] Source has ' + sourceChildCount + ' children — clone may be slow.');
2215
+ }
2216
+
2217
+ // Ensure the page containing the source is loaded (dynamic-page mode)
2218
+ var sourcePage = sourceNode;
2219
+ while (sourcePage.parent && sourcePage.parent.type !== 'PAGE' && sourcePage.parent.type !== 'DOCUMENT') {
2220
+ sourcePage = sourcePage.parent;
2221
+ }
2222
+ if (sourcePage.parent && sourcePage.parent.type === 'PAGE' && sourcePage.parent.loadAsync) {
2223
+ await sourcePage.parent.loadAsync();
2224
+ }
2225
+
2226
+ // Clone — Figma auto-places at (original.x+10, original.y+10) in same parent
2227
+ clonedNode = sourceNode.clone();
2228
+ console.log('🌉 [F-MCP] Cloned:', clonedNode.id);
2229
+
2230
+ // Reparent if requested
2231
+ if (msg.targetParentId) {
2232
+ var targetParent = await figma.getNodeByIdAsync(msg.targetParentId);
2233
+ if (targetParent && 'appendChild' in targetParent) {
2234
+ targetParent.appendChild(clonedNode);
2235
+ } else {
2236
+ console.warn('🌉 [F-MCP] Target parent not found or cannot append:', msg.targetParentId);
2237
+ }
2238
+ }
2239
+
2240
+ // Explicit position — or auto-place to the right of the source
2241
+ if (msg.position) {
2242
+ clonedNode.x = msg.position.x;
2243
+ clonedNode.y = msg.position.y;
2244
+ } else {
2245
+ // Auto: place to the right of source + 100px gap
2246
+ if ('x' in sourceNode && 'width' in sourceNode) {
2247
+ clonedNode.x = sourceNode.x + sourceNode.width + 100;
2248
+ clonedNode.y = sourceNode.y;
2249
+ }
2250
+ }
2251
+
2252
+ // Resize to target device dimensions
2253
+ // For auto-layout frames, we must switch sizingMode to FIXED first,
2254
+ // otherwise resize() is a no-op because the frame "hugs" its content.
2255
+ var wasHuggingX = false;
2256
+ var wasHuggingY = false;
2257
+ if ('layoutMode' in clonedNode && clonedNode.layoutMode !== 'NONE') {
2258
+ if (clonedNode.primaryAxisSizingMode === 'AUTO') {
2259
+ wasHuggingX = clonedNode.layoutMode === 'HORIZONTAL';
2260
+ wasHuggingY = clonedNode.layoutMode === 'VERTICAL';
2261
+ clonedNode.primaryAxisSizingMode = 'FIXED';
2262
+ }
2263
+ if (clonedNode.counterAxisSizingMode === 'AUTO') {
2264
+ if (clonedNode.layoutMode === 'HORIZONTAL') wasHuggingY = true;
2265
+ if (clonedNode.layoutMode === 'VERTICAL') wasHuggingX = true;
2266
+ clonedNode.counterAxisSizingMode = 'FIXED';
2267
+ }
2268
+ }
2269
+
2270
+ if ('resize' in clonedNode) {
2271
+ try {
2272
+ clonedNode.resize(msg.targetWidth, msg.targetHeight);
2273
+ } catch (resizeErr) {
2274
+ console.warn('🌉 [F-MCP] Resize error (may be a constraint):', resizeErr.message);
2275
+ }
2276
+ }
2277
+
2278
+ // Name the clone
2279
+ if (msg.newName) {
2280
+ clonedNode.name = msg.newName;
2281
+ } else if ('name' in clonedNode) {
2282
+ clonedNode.name = sourceNode.name + ' — ' + msg.targetDeviceName;
2283
+ }
2284
+
2285
+ // Count preserved elements for reporting
2286
+ var instanceCount = 0;
2287
+ var libraryInstanceCount = 0;
2288
+ var boundVarCount = 0;
2289
+ if ('findAllWithCriteria' in clonedNode) {
2290
+ try {
2291
+ var instances = clonedNode.findAllWithCriteria({ types: ['INSTANCE'] }) || [];
2292
+ instanceCount = instances.length;
2293
+ for (var i = 0; i < instances.length; i++) {
2294
+ var inst = instances[i];
2295
+ try {
2296
+ var mc = inst.mainComponent || (inst.getMainComponentAsync ? await inst.getMainComponentAsync() : null);
2297
+ var remote = mc && (mc.remote || (mc.parent && mc.parent.remote));
2298
+ if (remote) libraryInstanceCount++;
2299
+ } catch (e) { /* skip */ }
2300
+ }
2301
+ } catch (e) { /* skip */ }
2302
+ }
2303
+ // Walk the tree counting boundVariables (iterative, stack-safe)
2304
+ var stack = [clonedNode];
2305
+ var totalNodes = 0;
2306
+ while (stack.length > 0) {
2307
+ var n = stack.pop();
2308
+ totalNodes++;
2309
+ if (n && n.boundVariables && Object.keys(n.boundVariables).length > 0) {
2310
+ boundVarCount++;
2311
+ }
2312
+ if (n && n.children) {
2313
+ for (var ci = 0; ci < n.children.length; ci++) stack.push(n.children[ci]);
2314
+ }
2315
+ }
2316
+
2317
+ figma.ui.postMessage({
2318
+ type: 'CLONE_SCREEN_TO_DEVICE_RESULT',
2319
+ requestId: msg.requestId,
2320
+ success: true,
2321
+ clone: {
2322
+ id: clonedNode.id,
2323
+ name: clonedNode.name,
2324
+ x: clonedNode.x,
2325
+ y: clonedNode.y,
2326
+ width: clonedNode.width,
2327
+ height: clonedNode.height,
2328
+ layoutMode: clonedNode.layoutMode || 'NONE',
2329
+ },
2330
+ source: {
2331
+ id: sourceNode.id,
2332
+ name: sourceNode.name,
2333
+ },
2334
+ targetDevice: {
2335
+ name: msg.targetDeviceName,
2336
+ width: msg.targetWidth,
2337
+ height: msg.targetHeight,
2338
+ },
2339
+ preserved: {
2340
+ totalNodes: totalNodes,
2341
+ instanceCount: instanceCount,
2342
+ libraryInstanceCount: libraryInstanceCount,
2343
+ boundVariableCount: boundVarCount,
2344
+ },
2345
+ });
2346
+ } catch (error) {
2347
+ var errorMsg = error && error.message ? error.message : String(error);
2348
+ console.error('🌉 [F-MCP] Clone to device error:', errorMsg);
2349
+
2350
+ // v1.8.2: Orphan cleanup — remove half-finished clone to prevent duplicates
2351
+ var cleanedUp = false;
2352
+ if (clonedNode) {
2353
+ try {
2354
+ clonedNode.remove();
2355
+ cleanedUp = true;
2356
+ console.log('🌉 [F-MCP] Cleaned up orphan clone after error');
2357
+ } catch (cleanupErr) {
2358
+ console.warn('🌉 [F-MCP] Orphan cleanup failed:', cleanupErr.message);
2359
+ }
2360
+ }
2361
+
2362
+ figma.ui.postMessage({
2363
+ type: 'CLONE_SCREEN_TO_DEVICE_RESULT',
2364
+ requestId: msg.requestId,
2365
+ success: false,
2366
+ error: errorMsg,
2367
+ orphanCleanedUp: cleanedUp,
2368
+ });
2369
+ }
2370
+ }
2371
+
2372
+ // ============================================================================
2373
+ // VALIDATE_SCREEN (v1.8.1+) - DS compliance audit
2374
+ //
2375
+ // Walks a node tree (iterative, stack-safe) and computes 3 metrics:
2376
+ // - instanceCoverage — % of nodes that are library instances
2377
+ // - autoLayoutCoverage — % of frames with layoutMode != NONE
2378
+ // - tokenBindingCoverage — % of stylable nodes with boundVariables
2379
+ // Returns aggregate score (weighted 40/30/30) + violations list.
2380
+ // Read-only — does not mutate the file.
2381
+ // ============================================================================
2382
+ else if (msg.type === 'VALIDATE_SCREEN') {
2383
+ try {
2384
+ console.log('🌉 [F-MCP] Validate screen:', msg.nodeId);
2385
+
2386
+ var rootNode = await figma.getNodeByIdAsync(msg.nodeId);
2387
+ if (!rootNode) throw new Error('Node not found: ' + msg.nodeId);
2388
+
2389
+ // v1.8.2 OPTIMIZATION: Two-pass validation to avoid serial await on getMainComponentAsync
2390
+ // Pass 1: iterative walk, collect node tree + instance list (no async calls)
2391
+ // Pass 2: Promise.all on getMainComponentAsync for all instances in parallel
2392
+ // This cuts 100+ node validation from ~60s to ~5s.
2393
+
2394
+ // Iterative tree walk (stack-safe for deep trees up to 10,000+ nodes)
2395
+ var metrics = {
2396
+ totalNodes: 0,
2397
+ frameNodes: 0,
2398
+ autoLayoutFrames: 0,
2399
+ instanceNodes: 0,
2400
+ libraryInstanceNodes: 0,
2401
+ stylableNodes: 0,
2402
+ nodesWithBindings: 0,
2403
+ };
2404
+ var violations = [];
2405
+ var maxViolations = 20; // cap to keep response small
2406
+ var instanceNodesList = []; // v1.8.2: collect for batch resolve
2407
+
2408
+ var walkStack = [rootNode];
2409
+ var visited = 0;
2410
+ var maxVisit = 5000; // safety cap
2411
+
2412
+ while (walkStack.length > 0 && visited < maxVisit) {
2413
+ var node = walkStack.pop();
2414
+ visited++;
2415
+ metrics.totalNodes++;
2416
+
2417
+ // Instance check — DEFER async resolve to Pass 2
2418
+ if (node.type === 'INSTANCE') {
2419
+ metrics.instanceNodes++;
2420
+ // Try sync mainComponent first (works in most cases, free)
2421
+ try {
2422
+ var syncMc = node.mainComponent;
2423
+ if (syncMc) {
2424
+ var syncIsRemote = syncMc.remote || (syncMc.parent && syncMc.parent.remote);
2425
+ if (syncIsRemote) metrics.libraryInstanceNodes++;
2426
+ } else {
2427
+ // Defer to Pass 2 if sync access unavailable
2428
+ instanceNodesList.push(node);
2429
+ }
2430
+ } catch (e) {
2431
+ instanceNodesList.push(node);
2432
+ }
2433
+ }
2434
+
2435
+ // Frame auto-layout check
2436
+ if (node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'COMPONENT_SET' || node.type === 'INSTANCE') {
2437
+ metrics.frameNodes++;
2438
+ if ('layoutMode' in node && node.layoutMode && node.layoutMode !== 'NONE') {
2439
+ metrics.autoLayoutFrames++;
2440
+ } else if ((node.type === 'FRAME' || node.type === 'COMPONENT') && violations.length < maxViolations) {
2441
+ violations.push({
2442
+ nodeId: node.id,
2443
+ nodeName: node.name,
2444
+ category: 'NO_AUTO_LAYOUT',
2445
+ severity: 'HIGH',
2446
+ message: "Frame '" + (node.name || node.id) + "' has no auto-layout (layoutMode=NONE). Not responsive.",
2447
+ });
2448
+ }
2449
+ }
2450
+
2451
+ // Token binding check (any node with fills/strokes/effects that can have bound variables)
2452
+ if ('fills' in node || 'strokes' in node || 'effects' in node) {
2453
+ metrics.stylableNodes++;
2454
+ var hasBindings = false;
2455
+ try {
2456
+ if (node.boundVariables && Object.keys(node.boundVariables).length > 0) {
2457
+ hasBindings = true;
2458
+ }
2459
+ } catch (e) { /* skip */ }
2460
+ if (hasBindings) {
2461
+ metrics.nodesWithBindings++;
2462
+ } else if (node.type !== 'INSTANCE' && violations.length < maxViolations) {
2463
+ // Skip instances — their bindings come from the main component
2464
+ var hasFills = false;
2465
+ try {
2466
+ if (Array.isArray(node.fills) && node.fills.length > 0 && node.fills[0].type !== 'IMAGE') {
2467
+ hasFills = true;
2468
+ }
2469
+ } catch (e) { /* skip */ }
2470
+ if (hasFills) {
2471
+ violations.push({
2472
+ nodeId: node.id,
2473
+ nodeName: node.name,
2474
+ category: 'HARDCODED_FILL',
2475
+ severity: 'HIGH',
2476
+ message: "Node '" + (node.name || node.id) + "' has hardcoded fills (no boundVariables). Bind to DS token.",
2477
+ });
2478
+ }
2479
+ }
2480
+ }
2481
+
2482
+ // Push children
2483
+ if (node.children) {
2484
+ for (var ci = 0; ci < node.children.length; ci++) {
2485
+ walkStack.push(node.children[ci]);
2486
+ }
2487
+ }
2488
+ }
2489
+
2490
+ // v1.8.2 Pass 2: Batch-resolve deferred instance mainComponents via Promise.all
2491
+ // This is ~10x faster than serial await in the walk loop above
2492
+ if (instanceNodesList.length > 0 && typeof Promise.all === 'function') {
2493
+ try {
2494
+ var mcPromises = instanceNodesList.map(function (inst) {
2495
+ if (inst.getMainComponentAsync) {
2496
+ return inst.getMainComponentAsync().catch(function () { return null; });
2497
+ }
2498
+ return Promise.resolve(null);
2499
+ });
2500
+ var mcResults = await Promise.all(mcPromises);
2501
+ for (var mi = 0; mi < mcResults.length; mi++) {
2502
+ var mc = mcResults[mi];
2503
+ if (mc) {
2504
+ var isRemote = mc.remote || (mc.parent && mc.parent.remote);
2505
+ if (isRemote) metrics.libraryInstanceNodes++;
2506
+ }
2507
+ }
2508
+ } catch (batchErr) {
2509
+ console.warn('🌉 [F-MCP] Pass 2 batch mainComponent resolve failed:', batchErr.message);
2510
+ }
2511
+ }
2512
+
2513
+ // Compute scores (percentages)
2514
+ var instanceCoverage = metrics.totalNodes > 0
2515
+ ? Math.round((metrics.libraryInstanceNodes / Math.max(1, metrics.totalNodes)) * 100)
2516
+ : 0;
2517
+ var autoLayoutCoverage = metrics.frameNodes > 0
2518
+ ? Math.round((metrics.autoLayoutFrames / metrics.frameNodes) * 100)
2519
+ : 100;
2520
+ var tokenBindingCoverage = metrics.stylableNodes > 0
2521
+ ? Math.round((metrics.nodesWithBindings / metrics.stylableNodes) * 100)
2522
+ : 100;
2523
+
2524
+ // Weighted aggregate (instance 40%, binding 30%, layout 30%)
2525
+ // Note: instanceCoverage is vs total nodes which is lenient — library components
2526
+ // contain many internal nodes that aren't instances themselves. We cap the
2527
+ // library instance ratio so single instances don't drag the score too low.
2528
+ // If at least 1 library instance exists, give partial credit up to 70 for this axis.
2529
+ var normalizedInstanceScore = metrics.libraryInstanceNodes === 0
2530
+ ? 0
2531
+ : Math.min(100, Math.max(30, instanceCoverage * 3));
2532
+ var score = Math.round(
2533
+ (normalizedInstanceScore * 0.4) +
2534
+ (tokenBindingCoverage * 0.3) +
2535
+ (autoLayoutCoverage * 0.3)
2536
+ );
2537
+
2538
+ var minScore = typeof msg.minScore === 'number' ? msg.minScore : 80;
2539
+ var passed = score >= minScore;
2540
+
2541
+ var recommendation = passed
2542
+ ? 'Score ' + score + '/100 passed minimum ' + minScore + '. Screen is DS-compliant.'
2543
+ : 'Score ' + score + '/100 is below minimum ' + minScore + '. Consider deleting this screen and rebuilding using ' +
2544
+ 'DS components (figma_instantiate_component) and token bindings (figma_bind_variable).';
2545
+
2546
+ figma.ui.postMessage({
2547
+ type: 'VALIDATE_SCREEN_RESULT',
2548
+ requestId: msg.requestId,
2549
+ success: true,
2550
+ score: score,
2551
+ passed: passed,
2552
+ minScore: minScore,
2553
+ breakdown: {
2554
+ instanceCoverage: instanceCoverage,
2555
+ libraryInstanceCount: metrics.libraryInstanceNodes,
2556
+ totalInstances: metrics.instanceNodes,
2557
+ autoLayoutCoverage: autoLayoutCoverage,
2558
+ autoLayoutFrames: metrics.autoLayoutFrames,
2559
+ totalFrames: metrics.frameNodes,
2560
+ tokenBindingCoverage: tokenBindingCoverage,
2561
+ nodesWithBindings: metrics.nodesWithBindings,
2562
+ totalStylable: metrics.stylableNodes,
2563
+ },
2564
+ violations: violations,
2565
+ violationCount: violations.length,
2566
+ violationCapped: violations.length >= maxViolations,
2567
+ totalNodesWalked: visited,
2568
+ recommendation: recommendation,
2569
+ });
2570
+ } catch (error) {
2571
+ var errorMsg = error && error.message ? error.message : String(error);
2572
+ console.error('🌉 [F-MCP] Validate screen error:', errorMsg);
2573
+ figma.ui.postMessage({
2574
+ type: 'VALIDATE_SCREEN_RESULT',
2575
+ requestId: msg.requestId,
2576
+ success: false,
2577
+ error: errorMsg,
2578
+ });
2579
+ }
2580
+ }
2581
+
2108
2582
  // ============================================================================
2109
2583
  // DELETE_NODE - Delete a node
2110
2584
  // ============================================================================
@@ -2353,14 +2827,21 @@ figma.ui.onmessage = async (msg) => {
2353
2827
  throw new Error('Node type ' + node.type + ' does not support export');
2354
2828
  }
2355
2829
 
2356
- // Configure export settings
2357
- var format = msg.format || 'PNG';
2358
- var scale = msg.scale || 2;
2830
+ // v1.8.0: Default JPG@1x q70 for context safety. Plugin reads msg.format,
2831
+ // msg.scale, msg.jpegQuality from the request payload (passed through
2832
+ // captureScreenshot connector bridge → here).
2833
+ var format = (msg.format || 'JPG').toUpperCase();
2834
+ var scale = msg.scale != null ? msg.scale : 1;
2835
+ var jpegQuality = msg.jpegQuality != null ? msg.jpegQuality : 70;
2359
2836
 
2360
2837
  var exportSettings = {
2361
2838
  format: format,
2362
2839
  constraint: { type: 'SCALE', value: scale }
2363
2840
  };
2841
+ // JPG quality (Figma Plugin API supports 'quality' for ExportSettingsImage)
2842
+ if (format === 'JPG') {
2843
+ exportSettings.quality = jpegQuality;
2844
+ }
2364
2845
 
2365
2846
  // Export the node
2366
2847
  var bytes = await node.exportAsync(exportSettings);
@@ -2374,7 +2855,7 @@ figma.ui.onmessage = async (msg) => {
2374
2855
  bounds = node.absoluteBoundingBox;
2375
2856
  }
2376
2857
 
2377
- console.log('🌉 [F-MCP ATezer Bridge] Screenshot captured:', bytes.length, 'bytes');
2858
+ console.log('🌉 [F-MCP ATezer Bridge] Screenshot captured:', bytes.length, 'bytes (' + format + ' @' + scale + 'x)');
2378
2859
 
2379
2860
  figma.ui.postMessage({
2380
2861
  type: 'CAPTURE_SCREENSHOT_RESULT',
@@ -2384,6 +2865,7 @@ figma.ui.onmessage = async (msg) => {
2384
2865
  base64: base64,
2385
2866
  format: format,
2386
2867
  scale: scale,
2868
+ jpegQuality: format === 'JPG' ? jpegQuality : null,
2387
2869
  byteLength: bytes.length,
2388
2870
  node: {
2389
2871
  id: node.id,
@@ -2584,7 +3066,8 @@ figma.ui.onmessage = async (msg) => {
2584
3066
  try {
2585
3067
  await figma.loadAllPagesAsync();
2586
3068
 
2587
- var depth = Math.min(Math.max(msg.depth || 1, 0), 3);
3069
+ // v1.8.0: Use != null instead of || so depth=0 is preserved (|| would rewrite 0 to default).
3070
+ var depth = Math.min(Math.max(msg.depth != null ? msg.depth : 1, 0), 3);
2588
3071
  var verbosity = msg.verbosity || 'summary';
2589
3072
  var opts = {
2590
3073
  verbosity: verbosity,
@@ -2643,8 +3126,10 @@ figma.ui.onmessage = async (msg) => {
2643
3126
  error: 'Node not found: ' + nodeId
2644
3127
  });
2645
3128
  } else {
2646
- var depthNode = Math.min(Math.max(msg.depth || 2, 0), 3);
2647
- var verbosityNode = msg.verbosity || 'standard';
3129
+ // v1.8.0: Context-safe defaults — depth=1, verbosity="summary" (was depth=2, verbosity="standard").
3130
+ // Use != null instead of || so depth=0 is preserved.
3131
+ var depthNode = Math.min(Math.max(msg.depth != null ? msg.depth : 1, 0), 3);
3132
+ var verbosityNode = msg.verbosity || 'summary';
2648
3133
  var optsNode = {
2649
3134
  verbosity: verbosityNode,
2650
3135
  includeLayout: msg.includeLayout === true,