figma-relai 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.5.0"
16
16
  },
17
17
  {
18
18
  instructions: `
@@ -159,6 +159,20 @@ var FIGMA_COMMANDS = [
159
159
  "audit_voice_drift",
160
160
  "audit_readiness",
161
161
  "audit_ghosts",
162
+ "snapshot_variables",
163
+ "list_rulesets",
164
+ "save_ruleset",
165
+ "delete_ruleset",
166
+ "link_ruleset",
167
+ "unlink_ruleset",
168
+ "ruleset_status",
169
+ "restore_from_ruleset",
170
+ "push_to_ruleset",
171
+ "promote_precedent",
172
+ "export_ruleset",
173
+ "import_ruleset",
174
+ "list_write_log",
175
+ "propose_rule",
162
176
  // Components
163
177
  "get_local_components",
164
178
  "get_instance_overrides",
@@ -377,6 +391,31 @@ var PITFALLS = [
377
391
  pattern: null,
378
392
  hint: "",
379
393
  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."
394
+ },
395
+ {
396
+ pattern: null,
397
+ hint: "",
398
+ 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."
399
+ },
400
+ {
401
+ pattern: null,
402
+ hint: "",
403
+ 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.'
404
+ },
405
+ {
406
+ pattern: null,
407
+ hint: "",
408
+ 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."
409
+ },
410
+ {
411
+ pattern: "Component set.*existing errors",
412
+ 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.",
413
+ 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."
414
+ },
415
+ {
416
+ pattern: null,
417
+ hint: "",
418
+ 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
419
  }
381
420
  ];
382
421
 
@@ -641,7 +680,7 @@ var RelayCore = class {
641
680
  };
642
681
 
643
682
  // src/tools/index.ts
644
- import { z as z22 } from "zod";
683
+ import { z as z23 } from "zod";
645
684
 
646
685
  // src/tools/core/create.ts
647
686
  var create_exports = {};
@@ -1178,6 +1217,7 @@ import { z as z7 } from "zod";
1178
1217
  var ACTIONS = {
1179
1218
  list_collections: ["get_variable_collections", []],
1180
1219
  list: ["get_variables", ["collectionId"]],
1220
+ snapshot: ["snapshot_variables", []],
1181
1221
  create_collection: ["create_variable_collection", ["name", "modes"]],
1182
1222
  update_collection: ["update_variable_collection", ["collectionId", "name", "hiddenFromPublishing"]],
1183
1223
  delete_collection: ["delete_variable_collection", ["collectionId"]],
@@ -1200,7 +1240,7 @@ var ACTIONS = {
1200
1240
  function register6(server, sendCommand) {
1201
1241
  server.tool(
1202
1242
  "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.",
1243
+ "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
1244
  {
1205
1245
  action: z7.enum(Object.keys(ACTIONS)),
1206
1246
  collectionId: z7.string().optional(),
@@ -1921,27 +1961,76 @@ async function fetchLibraryCatalog(libraryFileUrl) {
1921
1961
  };
1922
1962
  }
1923
1963
 
1964
+ // src/tools/core/rulesets.ts
1965
+ var rulesets_exports = {};
1966
+ __export(rulesets_exports, {
1967
+ register: () => register17
1968
+ });
1969
+ import { z as z18 } from "zod";
1970
+ var ACTIONS2 = {
1971
+ list: ["list_rulesets", []],
1972
+ save: ["save_ruleset", ["name", "conventions", "fromFile", "autoRestore", "provenance"]],
1973
+ delete: ["delete_ruleset", ["name"]],
1974
+ link: ["link_ruleset", ["name", "restore"]],
1975
+ unlink: ["unlink_ruleset", []],
1976
+ status: ["ruleset_status", []],
1977
+ restore: ["restore_from_ruleset", []],
1978
+ push: ["push_to_ruleset", []],
1979
+ promote: ["promote_precedent", ["id"]],
1980
+ export: ["export_ruleset", ["name"]],
1981
+ import: ["import_ruleset", ["markdown", "name"]]
1982
+ };
1983
+ function register17(server, sendCommand) {
1984
+ server.tool(
1985
+ "manage_rulesets",
1986
+ "Kits (rulesets) \u2014 named common-ancestor law the plugin keeps on the designer's machine, spanning every file it opens (one designer usually runs one product; its files share the same law). The panel shows this as the KIT row in the connection cluster \u2014 when speaking to the designer, call it a kit, not a ruleset. A file LINKS to one ruleset; the file's own carried conventions stay the working copy. Figma DISCARDS file-carried law on branch\u2192main merges \u2014 a linked ruleset is the recovery: status shows 'file-empty' after a merge, restore (or the set's autoRestore switch) re-seeds it. Actions: list / status (this file's state: in-sync, file-empty, drifted\u2026) / save (name + conventions, or fromFile:true to capture this file's law) / link (optionally restore:true) / unlink / restore (ruleset\u2192file, also seeds promoted precedents) / push (file\u2192ruleset \u2014 the file's law becomes the ancestor) / promote (id \u2014 copy one precedent into the linked ruleset so every linked file inherits it) / delete / export (markdown package with provenance frontmatter) / import (markdown). Pass only the fields the action needs.",
1987
+ {
1988
+ action: z18.enum(Object.keys(ACTIONS2)),
1989
+ name: z18.string().optional().describe("Ruleset name (save/link/delete/export; import fallback)"),
1990
+ conventions: z18.string().optional().describe("save: full conventions markdown (or use fromFile)"),
1991
+ fromFile: z18.boolean().optional().describe("save: capture this file's current conventions"),
1992
+ autoRestore: z18.boolean().optional().describe("save: restore without asking when a linked file's law is found wiped"),
1993
+ provenance: z18.string().optional().describe("save: where this law comes from (author/repo)"),
1994
+ restore: z18.boolean().optional().describe("link: immediately restore the ruleset into the file"),
1995
+ id: z18.string().optional().describe("promote: precedent id (list_precedents shows them)"),
1996
+ markdown: z18.string().optional().describe("import: a ruleset package exported earlier")
1997
+ },
1998
+ async (args) => {
1999
+ try {
2000
+ const [command, fields] = ACTIONS2[args.action];
2001
+ const params = Object.fromEntries(
2002
+ fields.map((f) => [f, args[f]]).filter(([, v]) => v !== void 0)
2003
+ );
2004
+ const result = await sendCommand(command, params);
2005
+ return jsonResult(result);
2006
+ } catch (error) {
2007
+ return errorResult(error);
2008
+ }
2009
+ }
2010
+ );
2011
+ }
2012
+
1924
2013
  // src/tools/batch.ts
1925
2014
  var batch_exports = {};
1926
2015
  __export(batch_exports, {
1927
- register: () => register17
2016
+ register: () => register18
1928
2017
  });
1929
- import { z as z18 } from "zod";
2018
+ import { z as z19 } from "zod";
1930
2019
  function textResult2(text) {
1931
2020
  return { content: [{ type: "text", text }] };
1932
2021
  }
1933
- function register17(server, sendCommand) {
2022
+ function register18(server, sendCommand) {
1934
2023
  server.tool(
1935
2024
  "batch_execute",
1936
2025
  "Execute multiple commands in a single round-trip. Use when performing 3+ similar operations (e.g., styling multiple nodes, creating many elements). Commands run sequentially. Pass dryRun:true to preview the exact command list without touching the canvas \u2014 useful for showing the designer a plan before a large batch.",
1937
2026
  {
1938
- commands: z18.array(
1939
- z18.object({
1940
- command: z18.string().describe("Command name"),
1941
- params: z18.record(z18.string(), z18.unknown()).optional().describe("Command parameters")
2027
+ commands: z19.array(
2028
+ z19.object({
2029
+ command: z19.string().describe("Command name"),
2030
+ params: z19.record(z19.string(), z19.unknown()).optional().describe("Command parameters")
1942
2031
  })
1943
2032
  ).describe("Array of commands to execute sequentially"),
1944
- dryRun: z18.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
2033
+ dryRun: z19.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
1945
2034
  },
1946
2035
  async ({ commands, dryRun }) => {
1947
2036
  try {
@@ -1972,10 +2061,10 @@ function register17(server, sendCommand) {
1972
2061
  // src/tools/v2/context.ts
1973
2062
  var context_exports = {};
1974
2063
  __export(context_exports, {
1975
- register: () => register18,
2064
+ register: () => register19,
1976
2065
  styleCountsFrom: () => styleCountsFrom
1977
2066
  });
1978
- import { z as z19 } from "zod";
2067
+ import { z as z20 } from "zod";
1979
2068
 
1980
2069
  // src/semantic/response.ts
1981
2070
  function standardResult(opts) {
@@ -2167,7 +2256,7 @@ function styleCountsFrom(styles) {
2167
2256
  }
2168
2257
  return { ...counts, total: counts.paint + counts.text + counts.effect + counts.grid };
2169
2258
  }
2170
- function register18(server, sendCommand) {
2259
+ function register19(server, sendCommand) {
2171
2260
  server.tool(
2172
2261
  "get_document_overview",
2173
2262
  "Get the full document structure: pages, component/style/variable counts. Always call this first to understand the file. Follow with get_selection_context or search_nodes.",
@@ -2277,7 +2366,7 @@ function register18(server, sendCommand) {
2277
2366
  "get_node_details",
2278
2367
  "Get a single node's full properties, CSS, token bindings, and children. Use after search_nodes or get_selection_context to inspect a specific node. Follow with update_node or analyze_* tools.",
2279
2368
  {
2280
- nodeId: z19.string().describe("The node ID to inspect")
2369
+ nodeId: z20.string().describe("The node ID to inspect")
2281
2370
  },
2282
2371
  { readOnlyHint: true },
2283
2372
  async ({ nodeId }) => {
@@ -2336,9 +2425,9 @@ function register18(server, sendCommand) {
2336
2425
  "search_nodes",
2337
2426
  "Search for nodes by name and/or type within a scope. Returns matching node IDs, names, and types. Use to find specific elements before inspecting or modifying them. Follow with get_node_details or update_node.",
2338
2427
  {
2339
- query: z19.string().optional().describe("Name substring to search for"),
2340
- types: z19.array(z19.string()).optional().describe("Node types to filter: FRAME, TEXT, COMPONENT, INSTANCE, RECTANGLE, etc."),
2341
- parentId: z19.string().optional().describe("Scope search to children of this node (default: current page)")
2428
+ query: z20.string().optional().describe("Name substring to search for"),
2429
+ types: z20.array(z20.string()).optional().describe("Node types to filter: FRAME, TEXT, COMPONENT, INSTANCE, RECTANGLE, etc."),
2430
+ parentId: z20.string().optional().describe("Scope search to children of this node (default: current page)")
2342
2431
  },
2343
2432
  { readOnlyHint: true },
2344
2433
  async ({ query, types, parentId }) => {
@@ -2421,7 +2510,7 @@ function register18(server, sendCommand) {
2421
2510
  "screenshot",
2422
2511
  "Take a PNG screenshot of a node or the current view. Returns an image visible to the AI. Use to visually verify changes or inspect design details. Works for both context inspection and verification after modifications.",
2423
2512
  {
2424
- nodeId: z19.string().optional().describe("Node ID to screenshot (default: current view)")
2513
+ nodeId: z20.string().optional().describe("Node ID to screenshot (default: current view)")
2425
2514
  },
2426
2515
  { readOnlyHint: true },
2427
2516
  async ({ nodeId }) => {
@@ -2477,9 +2566,9 @@ function extractTokenName(boundVars, prefix) {
2477
2566
  var analysis_exports = {};
2478
2567
  __export(analysis_exports, {
2479
2568
  collectColorIssues: () => collectColorIssues,
2480
- register: () => register19
2569
+ register: () => register20
2481
2570
  });
2482
- import { z as z20 } from "zod";
2571
+ import { z as z21 } from "zod";
2483
2572
 
2484
2573
  // src/semantic/health.ts
2485
2574
  var WEIGHTS = { color: 0.3, layout: 0.25, components: 0.2, accessibility: 0.25 };
@@ -2532,7 +2621,7 @@ var ASPECT_TOOL = {
2532
2621
  components: "analyze_component_health",
2533
2622
  accessibility: "analyze_accessibility"
2534
2623
  };
2535
- function register19(server, sendCommand) {
2624
+ function register20(server, sendCommand) {
2536
2625
  const aspectHandlers = /* @__PURE__ */ new Map();
2537
2626
  const interceptor = new Proxy(server, {
2538
2627
  get(target, prop, receiver) {
@@ -2551,12 +2640,13 @@ function register19(server, sendCommand) {
2551
2640
  "analyze_design",
2552
2641
  `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.`,
2553
2642
  {
2554
- aspect: z20.enum(["color", "layout", "components", "accessibility", "tokens", "overall", "voice", "readiness", "ghosts"]),
2555
- 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)")
2643
+ aspect: z21.enum(["color", "layout", "components", "accessibility", "tokens", "overall", "voice", "readiness", "ghosts"]),
2644
+ nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)"),
2645
+ pageIds: z21.array(z21.string()).optional().describe("voice/ghosts: pages to scan (default: current page; ghosts auto-chunks any count)"),
2646
+ platform: z21.enum(["desktop", "mobile"]).optional().describe("accessibility: target-size profile \u2014 desktop 24px (default), mobile 44px")
2557
2647
  },
2558
2648
  { readOnlyHint: true },
2559
- async ({ aspect, nodeId, pageIds }) => {
2649
+ async ({ aspect, nodeId, pageIds, platform }) => {
2560
2650
  if (aspect === "overall") {
2561
2651
  return runOverallAudit(aspectHandlers, nodeId);
2562
2652
  }
@@ -2574,7 +2664,7 @@ function register19(server, sendCommand) {
2574
2664
  }
2575
2665
  const handler = aspectHandlers.get(ASPECT_TOOL[aspect]);
2576
2666
  if (!handler) throw new Error(`Unknown aspect: ${aspect}`);
2577
- return handler({ nodeId });
2667
+ return handler({ nodeId, platform });
2578
2668
  }
2579
2669
  );
2580
2670
  }
@@ -2599,7 +2689,50 @@ async function runReadiness(sendCommand) {
2599
2689
  });
2600
2690
  }
2601
2691
  async function runGhosts(sendCommand, pageIds) {
2602
- const data = await sendCommand("audit_ghosts", { pageIds }, 3e5);
2692
+ const chunks = [];
2693
+ if (pageIds && pageIds.length > 10) {
2694
+ for (let i = 0; i < pageIds.length; i += 10) chunks.push(pageIds.slice(i, i + 10));
2695
+ } else {
2696
+ chunks.push(pageIds);
2697
+ }
2698
+ let data;
2699
+ if (chunks.length === 1) {
2700
+ data = await sendCommand("audit_ghosts", { pageIds: chunks[0] }, 3e5);
2701
+ } else {
2702
+ const merged = {
2703
+ pages: [],
2704
+ ghostRefs: 0,
2705
+ ghostCount: 0,
2706
+ danglingRefs: 0,
2707
+ refsScanned: 0,
2708
+ nodesScanned: 0,
2709
+ remoteRefs: 0,
2710
+ ghosts: []
2711
+ };
2712
+ const byId = /* @__PURE__ */ new Map();
2713
+ for (const chunk of chunks) {
2714
+ const part = await sendCommand("audit_ghosts", { pageIds: chunk }, 3e5);
2715
+ merged.pages.push(...part.pages ?? []);
2716
+ merged.ghostRefs += part.ghostRefs ?? 0;
2717
+ merged.danglingRefs += part.danglingRefs ?? 0;
2718
+ merged.refsScanned += part.refsScanned ?? 0;
2719
+ merged.nodesScanned += part.nodesScanned ?? 0;
2720
+ merged.remoteRefs += part.remoteRefs ?? 0;
2721
+ merged.criterion = part.criterion ?? merged.criterion;
2722
+ for (const g of part.ghosts ?? []) {
2723
+ const entry = byId.get(g.id);
2724
+ if (entry) {
2725
+ entry.refCount += g.refCount;
2726
+ entry.sampleNodes = entry.sampleNodes.concat(g.sampleNodes).slice(0, 3);
2727
+ } else {
2728
+ byId.set(g.id, { ...g });
2729
+ }
2730
+ }
2731
+ }
2732
+ merged.ghosts = [...byId.values()].sort((a, b) => b.refCount - a.refCount).slice(0, 30);
2733
+ merged.ghostCount = byId.size;
2734
+ data = merged;
2735
+ }
2603
2736
  const ghosts = data.ghostRefs ?? 0;
2604
2737
  return standardResult({
2605
2738
  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.`,
@@ -2692,7 +2825,7 @@ function registerAnalysisTools(server, sendCommand) {
2692
2825
  "analyze_color_usage",
2693
2826
  "Audit color consistency and design token coverage for a node tree. Reports which fill/stroke colors are not bound to variables. Use after get_selection_context to diagnose token gaps. Follow with bind_tokens to fix issues.",
2694
2827
  {
2695
- nodeId: z20.string().optional().describe("Root node to analyze (default: current selection)")
2828
+ nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)")
2696
2829
  },
2697
2830
  { readOnlyHint: true },
2698
2831
  async ({ nodeId }) => {
@@ -2778,7 +2911,7 @@ function registerAnalysisTools(server, sendCommand) {
2778
2911
  "analyze_layout_quality",
2779
2912
  "Check layout quality: auto-layout usage, magic numbers in spacing/padding, and sizing consistency. Use after get_selection_context to find layout improvements. Follow with set_auto_layout to fix issues.",
2780
2913
  {
2781
- nodeId: z20.string().optional().describe("Root node to analyze (default: current selection)")
2914
+ nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)")
2782
2915
  },
2783
2916
  { readOnlyHint: true },
2784
2917
  async ({ nodeId }) => {
@@ -2831,7 +2964,7 @@ function registerAnalysisTools(server, sendCommand) {
2831
2964
  "analyze_component_health",
2832
2965
  "Check component system health: detached instances, unused components, and override consistency. Use to audit design system adherence. Follow with create_component or update_instance to fix issues.",
2833
2966
  {
2834
- nodeId: z20.string().optional().describe("Scope to analyze (default: current page)")
2967
+ nodeId: z21.string().optional().describe("Scope to analyze (default: current page)")
2835
2968
  },
2836
2969
  { readOnlyHint: true },
2837
2970
  async ({ nodeId }) => {
@@ -2882,12 +3015,13 @@ function registerAnalysisTools(server, sendCommand) {
2882
3015
  );
2883
3016
  server.tool(
2884
3017
  "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.",
3018
+ "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
3019
  {
2887
- nodeId: z20.string().optional().describe("Root node to analyze (default: current selection)")
3020
+ nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)"),
3021
+ platform: z21.enum(["desktop", "mobile"]).optional().describe("Target-size profile: desktop 24px (default), mobile 44px")
2888
3022
  },
2889
3023
  { readOnlyHint: true },
2890
- async ({ nodeId }) => {
3024
+ async ({ nodeId, platform }) => {
2891
3025
  try {
2892
3026
  let targetId;
2893
3027
  if (nodeId) {
@@ -2902,7 +3036,7 @@ function registerAnalysisTools(server, sendCommand) {
2902
3036
  return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
2903
3037
  }
2904
3038
  const issues = [];
2905
- checkAccessibility(nodeInfo, null, issues);
3039
+ checkAccessibility(nodeInfo, null, issues, platform === "mobile" ? 44 : 24);
2906
3040
  const data = {
2907
3041
  issueCount: issues.length,
2908
3042
  issues
@@ -2931,9 +3065,9 @@ function registerAnalysisTools(server, sendCommand) {
2931
3065
  "diff_nodes",
2932
3066
  `Compare two nodes and list their differences (fills, strokes, size, layout, etc.), or audit changes over time with checkpoints: checkpoint:"save" snapshots nodeIdA's key properties; checkpoint:"compare" later diffs the current state against that snapshot \u2014 useful before/after an editing session to show the designer exactly what changed.`,
2933
3067
  {
2934
- nodeIdA: z20.string().describe("First node ID (or the checkpoint target)"),
2935
- nodeIdB: z20.string().optional().describe("Second node ID (omit when using checkpoint)"),
2936
- checkpoint: z20.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now; compare: diff current vs saved snapshot")
3068
+ nodeIdA: z21.string().describe("First node ID (or the checkpoint target)"),
3069
+ nodeIdB: z21.string().optional().describe("Second node ID (omit when using checkpoint)"),
3070
+ checkpoint: z21.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now; compare: diff current vs saved snapshot")
2937
3071
  },
2938
3072
  { readOnlyHint: true },
2939
3073
  async ({ nodeIdA, nodeIdB, checkpoint }) => {
@@ -3128,15 +3262,16 @@ function analyzeLayoutNode(node, issues, _counters) {
3128
3262
  }
3129
3263
  }
3130
3264
  }
3131
- function checkAccessibility(node, parentBg, issues) {
3132
- if (node.type === "INSTANCE" || node.name?.toLowerCase().includes("button")) {
3265
+ function checkAccessibility(node, parentBg, issues, minTarget = 24) {
3266
+ const isContainer = node.type === "PAGE" || node.type === "SECTION" || node.type === "DOCUMENT";
3267
+ if (!isContainer && (node.type === "INSTANCE" || node.name?.toLowerCase().includes("button"))) {
3133
3268
  const w = node.width ?? node.absoluteBoundingBox?.width ?? 0;
3134
3269
  const h = node.height ?? node.absoluteBoundingBox?.height ?? 0;
3135
- if (w > 0 && w < 44 || h > 0 && h < 44) {
3270
+ if (w > 0 && w < minTarget || h > 0 && h < minTarget) {
3136
3271
  issues.push({
3137
3272
  nodeId: node.id,
3138
3273
  nodeName: node.name,
3139
- issue: `Touch target too small: ${Math.round(w)}\xD7${Math.round(h)}px (minimum 44\xD744px)`
3274
+ 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
3275
  });
3141
3276
  }
3142
3277
  }
@@ -3158,7 +3293,7 @@ function checkAccessibility(node, parentBg, issues) {
3158
3293
  const isLarge = fontSize !== null && (fontSize >= 24 || fontSize >= 18.66 && fontWeight >= 700);
3159
3294
  const required = isLarge ? 3 : 4.5;
3160
3295
  const ratio = calculateContrastRatio(effective, parentBg.color);
3161
- if (ratio < required) {
3296
+ if (Math.round(ratio * 10) / 10 < required) {
3162
3297
  issues.push({
3163
3298
  nodeId: node.id,
3164
3299
  nodeName: node.name,
@@ -3228,16 +3363,16 @@ function compareField(diffs, field, a, b) {
3228
3363
  // src/tools/v2/verification.ts
3229
3364
  var verification_exports = {};
3230
3365
  __export(verification_exports, {
3231
- register: () => register20
3366
+ register: () => register21
3232
3367
  });
3233
- import { z as z21 } from "zod";
3234
- function register20(server, sendCommand) {
3368
+ import { z as z22 } from "zod";
3369
+ function register21(server, sendCommand) {
3235
3370
  server.tool(
3236
3371
  "verify_changes",
3237
3372
  "Verify that a node's current properties match expected values. Use after update_node, set_auto_layout, or batch_execute to confirm changes applied correctly. Returns match/mismatch for each checked field.",
3238
3373
  {
3239
- nodeId: z21.string().describe("Node ID to verify"),
3240
- expected: z21.record(z21.string(), z21.unknown()).describe("Expected property values, e.g. { fill: '#3B82F6', cornerRadius: 8 }")
3374
+ nodeId: z22.string().describe("Node ID to verify"),
3375
+ expected: z22.record(z22.string(), z22.unknown()).describe("Expected property values, e.g. { fill: '#3B82F6', cornerRadius: 8 }")
3241
3376
  },
3242
3377
  { readOnlyHint: true },
3243
3378
  async ({ nodeId, expected }) => {
@@ -3288,10 +3423,11 @@ function register20(server, sendCommand) {
3288
3423
  "validate_design_rules",
3289
3424
  "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
3425
  {
3291
- nodeId: z21.string().optional().describe("Root node to validate (default: current selection)")
3426
+ nodeId: z22.string().optional().describe("Root node to validate (default: current selection)"),
3427
+ platform: z22.enum(["desktop", "mobile"]).optional().describe("Target-size profile for the touch rule: desktop 24px (default), mobile 44px")
3292
3428
  },
3293
3429
  { readOnlyHint: true },
3294
- async ({ nodeId }) => {
3430
+ async ({ nodeId, platform }) => {
3295
3431
  try {
3296
3432
  let targetId;
3297
3433
  if (nodeId) {
@@ -3363,17 +3499,19 @@ function register20(server, sendCommand) {
3363
3499
  });
3364
3500
  } catch {
3365
3501
  }
3366
- if (nodeInfo.type === "INSTANCE" || nodeInfo.name?.toLowerCase().includes("button")) {
3502
+ const isContainerNode = nodeInfo.type === "PAGE" || nodeInfo.type === "SECTION" || nodeInfo.type === "DOCUMENT";
3503
+ if (!isContainerNode && (nodeInfo.type === "INSTANCE" || nodeInfo.name?.toLowerCase().includes("button"))) {
3504
+ const minTarget = platform === "mobile" ? 44 : 24;
3367
3505
  const w = nodeInfo.width ?? 0;
3368
3506
  const h = nodeInfo.height ?? 0;
3369
- const adequate = w >= 44 && h >= 44;
3507
+ const adequate = w >= minTarget && h >= minTarget;
3370
3508
  results.push({
3371
3509
  rule: "touch_target_size",
3372
3510
  passed: adequate,
3373
3511
  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)`,
3512
+ 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
3513
  nodeId: targetId,
3376
- fix: !adequate ? { tool: "update_node", reason: "Increase size to meet touch target minimum", args: { nodeId: targetId } } : void 0
3514
+ fix: !adequate ? { tool: "update_node", reason: "Increase size to meet the target-size minimum", args: { nodeId: targetId } } : void 0
3377
3515
  });
3378
3516
  }
3379
3517
  try {
@@ -3417,8 +3555,8 @@ function register20(server, sendCommand) {
3417
3555
  "verify_visual",
3418
3556
  "The write\u2192see\u2192assert loop in one call: screenshots a node AND (optionally) checks expected property values. Returns the image plus structured pass/fail. Use after visual edits instead of separate screenshot + verify_changes calls.",
3419
3557
  {
3420
- nodeId: z21.string().describe("Node to verify"),
3421
- expected: z21.record(z21.string(), z21.unknown()).optional().describe("Expected property values, e.g. { cornerRadius: 8, width: 320 }")
3558
+ nodeId: z22.string().describe("Node to verify"),
3559
+ expected: z22.record(z22.string(), z22.unknown()).optional().describe("Expected property values, e.g. { cornerRadius: 8, width: 320 }")
3422
3560
  },
3423
3561
  { readOnlyHint: true },
3424
3562
  async ({ nodeId, expected }) => {
@@ -3502,6 +3640,7 @@ var moduleCategories = [
3502
3640
  [styles_exports, "design-system"],
3503
3641
  [library_exports, "design-system"],
3504
3642
  [design_system_exports, "design-system"],
3643
+ [rulesets_exports, "design-system"],
3505
3644
  [pages_exports, "document"],
3506
3645
  [navigate_exports, "document"],
3507
3646
  [assets_exports, "assets"],
@@ -3510,18 +3649,32 @@ var moduleCategories = [
3510
3649
  [batch_exports, "advanced"],
3511
3650
  [execute_exports, "advanced"]
3512
3651
  ];
3513
- function registerRoomTool(server, joinRoom) {
3652
+ function registerRoomTool(server, joinRoom, listRooms) {
3514
3653
  server.tool(
3515
3654
  "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.",
3655
+ "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
3656
  {
3518
- room: z22.string().describe("Room name shown in the Figma plugin")
3657
+ room: z23.string().optional().describe("Room name shown in the Figma plugin. Omit to list connected rooms.")
3519
3658
  },
3520
3659
  async ({ room }) => {
3521
3660
  try {
3522
3661
  if (!room) {
3662
+ const rooms = (await listRooms()).filter((r) => r.hasPlugin);
3663
+ if (rooms.length === 0) {
3664
+ return {
3665
+ content: [{ type: "text", text: "No Figma plugin is connected. Open the Relai plugin in Figma (it connects automatically), then try again." }]
3666
+ };
3667
+ }
3668
+ const lines = rooms.map(
3669
+ (r) => `- ${r.room}${r.fileName ? ` \u2014 "${r.fileName}"` : ""}`
3670
+ );
3523
3671
  return {
3524
- content: [{ type: "text", text: "Please provide a room name." }]
3672
+ content: [{
3673
+ type: "text",
3674
+ text: rooms.length === 1 ? `One plugin connected (auto-pairing will use it):
3675
+ ${lines[0]}` : `${rooms.length} plugins connected \u2014 call join_room with the room of the file you want:
3676
+ ${lines.join("\n")}`
3677
+ }]
3525
3678
  };
3526
3679
  }
3527
3680
  await joinRoom(room);
@@ -3548,7 +3701,7 @@ function registerEventsTool(server, consumeEvents, getSessionLog) {
3548
3701
  "get_events",
3549
3702
  "Activity since the last check. scope 'designer' (default): the designer's selection/node/page changes (also piggybacked as designer_events on command results). scope 'agent': this session's own command log \u2014 every plugin command sent, with success/duration \u2014 useful for summarizing what was changed. 'all': both.",
3550
3703
  {
3551
- scope: z22.enum(["designer", "agent", "all"]).optional()
3704
+ scope: z23.enum(["designer", "agent", "all"]).optional()
3552
3705
  },
3553
3706
  { readOnlyHint: true },
3554
3707
  async ({ scope = "designer" }) => {
@@ -3593,7 +3746,7 @@ function listToolCatalog() {
3593
3746
  }
3594
3747
 
3595
3748
  // ../../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';
3749
+ 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
3750
 
3598
3751
  // ../../docs/skills/design-tokens-strategy.md
3599
3752
  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";
@@ -3620,7 +3773,7 @@ var design_system_first_default = '# Design-system-first building\n\nThe differe
3620
3773
  var janitorial_cleanup_default = '# Janitorial cleanup\n\nThe chores designers postpone for months \u2014 layer renaming, token drift, spacing normalization \u2014 are where an agent earns trust fastest. These jobs are big, mechanical, and easy to verify. Do them conservatively and loudly.\n\n## Ground rules\n\n- **Preview before bulk.** Use `dryRun:true` on `batch_execute` / `set_properties`, or report findings first (`analyze_design`), and tell the designer the scale ("about 140 renames on this page \u2014 go?"). If the plugin\'s approval gate is on, big writes will ask them anyway.\n- **Scope tightly.** Work page by page (or within the designer\'s selection). Never sweep the whole file unasked.\n- **Never rename or restyle library instances\' internals** \u2014 only top-level frames/groups the team owns.\n\n## Recipes\n\n### Rename layers by content\n`Frame 427` tells nobody anything. Walk the target scope with `execute_figma` + `relai.query`, and derive names:\n- A frame whose only text child says "Submit" \u2192 `Button/Submit` (match the file\'s existing naming pattern from `get_design_system`, don\'t invent one).\n- Text nodes \u2192 their content, truncated (`Heading: Pricing plans`).\n- Skip nodes that already have deliberate names (anything not matching `/^(Frame|Group|Rectangle|Ellipse|Vector|Line) \\d+$/`).\nReturn the rename list; apply via one `batch_execute` of `rename_node` commands.\n\n### Tokenize hardcoded values\n`analyze_design aspect:"tokens"` finds hardcoded colors/numbers that visually match existing variables; `manage_variables action:"tokenize" fix:true` binds them in one pass. Report anything close-but-not-matching (deltaE just over tolerance) instead of silently snapping it.\n\n### Normalize spacing\nIn auto-layout scopes, collect `itemSpacing`/padding values; flag off-scale values (not on the file\'s 4/8pt grid or spacing variables) and propose the nearest on-scale value. Apply only after the designer confirms the mapping.\n\n### Sweep detached instances\n`analyze_design aspect:"components"` lists likely-detached instances. Offer to re-link obvious ones (matching component still exists) and just report the rest \u2014 re-attaching wrong is worse than detached.\n\n## Report format\n\nEnd with counts and jump-points: what changed, what was skipped and why, node ids for spot-checking (the designer can click entries in the plugin feed).\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';
3621
3774
 
3622
3775
  // ../../docs/skills/memory-and-precedents.md
3623
- var memory_and_precedents_default = '# File memory & precedents\n\nEvery Figma file this plugin touches can carry its own case law: **precedents** \u2014 single adjudications the designer made, stored inside the file (shared plugin data) so they reach every future session from any AI client. Conventions are the statutes; precedents are the case law. Together they are the file\'s law, and `manage_conventions action:get` returns both.\n\nWhen a write touches something a precedent references, **the file speaks**: the ruling arrives attached to your tool result. Treat it as the designer\'s voice from a past session calling a halt \u2014 the four-act shape is always the same: they said it once, the file kept it, you came to "fix" it knowing nothing, their words stop you. Back off before you undo something intentional.\n\n## Read before you work\n\nAt the start of any session that will modify the file, call `manage_conventions action:get`. Treat what comes back the way you treat user instructions:\n\n- `content` \u2014 the conventions doc (statutes).\n- `precedents` \u2014 recent adjudications (case law), newest first, with `precedentCount` for the total.\n\nA precedent outranks your own judgment about what "looks wrong". If a precedent says a deviation is intent, do not "fix" it \u2014 and do not re-ask about it.\n\n## When to record\n\nRecord a precedent (`action:record_precedent`) when the designer **rules** on something durable. The signal is adjudication language:\n\n- "This is intentional, not drift" \u2192 `kind: "intent"`\n- "Approved \u2014 but never restructure this table again" \u2192 `kind: "decision"`\n- "Don\'t do it that way; do X instead" \u2192 `kind: "correction"`\n\nOne precedent = one sentence (\u2264280 chars), written so a future session with zero context understands the ruling. Attach `refs` \u2014 the variable IDs, node IDs, or page IDs the ruling concerns \u2014 because refs power in-band surfacing: any future write that touches them gets the precedent attached to its result automatically.\n\n**Always say in your reply that you recorded it** ("\u5DF2\u8BB0\u5165\u6587\u4EF6\u8BB0\u5FC6: \u2026"). Recording silently breaks the designer\'s trust in what the file knows.\n\n## When NOT to record\n\n- Transient, one-off choices ("make this one red for the screenshot").\n- Anything already in the conventions doc (point to it instead; propose a conventions edit if it deserves statute status).\n- Secrets, personal information, or anything about people rather than the file.\n- Your own inferences the designer has not confirmed. Precedents are the DESIGNER\'s rulings, not your observations. When unsure, ask: "\u8BB0\u4E3A\u5224\u4F8B\u5417?"\n\n## Promote rulings into enforcement\n\nWhen conventions or repeated precedents describe something ENFORCEABLE, propose the promotion in chat \u2014 never enact it silently:\n\n- "Never touch page X" (in conventions or a repeated ruling) \u2192 offer: "\u8981\u628A X \u8BBE\u4E3A AI \u7981\u533A\u5417?(manage_pages action:guard)" \u2014 set it only after the designer agrees.\n- Repeated identical adjudications ("this is intent" three times on the same token family) \u2192 offer to consolidate them into one conventions line, then remove the superseded precedents.\n\nGuards belong to the designer: never guard/unguard on your own judgment, and mention any guard change in your reply.\n\n## Reporting style as trust matures\n\nEarly sessions: report every change. Once the designer clearly stops reading full receipts (they say "\u7B80\u62A5\u5C31\u597D" or approve digests), switch to digest mode: lead with **the two changes you are least sure about** and anything a precedent or guard touched, then one line for the rest ("\u5176\u4F59 N \u5904\u6309\u89C4\u7EA6"). Full detail must always remain one question away \u2014 digest is a summary, never a omission. Never digest away errors, guard rejections, or precedent hits.\n\n## Maintenance\n\n- The designer sees and can delete every entry in the plugin panel (Memory section). Their record, their rules.\n- Memory holds 200 entries / 64KB. When recording fails with a capacity error, offer a consolidation pass: `list_precedents`, merge superseded rulings into fewer summary entries (`record_precedent` the summaries, `remove_precedent` the merged ones), preserving every still-active ruling. Show the merge plan before applying it.\n- If a precedent contradicts current reality (the token it references is gone, the ruling was reversed), ask the designer whether to update or remove it \u2014 never silently drop case law.\n';
3776
+ var memory_and_precedents_default = '# File memory & precedents\n\nEvery Figma file this plugin touches can carry its own case law: **precedents** \u2014 single adjudications the designer made, stored inside the file (shared plugin data) so they reach every future session from any AI client. Conventions are the statutes; precedents are the case law. Together they are the file\'s law, and `manage_conventions action:get` returns both.\n\nWhen a write touches something a precedent references, **the file speaks**: the ruling arrives attached to your tool result. Treat it as the designer\'s voice from a past session calling a halt \u2014 the four-act shape is always the same: they said it once, the file kept it, you came to "fix" it knowing nothing, their words stop you. Back off before you undo something intentional.\n\n## Read before you work\n\nAt the start of any session that will modify the file, call `manage_conventions action:get`. Treat what comes back the way you treat user instructions:\n\n- `content` \u2014 the conventions doc (statutes).\n- `precedents` \u2014 recent adjudications (case law), newest first, with `precedentCount` for the total.\n\nA precedent outranks your own judgment about what "looks wrong". If a precedent says a deviation is intent, do not "fix" it \u2014 and do not re-ask about it.\n\n## When to record\n\nRecord a precedent (`action:record_precedent`) when the designer **rules** on something durable. The signal is adjudication language:\n\n- "This is intentional, not drift" \u2192 `kind: "intent"`\n- "Approved \u2014 but never restructure this table again" \u2192 `kind: "decision"`\n- "Don\'t do it that way; do X instead" \u2192 `kind: "correction"`\n\nOne precedent = one sentence (\u2264280 chars), written so a future session with zero context understands the ruling. Attach `refs` \u2014 the variable IDs, node IDs, or page IDs the ruling concerns \u2014 because refs power in-band surfacing: any future write that touches them gets the precedent attached to its result automatically.\n\n**Always say in your reply that you recorded it** ("\u5DF2\u8BB0\u5165\u6587\u4EF6\u8BB0\u5FC6: \u2026"). Recording silently breaks the designer\'s trust in what the file knows.\n\n## When NOT to record\n\n- Transient, one-off choices ("make this one red for the screenshot").\n- Anything already in the conventions doc (point to it instead; propose a conventions edit if it deserves statute status).\n- Secrets, personal information, or anything about people rather than the file.\n- Your own inferences the designer has not confirmed. Precedents are the DESIGNER\'s rulings, not your observations. When unsure, ask: "\u8BB0\u4E3A\u5224\u4F8B\u5417?"\n\n## Promote rulings into enforcement\n\nWhen conventions or repeated precedents describe something ENFORCEABLE, propose the promotion \u2014 never enact it silently:\n\n- "Never touch page X" (in conventions or a repeated ruling) \u2192 send a panel proposal: `propose_rule` (via batch_execute) with `kind: "page_guard"`, the `pageIds`, and a `reason` that QUOTES the sentence it comes from. The designer gets a card and decides there; do not wait for the verdict and do not guard on your own. Mention in your reply that you proposed it.\n- Repeated identical adjudications ("this is intent" three times on the same token family) \u2192 offer to consolidate them into one conventions line, then remove the superseded precedents.\n\nGuards belong to the designer: never guard/unguard on your own judgment, and mention any guard change in your reply.\n\n## Kits \u2014 the law\'s common ancestor\n\nA designer who runs one product keeps its shared law as a **kit** (`manage_rulesets`; the panel\'s KIT row) \u2014 named, stored by the plugin on their machine, spanning every file. The file\'s carried law stays the working copy; the kit is the ancestor it descends from. When speaking to the designer, say kit.\n\nThis matters because **Figma discards file-carried law on branch\u2192main merges**. At session start, if `manage_rulesets action:status` says `file-empty`, the law was wiped: say so, and restore it (`action:restore`) with the designer\'s consent \u2014 or note it happened automatically when the set\'s autoRestore is on. Other states: `drifted` means file and ancestor differ \u2014 the designer chooses pull (`restore`) or promote the file\'s version (`push`); never resolve drift on your own.\n\nWhen the designer rules something clearly product-wide (not one-file-local), offer to promote that precedent into the linked kit (`action:promote` with the precedent id) so every linked file inherits it on restore.\n\n## Reporting style as trust matures\n\nEarly sessions: report every change. Once the designer clearly stops reading full receipts (they say "\u7B80\u62A5\u5C31\u597D" or approve digests), switch to digest mode: lead with **the two changes you are least sure about** and anything a precedent or guard touched, then one line for the rest ("\u5176\u4F59 N \u5904\u6309\u89C4\u7EA6"). Full detail must always remain one question away \u2014 digest is a summary, never a omission. Never digest away errors, guard rejections, or precedent hits.\n\n## Maintenance\n\n- The designer sees and can delete every entry in the plugin panel (Memory section). Their record, their rules.\n- Memory holds 200 entries / 64KB. When recording fails with a capacity error, offer a consolidation pass: `list_precedents`, merge superseded rulings into fewer summary entries (`record_precedent` the summaries, `remove_precedent` the merged ones), preserving every still-active ruling. Show the merge plan before applying it.\n- If a precedent contradicts current reality (the token it references is gone, the ruling was reversed), ask the designer whether to update or remove it \u2014 never silently drop case law.\n';
3624
3777
 
3625
3778
  // ../../docs/skills/qa-gate.md
3626
3779
  var qa_gate_default = '# QA gate \u2014 the full physical, on demand\n\nHuman-triggered, review-moment audit of a file (or a set of pages). Run it when the designer says "\u4F53\u68C0" / "is this ready for review?" / before a library merge. It composes the gauges into one report with receipts.\n\n## The pass\n\nRun in this order (each is read-only):\n\n1. `manage_conventions action:get` \u2014 the law you\'ll be judging against (conventions + precedents). If precedents mark deviations as intent, they are NOT findings \u2014 the file already ruled. List them as "waived by precedent", never as problems.\n2. `analyze_design aspect:readiness` \u2014 agent-readiness score + top gaps.\n3. `analyze_design aspect:ghosts` (pass `pageIds` for a multi-page pass) \u2014 stale references to soft-deleted variables. Any ghost count > 0 is a red flag for publish.\n4. `analyze_design aspect:voice` \u2014 the file\'s signature (radius/spacing/type, tokenized-paint rate, instance rate).\n5. `analyze_design aspect:overall` on the key screens the designer names (health score 0-100).\n6. `analyze_design aspect:tokens` \u2014 hardcoded values that match existing variables (each is one `tokenize` away from bound).\n\n## The report\n\nProduce a compact markdown report, structured exactly like this:\n\n```\n# QA gate \u2014 <file name> \u2014 <date>\nverdict: PASS | PASS WITH NOTES | BLOCK\n\nreadiness NN/100 (top gap: \u2026)\nghosts N refs (0 required for publish)\nhealth NN/100 (screens: \u2026)\ntokens N unbound-but-matchable\nvoice radius \u2026, spacing \u2026, \u2026% tokenized\n\n## findings (ranked)\n1. [BLOCK|NOTE] \u2026 \u2014 evidence: <numbers/nodeIds> \u2014 fix: <one tool call>\n\u2026\n\n## precedent-protected (not findings)\n- \u2026 (per precedent "\u2026", YYYY-MM-DD)\n```\n\nVerdict rules: any ghosts or health < 60 on a named key screen \u2192 BLOCK; readiness < 50 \u2192 note it but readiness alone never blocks; everything protected by a precedent is listed separately, never as a finding.\n\n## Rules\n\n- Numbers unedited: report what the tools returned, never round a failing number into a passing one.\n- Findings must carry evidence (counts, nodeIds) and a one-call fix each.\n- Offer \u2014 do not perform \u2014 fixes. The QA gate is a read-only pass; repairs are a separate, approved session.\n- If the designer wants the report in-file: create a page named `\xB7 QA Report <date>` and write the report as text there (ask first \u2014 it adds a page to their file).\n- Big files: run ghosts page-set by page-set (`pageIds`) instead of one giant pass, and say which pages were covered. Silence about coverage reads as "covered everything" \u2014 never let it.\n';
@@ -3717,4 +3870,4 @@ export {
3717
3870
  listToolCatalog,
3718
3871
  registerPrompts
3719
3872
  };
3720
- //# sourceMappingURL=chunk-KZVU4D6L.js.map
3873
+ //# sourceMappingURL=chunk-2MSOOX4G.js.map