figma-relai 0.4.1 → 0.4.2

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.
@@ -12,7 +12,7 @@ function createServer() {
12
12
  return new McpServer(
13
13
  {
14
14
  name: "Relai",
15
- version: "0.4.1"
15
+ version: "0.4.2"
16
16
  },
17
17
  {
18
18
  instructions: `
@@ -159,6 +159,7 @@ var FIGMA_COMMANDS = [
159
159
  "audit_voice_drift",
160
160
  "audit_readiness",
161
161
  "audit_ghosts",
162
+ "snapshot_variables",
162
163
  // Components
163
164
  "get_local_components",
164
165
  "get_instance_overrides",
@@ -377,6 +378,31 @@ var PITFALLS = [
377
378
  pattern: null,
378
379
  hint: "",
379
380
  doc: "**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."
381
+ },
382
+ {
383
+ pattern: null,
384
+ hint: "",
385
+ doc: "**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."
386
+ },
387
+ {
388
+ pattern: null,
389
+ hint: "",
390
+ doc: '**`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.'
391
+ },
392
+ {
393
+ pattern: null,
394
+ hint: "",
395
+ doc: "**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."
396
+ },
397
+ {
398
+ pattern: "Component set.*existing errors",
399
+ hint: "The set has mismatched or duplicate variant names \u2014 every variant must carry the same axes with unique values. Rename stragglers to unique same-axis values (e.g. a temporary legacy-* value) to heal the set, then migrate instances and delete the leftovers.",
400
+ doc: "**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."
401
+ },
402
+ {
403
+ pattern: null,
404
+ hint: "",
405
+ doc: "**`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."
380
406
  }
381
407
  ];
382
408
 
@@ -1178,6 +1204,7 @@ import { z as z7 } from "zod";
1178
1204
  var ACTIONS = {
1179
1205
  list_collections: ["get_variable_collections", []],
1180
1206
  list: ["get_variables", ["collectionId"]],
1207
+ snapshot: ["snapshot_variables", []],
1181
1208
  create_collection: ["create_variable_collection", ["name", "modes"]],
1182
1209
  update_collection: ["update_variable_collection", ["collectionId", "name", "hiddenFromPublishing"]],
1183
1210
  delete_collection: ["delete_variable_collection", ["collectionId"]],
@@ -1200,7 +1227,7 @@ var ACTIONS = {
1200
1227
  function register6(server, sendCommand) {
1201
1228
  server.tool(
1202
1229
  "manage_variables",
1203
- "Design-token variables: list_collections / list (variables in a collection) / 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.",
1230
+ "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.",
1204
1231
  {
1205
1232
  action: z7.enum(Object.keys(ACTIONS)),
1206
1233
  collectionId: z7.string().optional(),
@@ -2553,10 +2580,11 @@ function register19(server, sendCommand) {
2553
2580
  {
2554
2581
  aspect: z20.enum(["color", "layout", "components", "accessibility", "tokens", "overall", "voice", "readiness", "ghosts"]),
2555
2582
  nodeId: z20.string().optional().describe("Root node to analyze (default: current selection)"),
2556
- pageIds: z20.array(z20.string()).optional().describe("voice/ghosts: pages to scan (default: current page)")
2583
+ pageIds: z20.array(z20.string()).optional().describe("voice/ghosts: pages to scan (default: current page; ghosts auto-chunks any count)"),
2584
+ platform: z20.enum(["desktop", "mobile"]).optional().describe("accessibility: target-size profile \u2014 desktop 24px (default), mobile 44px")
2557
2585
  },
2558
2586
  { readOnlyHint: true },
2559
- async ({ aspect, nodeId, pageIds }) => {
2587
+ async ({ aspect, nodeId, pageIds, platform }) => {
2560
2588
  if (aspect === "overall") {
2561
2589
  return runOverallAudit(aspectHandlers, nodeId);
2562
2590
  }
@@ -2574,7 +2602,7 @@ function register19(server, sendCommand) {
2574
2602
  }
2575
2603
  const handler = aspectHandlers.get(ASPECT_TOOL[aspect]);
2576
2604
  if (!handler) throw new Error(`Unknown aspect: ${aspect}`);
2577
- return handler({ nodeId });
2605
+ return handler({ nodeId, platform });
2578
2606
  }
2579
2607
  );
2580
2608
  }
@@ -2599,7 +2627,50 @@ async function runReadiness(sendCommand) {
2599
2627
  });
2600
2628
  }
2601
2629
  async function runGhosts(sendCommand, pageIds) {
2602
- const data = await sendCommand("audit_ghosts", { pageIds }, 3e5);
2630
+ const chunks = [];
2631
+ if (pageIds && pageIds.length > 10) {
2632
+ for (let i = 0; i < pageIds.length; i += 10) chunks.push(pageIds.slice(i, i + 10));
2633
+ } else {
2634
+ chunks.push(pageIds);
2635
+ }
2636
+ let data;
2637
+ if (chunks.length === 1) {
2638
+ data = await sendCommand("audit_ghosts", { pageIds: chunks[0] }, 3e5);
2639
+ } else {
2640
+ const merged = {
2641
+ pages: [],
2642
+ ghostRefs: 0,
2643
+ ghostCount: 0,
2644
+ danglingRefs: 0,
2645
+ refsScanned: 0,
2646
+ nodesScanned: 0,
2647
+ remoteRefs: 0,
2648
+ ghosts: []
2649
+ };
2650
+ const byId = /* @__PURE__ */ new Map();
2651
+ for (const chunk of chunks) {
2652
+ const part = await sendCommand("audit_ghosts", { pageIds: chunk }, 3e5);
2653
+ merged.pages.push(...part.pages ?? []);
2654
+ merged.ghostRefs += part.ghostRefs ?? 0;
2655
+ merged.danglingRefs += part.danglingRefs ?? 0;
2656
+ merged.refsScanned += part.refsScanned ?? 0;
2657
+ merged.nodesScanned += part.nodesScanned ?? 0;
2658
+ merged.remoteRefs += part.remoteRefs ?? 0;
2659
+ merged.criterion = part.criterion ?? merged.criterion;
2660
+ for (const g of part.ghosts ?? []) {
2661
+ const entry = byId.get(g.id);
2662
+ if (entry) {
2663
+ entry.refCount += g.refCount;
2664
+ entry.sampleNodes = entry.sampleNodes.concat(g.sampleNodes).slice(0, 3);
2665
+ } else {
2666
+ byId.set(g.id, { ...g });
2667
+ }
2668
+ }
2669
+ }
2670
+ merged.ghosts = [...byId.values()].sort((a, b) => b.refCount - a.refCount).slice(0, 30);
2671
+ merged.ghostCount = byId.size;
2672
+ data = merged;
2673
+ }
2603
2674
  const ghosts = data.ghostRefs ?? 0;
2604
2675
  return standardResult({
2605
2676
  summary: ghosts === 0 ? `No ghost references on ${data.pages?.join(", ")} (${data.refsScanned} refs across ${data.nodesScanned} nodes; dangling: ${data.danglingRefs ?? 0}).` : `${ghosts} ghost reference(s) to ${data.ghostCount} soft-deleted variable(s) on ${data.pages?.join(", ")} \u2014 they still render but are invisible in pickers and dead on publish.`,
@@ -2882,12 +2953,13 @@ function registerAnalysisTools(server, sendCommand) {
2882
2953
  );
2883
2954
  server.tool(
2884
2955
  "analyze_accessibility",
2885
- "Check accessibility: text contrast ratios against backgrounds, touch target sizes. Use to ensure designs meet WCAG guidelines. Follow with update_node to fix contrast or sizing issues.",
2956
+ "Check accessibility: text contrast ratios against backgrounds, interactive target sizes (WCAG 2.5.8: 24px desktop default, 44px with platform:mobile). Use to ensure designs meet WCAG guidelines. Follow with update_node to fix contrast or sizing issues.",
2886
2957
  {
2887
- nodeId: z20.string().optional().describe("Root node to analyze (default: current selection)")
2958
+ nodeId: z20.string().optional().describe("Root node to analyze (default: current selection)"),
2959
+ platform: z20.enum(["desktop", "mobile"]).optional().describe("Target-size profile: desktop 24px (default), mobile 44px")
2888
2960
  },
2889
2961
  { readOnlyHint: true },
2890
- async ({ nodeId }) => {
2962
+ async ({ nodeId, platform }) => {
2891
2963
  try {
2892
2964
  let targetId;
2893
2965
  if (nodeId) {
@@ -2902,7 +2974,7 @@ function registerAnalysisTools(server, sendCommand) {
2902
2974
  return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
2903
2975
  }
2904
2976
  const issues = [];
2905
- checkAccessibility(nodeInfo, null, issues);
2977
+ checkAccessibility(nodeInfo, null, issues, platform === "mobile" ? 44 : 24);
2906
2978
  const data = {
2907
2979
  issueCount: issues.length,
2908
2980
  issues
@@ -3128,15 +3200,16 @@ function analyzeLayoutNode(node, issues, _counters) {
3128
3200
  }
3129
3201
  }
3130
3202
  }
3131
- function checkAccessibility(node, parentBg, issues) {
3132
- if (node.type === "INSTANCE" || node.name?.toLowerCase().includes("button")) {
3203
+ function checkAccessibility(node, parentBg, issues, minTarget = 24) {
3204
+ const isContainer = node.type === "PAGE" || node.type === "SECTION" || node.type === "DOCUMENT";
3205
+ if (!isContainer && (node.type === "INSTANCE" || node.name?.toLowerCase().includes("button"))) {
3133
3206
  const w = node.width ?? node.absoluteBoundingBox?.width ?? 0;
3134
3207
  const h = node.height ?? node.absoluteBoundingBox?.height ?? 0;
3135
- if (w > 0 && w < 44 || h > 0 && h < 44) {
3208
+ if (w > 0 && w < minTarget || h > 0 && h < minTarget) {
3136
3209
  issues.push({
3137
3210
  nodeId: node.id,
3138
3211
  nodeName: node.name,
3139
- issue: `Touch target too small: ${Math.round(w)}\xD7${Math.round(h)}px (minimum 44\xD744px)`
3212
+ issue: `Target too small: ${Math.round(w)}\xD7${Math.round(h)}px (minimum ${minTarget}\xD7${minTarget}px \u2014 WCAG 2.5.8${minTarget >= 44 ? ", mobile" : ""})`
3140
3213
  });
3141
3214
  }
3142
3215
  }
@@ -3158,7 +3231,7 @@ function checkAccessibility(node, parentBg, issues) {
3158
3231
  const isLarge = fontSize !== null && (fontSize >= 24 || fontSize >= 18.66 && fontWeight >= 700);
3159
3232
  const required = isLarge ? 3 : 4.5;
3160
3233
  const ratio = calculateContrastRatio(effective, parentBg.color);
3161
- if (ratio < required) {
3234
+ if (Math.round(ratio * 10) / 10 < required) {
3162
3235
  issues.push({
3163
3236
  nodeId: node.id,
3164
3237
  nodeName: node.name,
@@ -3288,10 +3361,11 @@ function register20(server, sendCommand) {
3288
3361
  "validate_design_rules",
3289
3362
  "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.",
3290
3363
  {
3291
- nodeId: z21.string().optional().describe("Root node to validate (default: current selection)")
3364
+ nodeId: z21.string().optional().describe("Root node to validate (default: current selection)"),
3365
+ platform: z21.enum(["desktop", "mobile"]).optional().describe("Target-size profile for the touch rule: desktop 24px (default), mobile 44px")
3292
3366
  },
3293
3367
  { readOnlyHint: true },
3294
- async ({ nodeId }) => {
3368
+ async ({ nodeId, platform }) => {
3295
3369
  try {
3296
3370
  let targetId;
3297
3371
  if (nodeId) {
@@ -3363,17 +3437,19 @@ function register20(server, sendCommand) {
3363
3437
  });
3364
3438
  } catch {
3365
3439
  }
3366
- if (nodeInfo.type === "INSTANCE" || nodeInfo.name?.toLowerCase().includes("button")) {
3440
+ const isContainerNode = nodeInfo.type === "PAGE" || nodeInfo.type === "SECTION" || nodeInfo.type === "DOCUMENT";
3441
+ if (!isContainerNode && (nodeInfo.type === "INSTANCE" || nodeInfo.name?.toLowerCase().includes("button"))) {
3442
+ const minTarget = platform === "mobile" ? 44 : 24;
3367
3443
  const w = nodeInfo.width ?? 0;
3368
3444
  const h = nodeInfo.height ?? 0;
3369
- const adequate = w >= 44 && h >= 44;
3445
+ const adequate = w >= minTarget && h >= minTarget;
3370
3446
  results.push({
3371
3447
  rule: "touch_target_size",
3372
3448
  passed: adequate,
3373
3449
  severity: "warning",
3374
- message: adequate ? `Touch target: ${Math.round(w)}\xD7${Math.round(h)}px (OK)` : `Touch target: ${Math.round(w)}\xD7${Math.round(h)}px (minimum 44\xD744px)`,
3450
+ message: adequate ? `Target size: ${Math.round(w)}\xD7${Math.round(h)}px (OK, minimum ${minTarget}px)` : `Target size: ${Math.round(w)}\xD7${Math.round(h)}px (minimum ${minTarget}\xD7${minTarget}px \u2014 WCAG 2.5.8${minTarget >= 44 ? ", mobile" : ""})`,
3375
3451
  nodeId: targetId,
3376
- fix: !adequate ? { tool: "update_node", reason: "Increase size to meet touch target minimum", args: { nodeId: targetId } } : void 0
3452
+ fix: !adequate ? { tool: "update_node", reason: "Increase size to meet the target-size minimum", args: { nodeId: targetId } } : void 0
3377
3453
  });
3378
3454
  }
3379
3455
  try {
@@ -3510,18 +3586,32 @@ var moduleCategories = [
3510
3586
  [batch_exports, "advanced"],
3511
3587
  [execute_exports, "advanced"]
3512
3588
  ];
3513
- function registerRoomTool(server, joinRoom) {
3589
+ function registerRoomTool(server, joinRoom, listRooms) {
3514
3590
  server.tool(
3515
3591
  "join_room",
3516
- "Connect to a specific Figma plugin instance by room name. Usually unnecessary \u2014 pairing is automatic when one plugin is connected. Use only when an error reports multiple plugins/rooms; the room name is shown in each plugin's UI.",
3592
+ "Connect to a specific Figma plugin instance. Usually unnecessary \u2014 pairing is automatic when one plugin is connected. Call with no arguments to LIST the connected plugins (room + file name) instead of typing the room by hand, then call again with the room you want.",
3517
3593
  {
3518
- room: z22.string().describe("Room name shown in the Figma plugin")
3594
+ room: z22.string().optional().describe("Room name shown in the Figma plugin. Omit to list connected rooms.")
3519
3595
  },
3520
3596
  async ({ room }) => {
3521
3597
  try {
3522
3598
  if (!room) {
3599
+ const rooms = (await listRooms()).filter((r) => r.hasPlugin);
3600
+ if (rooms.length === 0) {
3601
+ return {
3602
+ content: [{ type: "text", text: "No Figma plugin is connected. Open the Relai plugin in Figma (it connects automatically), then try again." }]
3603
+ };
3604
+ }
3605
+ const lines = rooms.map(
3606
+ (r) => `- ${r.room}${r.fileName ? ` \u2014 "${r.fileName}"` : ""}`
3607
+ );
3523
3608
  return {
3524
- content: [{ type: "text", text: "Please provide a room name." }]
3609
+ content: [{
3610
+ type: "text",
3611
+ text: rooms.length === 1 ? `One plugin connected (auto-pairing will use it):
3612
+ ${lines[0]}` : `${rooms.length} plugins connected \u2014 call join_room with the room of the file you want:
3613
+ ${lines.join("\n")}`
3614
+ }]
3525
3615
  };
3526
3616
  }
3527
3617
  await joinRoom(room);
@@ -3593,7 +3683,7 @@ function listToolCatalog() {
3593
3683
  }
3594
3684
 
3595
3685
  // ../../docs/skills/figma-plugin-api.md
3596
- 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<!-- 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';
3686
+ 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<!-- 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';
3597
3687
 
3598
3688
  // ../../docs/skills/design-tokens-strategy.md
3599
3689
  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";
@@ -3717,4 +3807,4 @@ export {
3717
3807
  listToolCatalog,
3718
3808
  registerPrompts
3719
3809
  };
3720
- //# sourceMappingURL=chunk-KZVU4D6L.js.map
3810
+ //# sourceMappingURL=chunk-BH6KSGCN.js.map