@atezer/figma-mcp-bridge 1.7.30 → 1.9.1
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.
- package/CHANGELOG.md +408 -0
- package/README.md +8 -8
- package/agents/_orchestrator-protocol.md +185 -0
- package/agents/ds-auditor.md +73 -22
- package/agents/screen-builder.md +60 -22
- package/agents/token-syncer.md +63 -19
- package/dist/core/code-warnings.d.ts +38 -0
- package/dist/core/code-warnings.d.ts.map +1 -0
- package/dist/core/code-warnings.js +191 -0
- package/dist/core/code-warnings.js.map +1 -0
- package/dist/core/device-presets.d.ts +49 -0
- package/dist/core/device-presets.d.ts.map +1 -0
- package/dist/core/device-presets.js +141 -0
- package/dist/core/device-presets.js.map +1 -0
- package/dist/core/instructions.d.ts +4 -2
- package/dist/core/instructions.d.ts.map +1 -1
- package/dist/core/instructions.js +239 -29
- package/dist/core/instructions.js.map +1 -1
- package/dist/core/plugin-bridge-connector.d.ts +26 -0
- package/dist/core/plugin-bridge-connector.d.ts.map +1 -1
- package/dist/core/plugin-bridge-connector.js +18 -2
- package/dist/core/plugin-bridge-connector.js.map +1 -1
- package/dist/core/plugin-bridge-server.d.ts +16 -0
- package/dist/core/plugin-bridge-server.d.ts.map +1 -1
- package/dist/core/plugin-bridge-server.js +83 -1
- package/dist/core/plugin-bridge-server.js.map +1 -1
- package/dist/core/response-guard.d.ts +23 -0
- package/dist/core/response-guard.d.ts.map +1 -1
- package/dist/core/response-guard.js +113 -0
- package/dist/core/response-guard.js.map +1 -1
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.d.ts.map +1 -1
- package/dist/core/version.js +1 -1
- package/dist/core/version.js.map +1 -1
- package/dist/local-plugin-only.d.ts.map +1 -1
- package/dist/local-plugin-only.js +334 -101
- package/dist/local-plugin-only.js.map +1 -1
- package/f-mcp-plugin/code.js +514 -29
- package/f-mcp-plugin/ui.html +90 -14
- package/package.json +1 -1
- package/skills/SKILL_INDEX.md +13 -1
- package/skills/apply-figma-design-system/SKILL.md +37 -0
- package/skills/audit-figma-design-system/SKILL.md +38 -0
- package/skills/code-design-mapper/SKILL.md +37 -0
- package/skills/design-token-pipeline/SKILL.md +44 -0
- package/skills/figma-canvas-ops/SKILL.md +200 -243
- package/skills/fmcp-ds-audit-orchestrator/SKILL.md +205 -0
- package/skills/fmcp-intent-router/SKILL.md +574 -0
- package/skills/fmcp-screen-orchestrator/SKILL.md +166 -0
- package/skills/fmcp-screen-recipes/SKILL.md +528 -0
- package/skills/fmcp-token-sync-orchestrator/SKILL.md +198 -0
- package/skills/generate-figma-library/SKILL.md +38 -0
- package/skills/generate-figma-screen/SKILL.md +360 -6
- package/skills/implement-design/SKILL.md +32 -0
- package/skills/inspiration-intake/SKILL.md +220 -0
- package/skills/visual-qa-compare/SKILL.md +33 -0
package/f-mcp-plugin/code.js
CHANGED
|
@@ -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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
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:
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2357
|
-
|
|
2358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2647
|
-
|
|
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,
|