figma-relai 0.5.5 → 0.6.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.
@@ -15,7 +15,7 @@ function createServer() {
15
15
  return new McpServer(
16
16
  {
17
17
  name: "Relai",
18
- version: "0.5.5"
18
+ version: "0.6.1"
19
19
  },
20
20
  {
21
21
  instructions: `
@@ -162,6 +162,9 @@ var FIGMA_COMMANDS = [
162
162
  "audit_voice_drift",
163
163
  "audit_readiness",
164
164
  "audit_ghosts",
165
+ "audit_token_debt",
166
+ "audit_property_bindings",
167
+ "audit_node_tokens",
165
168
  "snapshot_variables",
166
169
  "list_rulesets",
167
170
  "save_ruleset",
@@ -184,6 +187,8 @@ var FIGMA_COMMANDS = [
184
187
  "create_component_set",
185
188
  "get_component_properties",
186
189
  "set_component_properties",
190
+ "add_component_property",
191
+ "bind_component_property",
187
192
  "detach_instance",
188
193
  "reset_instance",
189
194
  "audit_colors",
@@ -445,6 +450,31 @@ var PITFALLS = [
445
450
  hint: "Some layout properties are read-only and have an imperative setter instead. Grid children: gridRowAnchorIndex/gridColumnAnchorIndex are readonly \u2014 call node.setGridChildPosition(row, column). Grid tracks: gridColumnSizes/gridRowSizes cannot be assigned as arrays, but gridColumnCount/gridRowCount are writable and the returned GridTrackSize objects have their own .type/.value setters.",
446
451
  doc: "**GRID auto-layout has read-only positions and imperative setters.** `gridRowAnchorIndex` / `gridColumnAnchorIndex` are readonly \u2014 move a child with `node.setGridChildPosition(row, column)` (spans stay writable: `gridColumnSpan`). `gridColumnSizes` / `gridRowSizes` reject array assignment (`no setter for property`), but `gridColumnCount` / `gridRowCount` are writable and each returned `GridTrackSize` has `.type` / `.value` setters \u2014 so add a track by raising the count, then set the new track's type. Shrinking the row count is how you drop a leftover empty row."
447
452
  },
453
+ {
454
+ pattern: "loadAllPagesAsync",
455
+ hint: "Document-wide search needs every page in memory first: await figma.loadAllPagesAsync() before figma.root.findAll (expensive on big files \u2014 prefer scoping to figma.currentPage, or await page.loadAsync() on the one page you mean).",
456
+ doc: "**`figma.root.findAll` throws in dynamic-page mode until you `await figma.loadAllPagesAsync()`.** Page-scoped work never needs it: `figma.currentPage` is already loaded and `relai.query` searches from a node you pass. Load the whole document only when the search genuinely spans pages \u2014 on a 40-page file it is seconds of work and memory."
457
+ },
458
+ {
459
+ pattern: "getMainComponentAsync",
460
+ hint: "instance.mainComponent is a sync read and dynamic-page mode blocks it \u2014 await instance.getMainComponentAsync() instead (same for mainComponent.parent: await, then read).",
461
+ doc: "**`instance.mainComponent` throws in dynamic-page mode** \u2014 `await instance.getMainComponentAsync()`. The same rule catches every sync sibling of an async API (`getNodeById`, `textStyleId =`, `page.children` on an unloaded page): when the message says *use X instead*, it means the whole traversal from that point is async."
462
+ },
463
+ {
464
+ pattern: "component property definitions",
465
+ hint: "Component properties live on the SET, not on a variant: read node.parent.componentPropertyDefinitions when node.type === 'COMPONENT' and its parent is a COMPONENT_SET. Relai's get_component_properties does this redirect for you.",
466
+ doc: "**A variant has no component property definitions of its own** (`Can only get component property definitions of a component set or non-variant component`). They belong to the COMPONENT_SET \u2014 read `node.parent` when a variant is what you have, and add new TEXT/BOOLEAN properties to the set too: adding them to a single variant either fails or leaves the other variants without them."
467
+ },
468
+ {
469
+ pattern: null,
470
+ hint: "",
471
+ doc: "**`SPACE_BETWEEN` centres a lone child.** A header built as label + value with the value hidden by default looks correct in the variant you were staring at and lands centred everywhere else \u2014 because Figma spreads a single item to the middle rather than leaving it at the start. When one side is optional, align the row `MIN` and let the first child FILL: the optional one stays pinned to the far edge when it appears, and the other stays put when it does not."
472
+ },
473
+ {
474
+ pattern: null,
475
+ hint: "",
476
+ doc: "**Applying a text style changes the node's font, so a `characters` write right after it can throw for a font nobody loaded.** `await node.setTextStyleIdAsync(id)` may swap the family or weight; load the *new* font (`await figma.loadFontAsync(node.fontName)`) before writing text. Applying the style is also the last word on properties it owns \u2014 a `textDecoration` written in the same breath is overwritten by the style landing, so set it in a later call."
477
+ },
448
478
  {
449
479
  pattern: null,
450
480
  hint: "",
@@ -455,6 +485,16 @@ var PITFALLS = [
455
485
  hint: "",
456
486
  doc: "**Writing `leadingTrim` on a text node detaches its text style** and strands the style's typography variables as node-level bindings \u2014 the node keeps rendering but no longer follows the style. `textDecoration` is safe (the style survives); `leadingTrim` is not. When a cap-trimmed label needs to sit in a full line box (so an icon beside it doesn't hang below the baseline, and toggling the icon doesn't change the height), leave the trim alone and bind the PARENT frame's height to the same leading variable the style uses."
457
487
  },
488
+ {
489
+ pattern: null,
490
+ hint: "",
491
+ doc: "**A variable bound to a component property's VALUE hides from `boundVariables`.** `node.boundVariables.componentProperties` lists the VARIANT properties and nothing else \u2014 a token bound to a TEXT or INSTANCE_SWAP property (an icon glyph name, a swapped instance) lives only in `instance.componentProperties[key].boundVariables.value`. Read one location and every such token looks unreferenced. Bindings authored as a property's *default* sit in a third place, `componentPropertyDefinitions[key].boundVariables`, on the SET."
492
+ },
493
+ {
494
+ pattern: "Plugin runtime aborted",
495
+ hint: "Out of memory, not a timeout: something in the loop deep-wraps a large object per node. componentProperties is the usual culprit \u2014 read it in slices of ~1000 nodes per command instead of walking the whole file in one script.",
496
+ doc: '**"Plugin runtime aborted" is out of memory, not a timeout**, and reading `instance.componentProperties` in bulk is the fastest way there: the getter deep-wraps every property object, so 1,000 instances costs about 4 seconds and 5,700 kills the sandbox. Memory is only reclaimed when the command returns, so the fix is slices \u2014 walk a fixed number of nodes per command and have the caller ask again \u2014 not a tidier traversal inside one script.'
497
+ },
458
498
  {
459
499
  pattern: null,
460
500
  hint: "",
@@ -808,7 +848,9 @@ function register(server, sendCommand) {
808
848
  fontSize: z2.number().positive().optional().describe("text only"),
809
849
  fontWeight: z2.number().optional().describe("text only (e.g. 400, 700)"),
810
850
  fontColor: colorSchema.optional().describe("text only"),
811
- fillColor: colorSchema.optional().describe("frame/rectangle fill"),
851
+ fillColor: colorSchema.optional().describe("frame/rectangle/section fill"),
852
+ strokeColor: colorSchema.optional().describe("frame/rectangle/section stroke"),
853
+ cornerRadius: z2.number().min(0).optional().describe("section corner radius \u2014 a new section defaults to radius 2 with a 10% black stroke, which matches no design system; pass what yours uses (for other node types, set radius with set_properties)"),
812
854
  pointCount: z2.number().int().min(3).optional().describe("polygon/star points"),
813
855
  innerRadius: z2.number().min(0).max(1).optional().describe("star inner radius ratio"),
814
856
  length: z2.number().positive().optional().describe("line length"),
@@ -1186,7 +1228,7 @@ import { z as z6 } from "zod";
1186
1228
  function register5(server, sendCommand) {
1187
1229
  server.tool(
1188
1230
  "manage_components",
1189
- "Component workflow: list local components, create a component from a node, create_set (combine components as variants), instantiate (place an instance by componentKey \u2014 imports from the team library if needed), get_props / set_props (component properties on an instance), get_overrides / set_overrides (copy overrides from a source instance to targets), reset_instance (clear ALL overrides so the instance re-inherits its main component \u2014 returns property snapshots before/after so you can re-apply what mattered), detach.",
1231
+ "Component workflow: list local components, create a component from a node, create_set (combine components as variants), instantiate (place an instance by componentKey OR by the node id of a local component \u2014 an unpublished component has no importable key, so the id is the way), add_property / bind_property (declare a TEXT/BOOLEAN/INSTANCE_SWAP property on a SET \u2014 never on a single variant \u2014 then point a layer's characters/visible at it, in every variant at once with layerName), get_props / set_props (component properties on an instance; get_props on a variant answers with its set's definitions), get_overrides / set_overrides (copy overrides from a source instance to targets), reset_instance (clear ALL overrides so the instance re-inherits its main component \u2014 returns property snapshots before/after so you can re-apply what mattered), detach.",
1190
1232
  {
1191
1233
  action: z6.enum([
1192
1234
  "list",
@@ -1198,7 +1240,9 @@ function register5(server, sendCommand) {
1198
1240
  "get_overrides",
1199
1241
  "set_overrides",
1200
1242
  "reset_instance",
1201
- "detach"
1243
+ "detach",
1244
+ "add_property",
1245
+ "bind_property"
1202
1246
  ]),
1203
1247
  nodeId: z6.string().optional().describe("Target node (create/get_props/set_props/reset_instance/detach)"),
1204
1248
  componentIds: z6.array(z6.string()).optional().describe("create_set: components to combine"),
@@ -1208,7 +1252,13 @@ function register5(server, sendCommand) {
1208
1252
  properties: z6.record(z6.union([z6.string(), z6.boolean()])).optional().describe("set_props: property name \u2192 value (variant/text/boolean/swap)"),
1209
1253
  sourceInstanceId: z6.string().optional().describe("set_overrides: copy from this instance"),
1210
1254
  targetNodeIds: z6.array(z6.string()).optional().describe("set_overrides: apply to these"),
1211
- instanceNodeId: z6.string().optional().describe("get_overrides: instance to inspect")
1255
+ instanceNodeId: z6.string().optional().describe("get_overrides: instance to inspect"),
1256
+ propertyName: z6.string().optional().describe("add_property: the name designers will see (e.g. hasLabel, text)"),
1257
+ propertyType: z6.enum(["TEXT", "BOOLEAN", "INSTANCE_SWAP"]).optional().describe("add_property: VARIANT axes come from variant names, so they are not addable here"),
1258
+ defaultValue: z6.union([z6.string(), z6.boolean()]).optional().describe("add_property: the value a fresh instance starts with"),
1259
+ propertyKey: z6.string().optional().describe("bind_property: the key add_property returned (name#id)"),
1260
+ field: z6.enum(["characters", "visible", "mainComponent"]).optional().describe("bind_property: which part of the layer the property drives \u2014 text, show/hide, or which component sits in a swap slot"),
1261
+ layerName: z6.string().optional().describe("bind_property: bind this layer in EVERY variant of the set at once")
1212
1262
  },
1213
1263
  async (args) => {
1214
1264
  try {
@@ -1239,6 +1289,22 @@ function register5(server, sendCommand) {
1239
1289
  properties: args.properties
1240
1290
  });
1241
1291
  break;
1292
+ case "add_property":
1293
+ result = await sendCommand("add_component_property", {
1294
+ nodeId: args.nodeId,
1295
+ propertyName: args.propertyName,
1296
+ propertyType: args.propertyType,
1297
+ defaultValue: args.defaultValue
1298
+ });
1299
+ break;
1300
+ case "bind_property":
1301
+ result = await sendCommand("bind_component_property", {
1302
+ nodeId: args.nodeId,
1303
+ propertyKey: args.propertyKey,
1304
+ field: args.field,
1305
+ layerName: args.layerName
1306
+ });
1307
+ break;
1242
1308
  case "get_overrides":
1243
1309
  result = await sendCommand("get_instance_overrides", {
1244
1310
  instanceNodeId: args.instanceNodeId ?? args.nodeId
@@ -1268,9 +1334,46 @@ function register5(server, sendCommand) {
1268
1334
  // src/tools/core/variables.ts
1269
1335
  var variables_exports = {};
1270
1336
  __export(variables_exports, {
1271
- register: () => register6
1337
+ register: () => register6,
1338
+ scopeWarning: () => scopeWarning,
1339
+ suggestScopes: () => suggestScopes
1272
1340
  });
1273
1341
  import { z as z7 } from "zod";
1342
+ var SCOPE_BY_NAME = [
1343
+ [/radius|corner/i, ["CORNER_RADIUS"]],
1344
+ [/border-width|stroke-width|stroke\b/i, ["STROKE_FLOAT"]],
1345
+ [/gap|spacing|padding|inset/i, ["GAP"]],
1346
+ [/font-size|text-size/i, ["FONT_SIZE"]],
1347
+ [/line-height|leading/i, ["LINE_HEIGHT"]],
1348
+ [/letter-spacing|tracking/i, ["LETTER_SPACING"]],
1349
+ [/opacity|alpha/i, ["OPACITY"]],
1350
+ [/width|height|\bsize\b/i, ["WIDTH_HEIGHT"]],
1351
+ [/text-color|text-fill|\bfg\b|foreground/i, ["TEXT_FILL"]],
1352
+ [/border-color|outline-color|stroke-color/i, ["STROKE_COLOR"]],
1353
+ [/background|surface|\bfill\b|\bbg\b/i, ["FRAME_FILL", "SHAPE_FILL"]],
1354
+ [/shadow|elevation/i, ["EFFECT_COLOR"]]
1355
+ ];
1356
+ var SCOPE_MENU = {
1357
+ COLOR: ["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR", "EFFECT_COLOR"],
1358
+ FLOAT: ["CORNER_RADIUS", "GAP", "WIDTH_HEIGHT", "STROKE_FLOAT", "FONT_SIZE", "OPACITY"],
1359
+ STRING: ["TEXT_CONTENT", "FONT_FAMILY", "FONT_STYLE"]
1360
+ };
1361
+ function suggestScopes(name, resolvedType) {
1362
+ const menu = SCOPE_MENU[resolvedType];
1363
+ if (!menu) return null;
1364
+ for (const [pattern, scopes] of SCOPE_BY_NAME) {
1365
+ if (!pattern.test(name)) continue;
1366
+ const usable = scopes.filter((s) => menu.indexOf(s) >= 0);
1367
+ if (usable.length) return usable;
1368
+ }
1369
+ return null;
1370
+ }
1371
+ function scopeWarning(name, resolvedType) {
1372
+ const menu = SCOPE_MENU[resolvedType];
1373
+ if (!menu) return null;
1374
+ const guess = suggestScopes(name, resolvedType);
1375
+ return `"${name}" was created with ALL_SCOPES, so it will be offered for every property in the file. ` + (guess ? `Its name reads like ${guess.join(" + ")} \u2014 set that with manage_variables action:set_scopes.` : `Narrow it with manage_variables action:set_scopes (${resolvedType} takes ${menu.join(", ")}).`);
1376
+ }
1274
1377
  var ACTIONS = {
1275
1378
  list_collections: ["get_variable_collections", []],
1276
1379
  list: ["get_variables", ["collectionId"]],
@@ -1278,7 +1381,10 @@ var ACTIONS = {
1278
1381
  create_collection: ["create_variable_collection", ["name", "modes"]],
1279
1382
  update_collection: ["update_variable_collection", ["collectionId", "name", "hiddenFromPublishing"]],
1280
1383
  delete_collection: ["delete_variable_collection", ["collectionId"]],
1281
- create: ["create_variable", ["collectionId", "name", "resolvedType", "value"]],
1384
+ create: [
1385
+ "create_variable",
1386
+ ["collectionId", "name", "resolvedType", "value", "valuesByMode", "scopes", "description"]
1387
+ ],
1282
1388
  update: ["update_variable", ["variableId", "modeId", "value", "name", "description", "hiddenFromPublishing"]],
1283
1389
  delete: ["delete_variable", ["variableId"]],
1284
1390
  add_mode: ["add_mode", ["collectionId", "name"]],
@@ -1297,7 +1403,7 @@ var ACTIONS = {
1297
1403
  function register6(server, sendCommand) {
1298
1404
  server.tool(
1299
1405
  "manage_variables",
1300
- "Design-token variables: list_collections / list (variables in a collection) / snapshot (compact whole-file inventory \u2014 every collection and variable name/type in one call; take one before branch merges or bulk edits, diff after) / create_collection / update_collection / delete_collection / create / update / delete / add_mode / remove_mode / rename_mode / set_scopes / set_code_syntax / remove_code_syntax / create_alias / bind (variable\u2192node property) / unbind / set_node_mode / get_node_modes / tokenize (find hardcoded colors & numbers that match existing variables and bind them \u2014 fix:false to preview, fix:true to apply; scope with nodeId, default current page). Pass only the fields the action needs.",
1406
+ "Design-token variables: list_collections / list (variables in a collection) / snapshot (compact whole-file inventory \u2014 every collection and variable name/type in one call; take one before branch merges or bulk edits, diff after) / create_collection / update_collection / delete_collection / create (pass valuesByMode to set every mode \u2014 and alias by variable NAME \u2014 in the same call) / update / delete / add_mode / remove_mode / rename_mode / set_scopes / set_code_syntax / remove_code_syntax / create_alias / bind (variable\u2192node property) / unbind / set_node_mode / get_node_modes / tokenize (find hardcoded colors & numbers that match existing variables and bind them \u2014 fix:false to preview, fix:true to apply; scope with nodeId, default current page). Pass only the fields the action needs.",
1301
1407
  {
1302
1408
  action: z7.enum(Object.keys(ACTIONS)),
1303
1409
  collectionId: z7.string().optional(),
@@ -1310,6 +1416,9 @@ function register6(server, sendCommand) {
1310
1416
  modeId: z7.string().optional(),
1311
1417
  resolvedType: z7.enum(["COLOR", "FLOAT", "STRING", "BOOLEAN"]).optional(),
1312
1418
  value: z7.unknown().optional().describe("Variable value (color object, number, string, bool)"),
1419
+ valuesByMode: z7.record(z7.unknown()).optional().describe(
1420
+ 'create: every mode in one call, keyed by mode NAME or id \u2014 {"Light": {"r":1,"g":1,"b":1}, "Dark": {"aliasOf": "Color/gray/900"}}. `aliasOf` takes a variable id or its exact name, so a brand-mode token lands in one round-trip instead of create + one alias per mode.'
1421
+ ),
1313
1422
  property: z7.string().optional().describe('bind/unbind: node property (e.g. "fills", "width")'),
1314
1423
  scopes: z7.array(z7.string()).optional(),
1315
1424
  platform: z7.enum(["WEB", "ANDROID", "iOS"]).optional(),
@@ -1328,6 +1437,11 @@ function register6(server, sendCommand) {
1328
1437
  params,
1329
1438
  args.action === "tokenize" ? 12e4 : void 0
1330
1439
  );
1440
+ const wideOpen = args.action === "create" && (!Array.isArray(args.scopes) || args.scopes.length === 0 || args.scopes.indexOf("ALL_SCOPES") >= 0);
1441
+ if (wideOpen) {
1442
+ const warning = scopeWarning(String(args.name ?? ""), String(args.resolvedType ?? ""));
1443
+ if (warning) return jsonResult({ ...result, warning });
1444
+ }
1331
1445
  return jsonResult(result);
1332
1446
  } catch (error) {
1333
1447
  return errorResult(error);
@@ -1465,9 +1579,11 @@ function register8(server, sendCommand) {
1465
1579
  action: z9.enum(["list", "create", "rename", "delete", "set_background", "list_guards", "guard", "unguard"]),
1466
1580
  pageId: z9.string().optional().describe("Target page (rename/delete/set_background/guard/unguard)"),
1467
1581
  name: z9.string().optional().describe("Page name (create/rename)"),
1468
- color: colorSchema.optional().describe("Background color (set_background)")
1582
+ color: colorSchema.optional().describe("Background color (set_background)"),
1583
+ afterPageId: z9.string().optional().describe("create: put the new page right after this one \u2014 pages are ordered by meaning, and a new one lands at the bottom otherwise"),
1584
+ index: z9.number().int().min(0).optional().describe("create: exact position instead of afterPageId")
1469
1585
  },
1470
- async ({ action, pageId, name, color }) => {
1586
+ async ({ action, pageId, name, color, afterPageId, index }) => {
1471
1587
  try {
1472
1588
  let result;
1473
1589
  switch (action) {
@@ -1475,7 +1591,7 @@ function register8(server, sendCommand) {
1475
1591
  result = await sendCommand("get_pages", {});
1476
1592
  break;
1477
1593
  case "create":
1478
- result = await sendCommand("create_page", { name });
1594
+ result = await sendCommand("create_page", { name, afterPageId, index });
1479
1595
  break;
1480
1596
  case "rename":
1481
1597
  result = await sendCommand("rename_page", { pageId, name });
@@ -2654,7 +2770,10 @@ function register19(server, sendCommand) {
2654
2770
  {
2655
2771
  type: "text",
2656
2772
  text: JSON.stringify({
2657
- summary: `Screenshot taken${nodeId ? ` of node ${nodeId}` : " of current view"}`,
2773
+ summary: result.size ? `Screenshot of ${result.node?.name ?? "the current view"} (${result.node?.type ?? "?"}) \u2014 ${result.size}px, ${result.children} children` : `Screenshot taken${nodeId ? ` of node ${nodeId}` : " of current view"}`,
2774
+ // Read these when the image itself never arrives — some setups
2775
+ // strip vision, and a blob nobody can see is not an answer.
2776
+ measured: result.size ? { node: result.node, size: result.size, children: result.children, dominantColors: result.dominantColors } : void 0,
2658
2777
  recommended_next: [
2659
2778
  { tool: "get_selection_context", reason: "Get structured data about the selection" },
2660
2779
  { tool: "update_node", reason: "Make changes based on what you see" }
@@ -2765,9 +2884,9 @@ function register20(server, sendCommand) {
2765
2884
  registerAnalysisTools(interceptor, sendCommand);
2766
2885
  server.tool(
2767
2886
  "analyze_design",
2768
- `Audit the design from one aspect: color (token coverage, unbound fills/strokes), layout (auto-layout quality, spacing consistency), components (detached instances, component health), accessibility (WCAG contrast incl. large-text thresholds, touch targets, minimum text sizes), tokens (hardcoded values that match an existing variable \u2014 each finding names the variable to bind; fix in one shot with manage_variables action:tokenize fix:true), overall (runs color/layout/components/accessibility and returns a weighted 0-100 health score \u2014 good for audits and reports), voice (the file's statistical fingerprint: radius/spacing/type signatures, tokenized-paint and instance rates \u2014 what "sounds like this file"), readiness (0-100 agent-readiness score with top gaps: conventions, precedents, semantic tokens, components), or ghosts (stale references to soft-deleted variables \u2014 they still render but are invisible in pickers and dead on publish). Defaults to the current selection; tokens/voice/ghosts default to the current page.`,
2887
+ `Audit the design from one aspect: color (token coverage, unbound fills/strokes), layout (auto-layout quality, spacing consistency), components (detached instances, component health), accessibility (WCAG contrast incl. large-text thresholds, touch targets, minimum text sizes), tokens (hardcoded values that match an existing variable \u2014 each finding names the variable to bind; fix in one shot with manage_variables action:tokenize fix:true), overall (runs color/layout/components/accessibility and returns a weighted 0-100 health score \u2014 good for audits and reports), voice (the file's statistical fingerprint: radius/spacing/type signatures, tokenized-paint and instance rates \u2014 what "sounds like this file"), readiness (0-100 agent-readiness score with top gaps: conventions, precedents, semantic tokens, components), ghosts (stale references to soft-deleted variables \u2014 they still render but are invisible in pickers and dead on publish), or token_debt (component tokens nobody binds, and component tokens that alias ANOTHER component's token instead of the shared upstream \u2014 both look right on the canvas and bite later). Defaults to the current selection; tokens/voice/ghosts default to the current page.`,
2769
2888
  {
2770
- aspect: z21.enum(["color", "layout", "components", "accessibility", "tokens", "overall", "voice", "readiness", "ghosts"]),
2889
+ aspect: z21.enum(["color", "layout", "components", "accessibility", "tokens", "overall", "voice", "readiness", "ghosts", "token_debt"]),
2771
2890
  nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)"),
2772
2891
  pageIds: z21.array(z21.string()).optional().describe("voice/ghosts: pages to scan (default: current page; ghosts auto-chunks any count)"),
2773
2892
  platform: z21.enum(["desktop", "mobile"]).optional().describe("accessibility: target-size profile \u2014 desktop 24px (default), mobile 44px")
@@ -2786,6 +2905,9 @@ function register20(server, sendCommand) {
2786
2905
  if (aspect === "readiness") {
2787
2906
  return runReadiness(sendCommand);
2788
2907
  }
2908
+ if (aspect === "token_debt") {
2909
+ return runTokenDebt(sendCommand, pageIds);
2910
+ }
2789
2911
  if (aspect === "ghosts") {
2790
2912
  return runGhosts(sendCommand, pageIds);
2791
2913
  }
@@ -2815,6 +2937,56 @@ async function runReadiness(sendCommand) {
2815
2937
  recommended_next: (data.score ?? 0) < 80 ? [{ tool: "manage_conventions", reason: "Close the cheapest gap first \u2014 conventions and precedents compound" }] : []
2816
2938
  });
2817
2939
  }
2940
+ async function runTokenDebt(sendCommand, pageIds) {
2941
+ const chunks = [];
2942
+ if (pageIds && pageIds.length > 10) {
2943
+ for (let i = 0; i < pageIds.length; i += 10) chunks.push(pageIds.slice(i, i + 10));
2944
+ } else {
2945
+ chunks.push(pageIds);
2946
+ }
2947
+ const used = /* @__PURE__ */ new Set();
2948
+ const pagesScanned = [];
2949
+ let nodesScanned = 0;
2950
+ let head = {};
2951
+ let instancesScanned = 0;
2952
+ for (const chunk of chunks) {
2953
+ const part = await sendCommand("audit_token_debt", { pageIds: chunk }, 3e5);
2954
+ head = part;
2955
+ for (const id of part.seenUsed ?? []) used.add(id);
2956
+ pagesScanned.push(...part.pages ?? []);
2957
+ nodesScanned += part.nodesScanned ?? 0;
2958
+ let offset = 0;
2959
+ for (; ; ) {
2960
+ const slice = await sendCommand(
2961
+ "audit_property_bindings",
2962
+ { pageIds: chunk, offset, limit: 1e3 },
2963
+ 3e5
2964
+ );
2965
+ for (const id of slice.ids ?? []) used.add(id);
2966
+ instancesScanned += slice.next != null ? slice.next - offset : 0;
2967
+ if (slice.done !== false) break;
2968
+ offset = slice.next ?? offset + 1e3;
2969
+ }
2970
+ }
2971
+ const unused = (head.candidates ?? []).filter((c) => !used.has(c.id));
2972
+ const borrows = head.borrowedTokens ?? [];
2973
+ const summary = `${borrows.length} borrowed token${borrows.length === 1 ? "" : "s"}, ${unused.length} unused across ${pagesScanned.length} page${pagesScanned.length === 1 ? "" : "s"}`;
2974
+ return standardResult({
2975
+ summary: summary + (head.noAliasLayer ? " \u2014 this file has no alias layer, so nothing counts as a foundation: the unused list is plain variables, not component tokens" : "") + (pagesScanned.length <= 1 ? " \u2014 only one page was walked; pass pageIds for the whole file before treating anything as unused" : ""),
2976
+ data: {
2977
+ pagesScanned,
2978
+ nodesScanned,
2979
+ instancesScanned,
2980
+ foundationGroups: head.foundationGroups,
2981
+ borrowedTokens: borrows,
2982
+ unusedTokens: unused.slice(0, 40),
2983
+ unusedCount: unused.length,
2984
+ allScopesByGroup: head.allScopesByGroup,
2985
+ criterion: head.criterion
2986
+ },
2987
+ recommended_next: borrows.length ? [{ tool: "manage_variables", reason: "Point both tokens at the shared upstream instead of one borrowing the other" }] : [{ tool: "analyze_design", reason: "aspect: ghosts \u2014 unused tokens are what stale references are made of" }]
2988
+ });
2989
+ }
2818
2990
  async function runGhosts(sendCommand, pageIds) {
2819
2991
  const chunks = [];
2820
2992
  if (pageIds && pageIds.length > 10) {
@@ -3548,7 +3720,7 @@ function register21(server, sendCommand) {
3548
3720
  );
3549
3721
  server.tool(
3550
3722
  "validate_design_rules",
3551
- "Run design quality rules on a node: token coverage, auto-layout usage, naming conventions, and accessibility basics. Use after modifications or during design audits. Returns pass/fail for each rule with fix suggestions.",
3723
+ "Run design quality rules on a node: token coverage, token scopes, cross-component token borrowing, auto-layout usage, naming conventions, and accessibility basics. Use after modifications or during design audits \u2014 the scope and borrowing rules catch the two token faults that look perfect on the canvas. Returns pass/fail for each rule with fix suggestions.",
3552
3724
  {
3553
3725
  nodeId: z22.string().optional().describe("Root node to validate (default: current selection)"),
3554
3726
  platform: z22.enum(["desktop", "mobile"]).optional().describe("Target-size profile for the touch rule: desktop 24px (default), mobile 44px")
@@ -3656,6 +3828,30 @@ function register21(server, sendCommand) {
3656
3828
  }
3657
3829
  } catch {
3658
3830
  }
3831
+ try {
3832
+ const tok = await sendCommand("audit_node_tokens", { nodeId: targetId }, 6e4);
3833
+ if (tok && typeof tok.tokensBound === "number") {
3834
+ const wide = tok.wideScopes ?? [];
3835
+ const borrowed = tok.borrowed ?? [];
3836
+ results.push({
3837
+ rule: "token_scopes",
3838
+ passed: wide.length === 0,
3839
+ severity: "warning",
3840
+ message: wide.length ? `${wide.length} of ${tok.tokensBound} bound token(s) are still ALL_SCOPES: ${wide.slice(0, 4).map((w) => w.name).join(", ")}${wide.length > 4 ? "\u2026" : ""}. A wide-open token shows up in every property picker \u2014 a stroke weight offered as padding, a text colour offered as a border.` : `Scopes narrowed on all ${tok.tokensBound} bound token(s)`,
3841
+ nodeId: targetId,
3842
+ fix: wide.length ? { tool: "manage_variables", reason: "Narrow each token's scopes to the properties it is for", args: { action: "update" } } : void 0
3843
+ });
3844
+ results.push({
3845
+ rule: "token_borrowing",
3846
+ passed: borrowed.length === 0,
3847
+ severity: "error",
3848
+ message: borrowed.length ? `${borrowed.length} token(s) alias another component's token: ${borrowed.slice(0, 3).map((b) => `${b.token} \u2192 ${b.aliases}`).join("; ")}${borrowed.length > 3 ? "\u2026" : ""}. Point both at the shared upstream instead \u2014 as written, changing that component changes this one too.` : `No cross-component borrowing among ${tok.tokensBound} bound token(s)`,
3849
+ nodeId: targetId,
3850
+ fix: borrowed.length ? { tool: "manage_variables", reason: "Re-point the borrowing token at the same upstream the lender uses", args: { action: "update" } } : void 0
3851
+ });
3852
+ }
3853
+ } catch {
3854
+ }
3659
3855
  const passed = results.filter((r) => r.passed).length;
3660
3856
  const failed = results.filter((r) => !r.passed).length;
3661
3857
  const data = {
@@ -3873,7 +4069,7 @@ function listToolCatalog() {
3873
4069
  }
3874
4070
 
3875
4071
  // ../../docs/skills/figma-plugin-api.md
3876
- var figma_plugin_api_default = '# Figma Plugin API Cheat Sheet \u2014 for `execute_figma`\n\nRules and patterns for writing code that runs in the plugin sandbox. Work in small incremental scripts and `screenshot` between steps.\n\n## Non-negotiable rules (dynamic-page mode)\n\n- **Node lookup is async**: `await figma.getNodeByIdAsync(id)` \u2014 `getNodeById` throws in this plugin.\n- **Load pages before traversal**: `await page.loadAsync()` before `page.findAll(...)` on a non-current page; `figma.currentPage` is always loaded.\n- **Load fonts before ANY text edit**: `await figma.loadFontAsync(textNode.fontName)` \u2014 if `fontName === figma.mixed`, load every range: `for (const f of textNode.getRangeAllFontNames(0, textNode.characters.length)) await figma.loadFontAsync(f)`.\n- **`figma.mixed` is a Symbol**: properties like `fontSize`, `cornerRadius`, `fills` return it when values differ across ranges/children. Check `=== figma.mixed` before using; never JSON-serialize it.\n\n## Common patterns\n\n```js\n// Selection\nconst sel = figma.currentPage.selection; // read\nfigma.currentPage.selection = [node]; // write\n\n// Create + place\nconst frame = figma.createFrame();\nframe.resize(320, 200); // width/height are read-only; use resize()\nparent.appendChild(frame); // default parent is currentPage\n\n// Fills/strokes are ARRAYS and must be reassigned wholesale\nnode.fills = [{ type: "SOLID", color: { r: 1, g: 0.5, b: 0 } }]; // rgb 0-1, no alpha key \u2014 use opacity\nconst fills = JSON.parse(JSON.stringify(node.fills)); // clone before mutating\nfills[0].color.r = 0; node.fills = fills;\n\n// Auto-layout: set layoutMode FIRST, then padding/spacing/align\nframe.layoutMode = "VERTICAL";\nframe.itemSpacing = 8; frame.paddingTop = 16;\nchild.layoutSizingHorizontal = "FILL"; // requires PARENT with auto-layout\n\n// Components\nconst comp = await figma.importComponentByKeyAsync(key); // team library\nconst inst = comp.createInstance();\ninst.setProperties({ Variant: "Primary" }); // throws on unknown property names\n\n// Variables\nconst collections = await figma.variables.getLocalVariableCollectionsAsync();\nconst v = figma.variables.createVariable("name", collection, "COLOR");\nv.setValueForMode(modeId, { r: 0, g: 0, b: 0 });\nnode.setBoundVariable("fills", v); // binding paint needs figma.variables.setBoundVariableForPaint on the paint object for fills in older APIs \u2014 prefer setBoundVariable where available\n\n// Export (returns Uint8Array)\nconst bytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 2 } });\n```\n\n## `relai.*` helpers \u2014 the shortest path is the correct one\n\nAlongside `figma`, scripts get a `relai` object whose helpers are immune to the pitfalls below. Prefer them:\n\n```js\nawait relai.text(parent, "Hello", { font: {family:"IBM Plex Mono", style:"Regular"}, size: 14, color: {r:0,g:0,b:0} });\n // loads the font BEFORE writing characters\nconst card = relai.autoLayout("VERTICAL", { name: "Card", itemSpacing: 8 });\n // auto-layout frame, both axes hugging\nrelai.set(node, { layoutMode: "HORIZONTAL", width: 320, opacity: 0.9 });\n // layoutMode applied first; width/height via resize()\nrelai.hug(node); // HUG that sticks \u2014 call after appending children\nrelai.focusRing(button); // clipsContent + double spread shadows that render\nconst page = await relai.page(p => p.children.some(c => c.name === "Button"));\n // find pages by CONTENT \u2014 names get renamed\nconst titles = relai.query(\'FRAME[name^=Card] > TEXT\');\n // CSS-like search on the current page; relai.query(node, sel) scopes to a subtree.\n // Supported: TYPE, *, [name=] [name*=] [name^=] [name$=], descendant, >, comma.\n // NOT supported: pseudo-classes, dot-paths, sibling combinators \u2014 use findAll.\nrelai.placeholder(section); // construction veil so the designer sees work-in-progress\nrelai.placeholder(section, false); // ALWAYS remove when the section is done\n```\n\nTwo important facts: **scripts are NOT atomic** \u2014 on error, changes made before the throw persist, so keep scripts small and clean up after failures; and results may carry a `warnings` array for silent mistakes the lint catches on relai-created nodes and any node ids you return (e.g. spread shadows on a non-clipping frame). Convention: **return every created/mutated node id** (`return { createdNodeIds: [...] }`) \u2014 it powers both follow-up calls and the lint.\n\n## Pitfalls that throw (or silently do the wrong thing)\n\nWhen one of these throws inside `execute_figma`, the error already carries the remedy as a `Hint:` \u2014 this list is generated from the same registry (`packages/shared/src/pitfalls.ts`).\n\n<!-- PITFALLS:START -->\n- **`not a function` errors may name no symbol** \u2014 Figma\'s VM drops the subject. Usual cause: APIs from other agent tools that don\'t exist here (`node.query()`, `figma.notify()`). Use `findAll`/`findAllWithCriteria` for search; bisect when the message names nothing.\n- **Editing text without loading its font throws** (`unloaded font`). `await figma.loadFontAsync(textNode.fontName)` first; new TextNodes default to Inter Regular; when `fontName === figma.mixed`, load every font from `getRangeAllFontNames()`.\n- **`getNodeById` throws in dynamic-page mode** \u2014 always `await figma.getNodeByIdAsync(id)`.\n- **Traversing a non-current page throws until you `await page.loadAsync()`** \u2014 `figma.currentPage` is always loaded, other pages are not.\n- **`layoutSizingHorizontal/Vertical` throw without auto-layout** \u2014 FILL requires the parent to have a `layoutMode`, HUG requires it on the node itself.\n- **`createPage()` throws on the free plan once a file has 3 pages** \u2014 reuse an existing page instead.\n- **`width`/`height` are read-only** \u2014 use `node.resize(w, h)`.\n- **`figma.mixed` is a Symbol** returned by `fontSize`, `cornerRadius`, `fills` etc. when values differ across ranges/children \u2014 check `=== figma.mixed` before use; never JSON-serialize it.\n- **Exact-name lookups are fragile** \u2014 designers rename pages and layers freely; locate nodes by type/content instead of `name ===`.\n- **Stale node ids throw `does not exist`** \u2014 nodes get deleted while you work; re-read before editing and check `node.removed`.\n- **Nodes are non-extensible** \u2014 `node.myCustomProp = x` throws `object is not extensible`; use `setPluginData` or return the data instead.\n- **Shadow `spread` renders only on shapes or frames with `clipsContent: true`** \u2014 a focus ring built from spread shadows is invisible on a non-clipping frame/component.\n- **`resize()` on an auto-layout frame silently pins that axis to FIXED**, overriding `primaryAxisSizingMode: "AUTO"`. Append children first, then set `layoutSizingHorizontal = "HUG"`; use `resize` only for the fixed cross-axis.\n- **Per-corner radius** (`topLeftRadius` \u2026) only exists on RectangleCornerMixin nodes (rectangles, frames, components) \u2014 polygons, stars and lines throw.\n- **Instance children can\'t be added or removed** \u2014 detach first, or edit the main component.\n- **Figma\'s slot feature (Convert to slot, \u21E7\u2318S) has no Plugin API** \u2014 no createSlot/convertToSlot exists (verified against typings 1.123 and at runtime, 2026-07). Scaffold a frame named "Slot", select it for the designer (navigate select), and let them press the shortcut.\n- **A converted SLOT node stops hugging** \u2014 after the designer converts a frame to a slot it carries its own fixed size. Re-assert `layoutSizingVertical = "HUG"` (and the intended horizontal mode) on the SLOT node afterwards; the API can still edit its layout props.\n- **Variantizing can silently pin the hug axis to FIXED** \u2014 observed after clone \u2192 counterAxisSizingMode/width-variable changes \u2192 combineAsVariants: the variants\' `primaryAxisSizingMode` ended up FIXED though the source hugged. Always verify sizing modes on every variant after combineAsVariants and re-assert `"AUTO"`.\n- **A backgrounded Figma window suspends the plugin iframe** \u2014 timers freeze and the socket can\'t pong, so the relay\'s staleness sweep kicks the plugin and the frozen 2s redial never fires until the window regains attention. The panel redials on visibilitychange/focus (ui.html \u22650.2.4); when a paired plugin goes missing mid-session, ask the designer to click the Figma window before assuming anything is broken.\n- **A freshly-converted slot can still report `type: "FRAME"` to the Plugin API** \u2014 the layer panel shows the slot badge while getNodeByIdAsync returns FRAME (a reload boundary was observed to fix it). Never gate automation on `type === "SLOT"`; match the frame by name and trust the designer\'s visual confirmation.\n- **Deleted variables are soft-deleted ghosts** \u2014 `getVariableByIdAsync` still resolves them with name AND working values (`Variable` has no `removed` property), so bindings to them keep rendering while being invisible in pickers and dead on publish. To test aliveness, check membership in `getLocalVariablesAsync()` (remote variables are legitimately absent \u2014 check `variable.remote`).\n- **Variables cannot be reordered via the API** (`variableIds` is read-only; the `move\u2026After` methods exist only for styles) \u2014 new variables always append last; panel order is manual-drag only. NEVER delete-and-recreate variables to reorder: bindings reference variable IDs, so every binding in the file silently dies (observed in production; the ghosts keep rendering, hiding the damage).\n- **A remote (library) text style dominates variable bindings** \u2014 on text with a remote style applied, `setBoundVariable` writes appear to succeed but snap back to the style\'s own bindings. Clear the style first (`await setTextStyleIdAsync("")`), then bind.\n- **Binding writes inside instances silently revert \u2014 with two escape hatches.** `setBoundVariable` on a node nested inside an INSTANCE succeeds without error, then reverts (instance roots placed directly in a main ARE writable; anything deeper is not). What does persist: reassigning the whole `fills`/`strokes` array (variable-bound paints ride inside), and range-level `setRangeBoundVariable` on text. Verify after writing; never trust the call alone.\n- **A bound `strokeWeight` renders nothing while `strokes` is empty, and wrappers don\'t hug stroke thickness.** A LINE\'s visible weight lives entirely in its stroke paint; the frame around it keeps its own raw height/width, so the panel shows an unbound number with empty Fill/Stroke \u2014 looking broken when it isn\'t. Inspect the LINE child\'s stroke bindings, not the wrapper; and bind the wrapper\'s thickness-axis dimension to the same variable so nothing stays raw.\n- **`setBoundVariable("strokeWeight", \u2026)` is a silent no-op on FRAME-family nodes.** Frames and components bind stroke weight per side \u2014 `strokeTopWeight` / `strokeRightWeight` / `strokeBottomWeight` / `strokeLeftWeight`; the uniform `strokeWeight` field only binds on stroke-geometry nodes like LINE. No error is thrown, the binding just never appears \u2014 read back `boundVariables` after writing.\n- **Cloning a variant escapes its component set \u2014 onto whatever page the user is viewing.** `variant.clone()` parents the copy to `figma.currentPage` as a standalone COMPONENT (not into the set) \u2014 plain nodes inside components clone to the current page too. Every `componentPropertyReferences` pointing at set-level properties is silently severed, and native SLOT children are demoted to plain FRAMEs (slot-ness needs set context). Recover by `componentSet.appendChild(clone)`, re-assigning ALL references (`visible`, `slotContentId`, \u2026) \u2014 a SLOT can be restored by cloning a surviving SLOT node from a sibling variant (SLOT clones keep their type) and relinking `componentPropertyReferences.slotContentId`. Audit every set-scoped feature after adoption; none of it comes back on its own.\n- **Uneven variant renames/deletes poison the whole component set.** If variants end up with mismatched axis segments (some carry `axis=value`, others don\'t) or duplicate names, the set enters an error state where `componentPropertyDefinitions` AND every instance\'s `variantProperties`/`setProperties` throw (`Component set has existing errors`). Escape: rename stragglers so all variants share the same axes with unique values (temporary `legacy-*` values work), which heals the set \u2014 then migrate and delete.\n- **`setProperties` DOES work on instances nested inside other instances** \u2014 it records as an override on the outer instance. Only variable-binding writes (`setBoundVariable`) silently revert inside instance internals. Don\'t skip nested instances when migrating variant or boolean properties; skip them only for binding writes.\n- **`Plugin runtime aborted` is an out-of-memory, not a timeout.** The stack shows `deepWrap` recursing into `newString` before `abort`. Reading deep-wrapped properties \u2014 `componentProperties`, `overrides`, `boundVariables`, `exportSettings` \u2014 copies a fresh object into the QuickJS sandbox on EVERY access, so a loop over tens of thousands of instances kills the runtime even though each read looks free. Chunk the scan and keep only the fields you need. (Real case: `componentProperties` over 29,861 instances aborted; the same scan over 7,139 was fine.)\n- **What a file USES is not what its library HAS.** `get_design_system`\'s remote-used list only reports components the file already placed \u2014 a library component nobody has dragged in yet is invisible to the plugin, and no amount of scanning the document will reveal it. Read the published catalog before concluding a component doesn\'t exist (one real case: a design system\'s `TextArea` was declared missing and hand-rebuilt from a stretched `Text Field`, because the file had never used the real one).\n- **A component property\'s identity is the `#id` suffix, not its display name.** Renaming `show label#4525:247` to `hasLabel#4525:247` in the library keeps every instance\'s override \u2014 text and boolean values survive a library update untouched. VARIANT axes are the opposite: they have no id and are matched by name, so renaming an axis or its values (`layout=horizontal` \u2192 `labelPosition=side`) is the only thing that actually drops instances back to a default. When auditing a library migration, snapshot the variant axes and ignore the renamed text/boolean props.\n- **`importComponentSetByKeyAsync` returns the file\'s LOCAL copy, not the latest published version.** If the file holds a stale copy of a library component, the import hands back the stale one and looks authoritative. To see what is actually published, read `componentPropertyDefinitions` over REST (`/v1/files/{key}/nodes?ids={node_id}&depth=1`) and compare. Importing is also expensive \u2014 each call materialises the whole set (a 72-variant set is thousands of nodes), and a handful in one script will abort the sandbox.\n- **GRID auto-layout has read-only positions and imperative setters.** `gridRowAnchorIndex` / `gridColumnAnchorIndex` are readonly \u2014 move a child with `node.setGridChildPosition(row, column)` (spans stay writable: `gridColumnSpan`). `gridColumnSizes` / `gridRowSizes` reject array assignment (`no setter for property`), but `gridColumnCount` / `gridRowCount` are writable and each returned `GridTrackSize` has `.type` / `.value` setters \u2014 so add a track by raising the count, then set the new track\'s type. Shrinking the row count is how you drop a leftover empty row.\n- **Text edits inside a component SET propagate to every variant.** Applying a text style to one variant\'s label retypes the matching layer in all of them \u2014 same for `textDecoration` and text fills, at node level and via `setRange*` alike. Renaming the layer does not break the link (matching is positional). So a set whose sizes need DIFFERENT text styles cannot be styled in place: apply the styles while the components are still standalone and combine afterwards \u2014 an atom does not save you either unless the atom\'s variants are standalone components rather than a set of their own. Two more from the same afternoon: `combineAsVariants` drops `textDecoration`, and a decoration written in the same execution as `setTextStyleIdAsync` is clobbered by the style landing \u2014 set it in a later call.\n- **Writing `leadingTrim` on a text node detaches its text style** and strands the style\'s typography variables as node-level bindings \u2014 the node keeps rendering but no longer follows the style. `textDecoration` is safe (the style survives); `leadingTrim` is not. When a cap-trimmed label needs to sit in a full line box (so an icon beside it doesn\'t hang below the baseline, and toggling the icon doesn\'t change the height), leave the trim alone and bind the PARENT frame\'s height to the same leading variable the style uses.\n- **Dot-prefixed components are never published**, so their keys 404 on every REST endpoint. Figma hides `.`-prefixed assets from the publish dialog; they exist only nested inside published parents. When resolving a key \u2192 file via `/v1/components/{key}`, skip names starting with `.` \u2014 and note a component SET\'s key resolves only at `/v1/component_sets/{key}`, never at `/v1/components/{key}` (and vice versa). Relatedly, `/v1/files/{key}/components` returns the individual VARIANTS (`size=md, state=default`); the components a designer names live in `/component_sets`.\n<!-- PITFALLS:END -->\n\nAlso: `resize(w, h)` throws on `w <= 0 || h <= 0`; `layoutWrap = "WRAP"` is HORIZONTAL-only, `counterAxisAlignItems = "BASELINE"` too; writing to `locked` nodes is allowed by the API but surprises designers \u2014 check first.\n\n## Return values\n\nReturn JSON-serializable data (the bridge summarizes nodes automatically and truncates >50k chars). Prefer returning `{ id, name, type }` summaries over whole nodes. `console.log` is captured and returned in `logs`.\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
4072
+ var figma_plugin_api_default = '# Figma Plugin API Cheat Sheet \u2014 for `execute_figma`\n\nRules and patterns for writing code that runs in the plugin sandbox. Work in small incremental scripts and `screenshot` between steps.\n\n## Non-negotiable rules (dynamic-page mode)\n\n- **Node lookup is async**: `await figma.getNodeByIdAsync(id)` \u2014 `getNodeById` throws in this plugin.\n- **Load pages before traversal**: `await page.loadAsync()` before `page.findAll(...)` on a non-current page; `figma.currentPage` is always loaded.\n- **Load fonts before ANY text edit**: `await figma.loadFontAsync(textNode.fontName)` \u2014 if `fontName === figma.mixed`, load every range: `for (const f of textNode.getRangeAllFontNames(0, textNode.characters.length)) await figma.loadFontAsync(f)`.\n- **`figma.mixed` is a Symbol**: properties like `fontSize`, `cornerRadius`, `fills` return it when values differ across ranges/children. Check `=== figma.mixed` before using; never JSON-serialize it.\n\n## Common patterns\n\n```js\n// Selection\nconst sel = figma.currentPage.selection; // read\nfigma.currentPage.selection = [node]; // write\n\n// Create + place\nconst frame = figma.createFrame();\nframe.resize(320, 200); // width/height are read-only; use resize()\nparent.appendChild(frame); // default parent is currentPage\n\n// Fills/strokes are ARRAYS and must be reassigned wholesale\nnode.fills = [{ type: "SOLID", color: { r: 1, g: 0.5, b: 0 } }]; // rgb 0-1, no alpha key \u2014 use opacity\nconst fills = JSON.parse(JSON.stringify(node.fills)); // clone before mutating\nfills[0].color.r = 0; node.fills = fills;\n\n// Auto-layout: set layoutMode FIRST, then padding/spacing/align\nframe.layoutMode = "VERTICAL";\nframe.itemSpacing = 8; frame.paddingTop = 16;\nchild.layoutSizingHorizontal = "FILL"; // requires PARENT with auto-layout\n\n// Components\nconst comp = await figma.importComponentByKeyAsync(key); // team library\nconst inst = comp.createInstance();\ninst.setProperties({ Variant: "Primary" }); // throws on unknown property names\n\n// Variables\nconst collections = await figma.variables.getLocalVariableCollectionsAsync();\nconst v = figma.variables.createVariable("name", collection, "COLOR");\nv.setValueForMode(modeId, { r: 0, g: 0, b: 0 });\nnode.setBoundVariable("fills", v); // binding paint needs figma.variables.setBoundVariableForPaint on the paint object for fills in older APIs \u2014 prefer setBoundVariable where available\n\n// Export (returns Uint8Array)\nconst bytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 2 } });\n```\n\n## `relai.*` helpers \u2014 the shortest path is the correct one\n\nAlongside `figma`, scripts get a `relai` object whose helpers are immune to the pitfalls below. Prefer them:\n\n```js\nawait relai.text(parent, "Hello", { font: {family:"IBM Plex Mono", style:"Regular"}, size: 14, color: {r:0,g:0,b:0} });\n // loads the font BEFORE writing characters\nconst card = relai.autoLayout("VERTICAL", { name: "Card", itemSpacing: 8 });\n // auto-layout frame, both axes hugging\nrelai.set(node, { layoutMode: "HORIZONTAL", width: 320, opacity: 0.9 });\n // layoutMode applied first; width/height via resize()\nrelai.hug(node); // HUG that sticks \u2014 call after appending children\nrelai.focusRing(button); // clipsContent + double spread shadows that render\nconst page = await relai.page(p => p.children.some(c => c.name === "Button"));\n // find pages by CONTENT \u2014 names get renamed\nconst titles = relai.query(\'FRAME[name^=Card] > TEXT\');\n // CSS-like search on the current page; relai.query(node, sel) scopes to a subtree.\n // Supported: TYPE, *, [name=] [name*=] [name^=] [name$=], descendant, >, comma.\n // NOT supported: pseudo-classes, dot-paths, sibling combinators \u2014 use findAll.\nrelai.placeholder(section); // construction veil so the designer sees work-in-progress\nrelai.placeholder(section, false); // ALWAYS remove when the section is done\n```\n\nTwo important facts: **scripts are NOT atomic** \u2014 on error, changes made before the throw persist, so keep scripts small and clean up after failures; and results may carry a `warnings` array for silent mistakes the lint catches on relai-created nodes and any node ids you return (e.g. spread shadows on a non-clipping frame). Convention: **return every created/mutated node id** (`return { createdNodeIds: [...] }`) \u2014 it powers both follow-up calls and the lint.\n\n## Pitfalls that throw (or silently do the wrong thing)\n\nWhen one of these throws inside `execute_figma`, the error already carries the remedy as a `Hint:` \u2014 this list is generated from the same registry (`packages/shared/src/pitfalls.ts`).\n\n<!-- PITFALLS:START -->\n- **`not a function` errors may name no symbol** \u2014 Figma\'s VM drops the subject. Usual cause: APIs from other agent tools that don\'t exist here (`node.query()`, `figma.notify()`). Use `findAll`/`findAllWithCriteria` for search; bisect when the message names nothing.\n- **Editing text without loading its font throws** (`unloaded font`). `await figma.loadFontAsync(textNode.fontName)` first; new TextNodes default to Inter Regular; when `fontName === figma.mixed`, load every font from `getRangeAllFontNames()`.\n- **`getNodeById` throws in dynamic-page mode** \u2014 always `await figma.getNodeByIdAsync(id)`.\n- **Traversing a non-current page throws until you `await page.loadAsync()`** \u2014 `figma.currentPage` is always loaded, other pages are not.\n- **`layoutSizingHorizontal/Vertical` throw without auto-layout** \u2014 FILL requires the parent to have a `layoutMode`, HUG requires it on the node itself.\n- **`createPage()` throws on the free plan once a file has 3 pages** \u2014 reuse an existing page instead.\n- **`width`/`height` are read-only** \u2014 use `node.resize(w, h)`.\n- **`figma.mixed` is a Symbol** returned by `fontSize`, `cornerRadius`, `fills` etc. when values differ across ranges/children \u2014 check `=== figma.mixed` before use; never JSON-serialize it.\n- **Exact-name lookups are fragile** \u2014 designers rename pages and layers freely; locate nodes by type/content instead of `name ===`.\n- **Stale node ids throw `does not exist`** \u2014 nodes get deleted while you work; re-read before editing and check `node.removed`.\n- **Nodes are non-extensible** \u2014 `node.myCustomProp = x` throws `object is not extensible`; use `setPluginData` or return the data instead.\n- **Shadow `spread` renders only on shapes or frames with `clipsContent: true`** \u2014 a focus ring built from spread shadows is invisible on a non-clipping frame/component.\n- **`resize()` on an auto-layout frame silently pins that axis to FIXED**, overriding `primaryAxisSizingMode: "AUTO"`. Append children first, then set `layoutSizingHorizontal = "HUG"`; use `resize` only for the fixed cross-axis.\n- **Per-corner radius** (`topLeftRadius` \u2026) only exists on RectangleCornerMixin nodes (rectangles, frames, components) \u2014 polygons, stars and lines throw.\n- **Instance children can\'t be added or removed** \u2014 detach first, or edit the main component.\n- **Figma\'s slot feature (Convert to slot, \u21E7\u2318S) has no Plugin API** \u2014 no createSlot/convertToSlot exists (verified against typings 1.123 and at runtime, 2026-07). Scaffold a frame named "Slot", select it for the designer (navigate select), and let them press the shortcut.\n- **A converted SLOT node stops hugging** \u2014 after the designer converts a frame to a slot it carries its own fixed size. Re-assert `layoutSizingVertical = "HUG"` (and the intended horizontal mode) on the SLOT node afterwards; the API can still edit its layout props.\n- **Variantizing can silently pin the hug axis to FIXED** \u2014 observed after clone \u2192 counterAxisSizingMode/width-variable changes \u2192 combineAsVariants: the variants\' `primaryAxisSizingMode` ended up FIXED though the source hugged. Always verify sizing modes on every variant after combineAsVariants and re-assert `"AUTO"`.\n- **A backgrounded Figma window suspends the plugin iframe** \u2014 timers freeze and the socket can\'t pong, so the relay\'s staleness sweep kicks the plugin and the frozen 2s redial never fires until the window regains attention. The panel redials on visibilitychange/focus (ui.html \u22650.2.4); when a paired plugin goes missing mid-session, ask the designer to click the Figma window before assuming anything is broken.\n- **A freshly-converted slot can still report `type: "FRAME"` to the Plugin API** \u2014 the layer panel shows the slot badge while getNodeByIdAsync returns FRAME (a reload boundary was observed to fix it). Never gate automation on `type === "SLOT"`; match the frame by name and trust the designer\'s visual confirmation.\n- **Deleted variables are soft-deleted ghosts** \u2014 `getVariableByIdAsync` still resolves them with name AND working values (`Variable` has no `removed` property), so bindings to them keep rendering while being invisible in pickers and dead on publish. To test aliveness, check membership in `getLocalVariablesAsync()` (remote variables are legitimately absent \u2014 check `variable.remote`).\n- **Variables cannot be reordered via the API** (`variableIds` is read-only; the `move\u2026After` methods exist only for styles) \u2014 new variables always append last; panel order is manual-drag only. NEVER delete-and-recreate variables to reorder: bindings reference variable IDs, so every binding in the file silently dies (observed in production; the ghosts keep rendering, hiding the damage).\n- **A remote (library) text style dominates variable bindings** \u2014 on text with a remote style applied, `setBoundVariable` writes appear to succeed but snap back to the style\'s own bindings. Clear the style first (`await setTextStyleIdAsync("")`), then bind.\n- **Binding writes inside instances silently revert \u2014 with two escape hatches.** `setBoundVariable` on a node nested inside an INSTANCE succeeds without error, then reverts (instance roots placed directly in a main ARE writable; anything deeper is not). What does persist: reassigning the whole `fills`/`strokes` array (variable-bound paints ride inside), and range-level `setRangeBoundVariable` on text. Verify after writing; never trust the call alone.\n- **A bound `strokeWeight` renders nothing while `strokes` is empty, and wrappers don\'t hug stroke thickness.** A LINE\'s visible weight lives entirely in its stroke paint; the frame around it keeps its own raw height/width, so the panel shows an unbound number with empty Fill/Stroke \u2014 looking broken when it isn\'t. Inspect the LINE child\'s stroke bindings, not the wrapper; and bind the wrapper\'s thickness-axis dimension to the same variable so nothing stays raw.\n- **`setBoundVariable("strokeWeight", \u2026)` is a silent no-op on FRAME-family nodes.** Frames and components bind stroke weight per side \u2014 `strokeTopWeight` / `strokeRightWeight` / `strokeBottomWeight` / `strokeLeftWeight`; the uniform `strokeWeight` field only binds on stroke-geometry nodes like LINE. No error is thrown, the binding just never appears \u2014 read back `boundVariables` after writing.\n- **Cloning a variant escapes its component set \u2014 onto whatever page the user is viewing.** `variant.clone()` parents the copy to `figma.currentPage` as a standalone COMPONENT (not into the set) \u2014 plain nodes inside components clone to the current page too. Every `componentPropertyReferences` pointing at set-level properties is silently severed, and native SLOT children are demoted to plain FRAMEs (slot-ness needs set context). Recover by `componentSet.appendChild(clone)`, re-assigning ALL references (`visible`, `slotContentId`, \u2026) \u2014 a SLOT can be restored by cloning a surviving SLOT node from a sibling variant (SLOT clones keep their type) and relinking `componentPropertyReferences.slotContentId`. Audit every set-scoped feature after adoption; none of it comes back on its own.\n- **Uneven variant renames/deletes poison the whole component set.** If variants end up with mismatched axis segments (some carry `axis=value`, others don\'t) or duplicate names, the set enters an error state where `componentPropertyDefinitions` AND every instance\'s `variantProperties`/`setProperties` throw (`Component set has existing errors`). Escape: rename stragglers so all variants share the same axes with unique values (temporary `legacy-*` values work), which heals the set \u2014 then migrate and delete.\n- **`setProperties` DOES work on instances nested inside other instances** \u2014 it records as an override on the outer instance. Only variable-binding writes (`setBoundVariable`) silently revert inside instance internals. Don\'t skip nested instances when migrating variant or boolean properties; skip them only for binding writes.\n- **`Plugin runtime aborted` is an out-of-memory, not a timeout.** The stack shows `deepWrap` recursing into `newString` before `abort`. Reading deep-wrapped properties \u2014 `componentProperties`, `overrides`, `boundVariables`, `exportSettings` \u2014 copies a fresh object into the QuickJS sandbox on EVERY access, so a loop over tens of thousands of instances kills the runtime even though each read looks free. Chunk the scan and keep only the fields you need. (Real case: `componentProperties` over 29,861 instances aborted; the same scan over 7,139 was fine.)\n- **What a file USES is not what its library HAS.** `get_design_system`\'s remote-used list only reports components the file already placed \u2014 a library component nobody has dragged in yet is invisible to the plugin, and no amount of scanning the document will reveal it. Read the published catalog before concluding a component doesn\'t exist (one real case: a design system\'s `TextArea` was declared missing and hand-rebuilt from a stretched `Text Field`, because the file had never used the real one).\n- **A component property\'s identity is the `#id` suffix, not its display name.** Renaming `show label#4525:247` to `hasLabel#4525:247` in the library keeps every instance\'s override \u2014 text and boolean values survive a library update untouched. VARIANT axes are the opposite: they have no id and are matched by name, so renaming an axis or its values (`layout=horizontal` \u2192 `labelPosition=side`) is the only thing that actually drops instances back to a default. When auditing a library migration, snapshot the variant axes and ignore the renamed text/boolean props.\n- **`importComponentSetByKeyAsync` returns the file\'s LOCAL copy, not the latest published version.** If the file holds a stale copy of a library component, the import hands back the stale one and looks authoritative. To see what is actually published, read `componentPropertyDefinitions` over REST (`/v1/files/{key}/nodes?ids={node_id}&depth=1`) and compare. Importing is also expensive \u2014 each call materialises the whole set (a 72-variant set is thousands of nodes), and a handful in one script will abort the sandbox.\n- **GRID auto-layout has read-only positions and imperative setters.** `gridRowAnchorIndex` / `gridColumnAnchorIndex` are readonly \u2014 move a child with `node.setGridChildPosition(row, column)` (spans stay writable: `gridColumnSpan`). `gridColumnSizes` / `gridRowSizes` reject array assignment (`no setter for property`), but `gridColumnCount` / `gridRowCount` are writable and each returned `GridTrackSize` has `.type` / `.value` setters \u2014 so add a track by raising the count, then set the new track\'s type. Shrinking the row count is how you drop a leftover empty row.\n- **`figma.root.findAll` throws in dynamic-page mode until you `await figma.loadAllPagesAsync()`.** Page-scoped work never needs it: `figma.currentPage` is already loaded and `relai.query` searches from a node you pass. Load the whole document only when the search genuinely spans pages \u2014 on a 40-page file it is seconds of work and memory.\n- **`instance.mainComponent` throws in dynamic-page mode** \u2014 `await instance.getMainComponentAsync()`. The same rule catches every sync sibling of an async API (`getNodeById`, `textStyleId =`, `page.children` on an unloaded page): when the message says *use X instead*, it means the whole traversal from that point is async.\n- **A variant has no component property definitions of its own** (`Can only get component property definitions of a component set or non-variant component`). They belong to the COMPONENT_SET \u2014 read `node.parent` when a variant is what you have, and add new TEXT/BOOLEAN properties to the set too: adding them to a single variant either fails or leaves the other variants without them.\n- **`SPACE_BETWEEN` centres a lone child.** A header built as label + value with the value hidden by default looks correct in the variant you were staring at and lands centred everywhere else \u2014 because Figma spreads a single item to the middle rather than leaving it at the start. When one side is optional, align the row `MIN` and let the first child FILL: the optional one stays pinned to the far edge when it appears, and the other stays put when it does not.\n- **Applying a text style changes the node\'s font, so a `characters` write right after it can throw for a font nobody loaded.** `await node.setTextStyleIdAsync(id)` may swap the family or weight; load the *new* font (`await figma.loadFontAsync(node.fontName)`) before writing text. Applying the style is also the last word on properties it owns \u2014 a `textDecoration` written in the same breath is overwritten by the style landing, so set it in a later call.\n- **Text edits inside a component SET propagate to every variant.** Applying a text style to one variant\'s label retypes the matching layer in all of them \u2014 same for `textDecoration` and text fills, at node level and via `setRange*` alike. Renaming the layer does not break the link (matching is positional). So a set whose sizes need DIFFERENT text styles cannot be styled in place: apply the styles while the components are still standalone and combine afterwards \u2014 an atom does not save you either unless the atom\'s variants are standalone components rather than a set of their own. Two more from the same afternoon: `combineAsVariants` drops `textDecoration`, and a decoration written in the same execution as `setTextStyleIdAsync` is clobbered by the style landing \u2014 set it in a later call.\n- **Writing `leadingTrim` on a text node detaches its text style** and strands the style\'s typography variables as node-level bindings \u2014 the node keeps rendering but no longer follows the style. `textDecoration` is safe (the style survives); `leadingTrim` is not. When a cap-trimmed label needs to sit in a full line box (so an icon beside it doesn\'t hang below the baseline, and toggling the icon doesn\'t change the height), leave the trim alone and bind the PARENT frame\'s height to the same leading variable the style uses.\n- **A variable bound to a component property\'s VALUE hides from `boundVariables`.** `node.boundVariables.componentProperties` lists the VARIANT properties and nothing else \u2014 a token bound to a TEXT or INSTANCE_SWAP property (an icon glyph name, a swapped instance) lives only in `instance.componentProperties[key].boundVariables.value`. Read one location and every such token looks unreferenced. Bindings authored as a property\'s *default* sit in a third place, `componentPropertyDefinitions[key].boundVariables`, on the SET.\n- **"Plugin runtime aborted" is out of memory, not a timeout**, and reading `instance.componentProperties` in bulk is the fastest way there: the getter deep-wraps every property object, so 1,000 instances costs about 4 seconds and 5,700 kills the sandbox. Memory is only reclaimed when the command returns, so the fix is slices \u2014 walk a fixed number of nodes per command and have the caller ask again \u2014 not a tidier traversal inside one script.\n- **Dot-prefixed components are never published**, so their keys 404 on every REST endpoint. Figma hides `.`-prefixed assets from the publish dialog; they exist only nested inside published parents. When resolving a key \u2192 file via `/v1/components/{key}`, skip names starting with `.` \u2014 and note a component SET\'s key resolves only at `/v1/component_sets/{key}`, never at `/v1/components/{key}` (and vice versa). Relatedly, `/v1/files/{key}/components` returns the individual VARIANTS (`size=md, state=default`); the components a designer names live in `/component_sets`.\n<!-- PITFALLS:END -->\n\nAlso: `resize(w, h)` throws on `w <= 0 || h <= 0`; `layoutWrap = "WRAP"` is HORIZONTAL-only, `counterAxisAlignItems = "BASELINE"` too; writing to `locked` nodes is allowed by the API but surprises designers \u2014 check first.\n\n## Return values\n\nReturn JSON-serializable data (the bridge summarizes nodes automatically and truncates >50k chars). Prefer returning `{ id, name, type }` summaries over whole nodes. `console.log` is captured and returned in `logs`.\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
3877
4073
 
3878
4074
  // ../../docs/skills/design-tokens-strategy.md
3879
4075
  var design_tokens_strategy_default = "# Design Tokens Strategy \u2014 4-Tier Token Architecture\n\n## Token Hierarchy\n\n### Tier 1 \u2014 Core (static)\nRaw values. Never referenced directly by components.\n- Naming: `color-static/{hue}/{shade}`, `spacing-static/{value}`\n- Examples: `color-static/slate/50`, `color-static/rose/500`\n- Single mode: Value\n- hiddenFromPublishing: true\n\n### Tier 1 \u2014 Core (dynamic)\nReferences static tokens. Handles Light/Dark inversion.\n- Naming: `color-dynamic/{hue}/{shade}`\n- Light mode: shade 50 \u2192 static/50, shade 900 \u2192 static/900\n- Dark mode: shade 50 \u2192 static/900, shade 900 \u2192 static/50 (inverted)\n- 2 modes: Light / Dark\n- hiddenFromPublishing: true\n\n### Tier 2 \u2014 Themes\nSemantic tokens. Define brand personality.\n- Naming: `color/{role}/{variant}`, `typography/{style}/{property}`, `border/{type}/{size}`\n- Color roles: background, content, border, icon\n- Color variants: surface-page, surface-default, surface-subtle, surface-knockout, disabled, utility/{status}/{emphasis|subtle}\n- Typography: headline-{lg|md|sm}, title-{lg|md}, body-{lg|md|sm}[-bold], label-{lg|md|sm}, code-{md|sm}[-bold]\n- Each typography style has: font-family, font-weight, font-size, letter-spacing, line-height\n- Modes: one per brand (e.g., Brand A, Brand B)\n- description: required \u2014 explain purpose and usage constraints\n- scopes: set appropriately (TEXT_FILL for content, STROKE_COLOR for border, FRAME_FILL for background, etc.)\n\n### Tier 3 \u2014 Components\nComponent-specific tokens. Reference Tier 2 or dynamic Tier 1.\n- Naming: `{component}/color/{role}/{state}`, `{component}/size/{variant}`\n- States: default, hover, pressed, disabled, selected, selected-hover, selected-pressed, selected-disabled\n- Sizes: sm, md, lg\n- Examples: `button/color/background/default`, `button/size/md`, `navigation/color/content/selected`\n- Modes: same as Tier 2 (one per brand)\n- Not every component needs Tier 3 \u2014 only when reuse or shared decision-making justifies it\n\n## Key Rules\n- Components MUST reference tokens only \u2014 never raw hex/px values\n- Tier 1 is internal plumbing \u2014 never expose to consumers\n- Adding a new brand = adding a mode to Tier 2 and Tier 3 collections\n- Light/Dark switching is handled entirely in Tier 1 dynamic \u2014 no brand-level light/dark logic needed\n\n## Workflow\n1. `get_design_tokens` \u2192 understand existing collections and modes\n2. `manage_variables` (list) with collectionId \u2192 inspect current tokens\n3. `manage_variables` (create_collection) \u2192 new collection with modes\n4. `manage_variables` (create) \u2192 define tokens with values per mode\n5. `manage_variables` (bind) \u2192 connect tokens to node properties\n6. `manage_variables` (set_scopes) \u2192 restrict UI picker visibility\n7. `manage_variables` (set_code_syntax) \u2192 set Dev Mode code references\n\n## Verification\n- `analyze_design` (aspect: color) \u2192 find unbound colors (token coverage)\n- `validate_design_rules` \u2192 check token_coverage rule\n- `get_node_data` (detail: variables) on key nodes \u2192 confirm bindings are correct\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n";
@@ -3997,4 +4193,4 @@ export {
3997
4193
  listToolCatalog,
3998
4194
  registerPrompts
3999
4195
  };
4000
- //# sourceMappingURL=chunk-2YTXSIXE.js.map
4196
+ //# sourceMappingURL=chunk-Y72VGT6Z.js.map