figma-relai 0.2.0 → 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 figma-relai contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ <img src="assets/relai-logo.svg" alt="Relai" height="36" />
2
+
3
+ English | [日本語](README.ja.md) | [中文](README.zh.md)
4
+
5
+ **Your AI, on the canvas.** Relai connects Claude Code, Cursor, Codex — any MCP client — to Figma, so you can read, edit, audit, and build design systems by talking to the model you already use. It works on every Figma plan, because writes go through a Figma plugin rather than the paid REST API.
6
+
7
+ <img src="assets/plugin-ui.png" alt="The Relai plugin: activity feed, connection status, and a Stop button" width="380" />
8
+
9
+ ## What a session looks like
10
+
11
+ > **You:** Make the CTA pop and round it off.
12
+ >
13
+ > **AI:** `set_properties · 3 nodes · 0.4s ✓` → `verify_visual · match ✓`
14
+ >
15
+ > **You:** Now sweep the whole screen for dark mode.
16
+ >
17
+ > **AI:** `set_properties · 24 nodes · 1.2s ✓` → `analyze_design · overall → 92/100`
18
+
19
+ Every command shows up in the plugin as it runs, with timing and success or failure. Click an entry to jump to that layer on the canvas. Press **Stop** if you change your mind — the rest of the batch is cancelled.
20
+
21
+ ## Get started
22
+
23
+ You need [Figma Desktop](https://www.figma.com/downloads/), [Node.js](https://nodejs.org/) 18+, and an MCP client.
24
+
25
+ **1. Install the plugin.** Get it from [Figma Community](https://www.figma.com/community/plugin/1662131506342078142) and run it. It connects on its own and remembers its room across restarts.
26
+
27
+ **2. Register the server** with your AI client:
28
+
29
+ ```bash
30
+ claude mcp add Relai -- npx -y figma-relai # Claude Code
31
+ codex mcp add Relai -- npx -y figma-relai # Codex CLI
32
+ ```
33
+
34
+ For Cursor, add this to `.cursor/mcp.json`:
35
+
36
+ ```json
37
+ { "mcpServers": { "Relai": { "command": "npx", "args": ["-y", "figma-relai"] } } }
38
+ ```
39
+
40
+ **3. Ask for something.** Pairing is automatic; there is nothing to copy between windows. The `join_room` tool exists for one rare case only: two Figma files running the plugin at the same time.
41
+
42
+ ## What it's good at
43
+
44
+ Understanding a design. "How is this screen put together?" gets you structure, colors, layout, and token usage in one pass, and the AI can take a screenshot to actually look at the canvas rather than guess.
45
+
46
+ Bulk edits. "Translate every button label to English" or "recolor this for dark mode" become one round-trip across dozens of nodes instead of an afternoon of clicking.
47
+
48
+ Audits. `analyze_design` checks color-token coverage, auto-layout quality, component health, and accessibility (WCAG contrast, touch targets, text sizes) — or all four at once as a weighted 0–100 health score you can put in a review.
49
+
50
+ Design systems. Variable collections with modes, token binding, shared styles, components with proper variants, team-library imports. `get_design_system` inventories what the file — and the libraries it uses — already has, so the AI builds from your components instead of redrawing near-copies; `analyze_design`'s tokens aspect finds hardcoded values that visually match an existing variable, and one `tokenize` call binds them all. These run as declarative operations with precondition checks, so the same request behaves the same way every time, and a failure tells the AI what to do next ("call set_layout_mode first") instead of dumping a stack trace.
51
+
52
+ Everything else. `execute_figma` runs JavaScript against the Figma Plugin API directly — the same escape-hatch approach as Figma's official MCP — with a `relai.*` helper library that makes the correct pattern the shortest one, hints attached to known errors, and a lint that flags silent mistakes. If you'd rather the AI never ran code, turn it off with the plugin's "Allow code execution" toggle.
53
+
54
+ ## You stay in control
55
+
56
+ The plugin is the designer's side of the deal: a live activity feed of everything the AI does, an "AI connected" indicator that means an agent is actually paired (not just that a server is running), and a Stop button that cancels pending work. Selection and page changes you make flow back to the AI as events, so "now do the same to this one" works without re-explaining.
57
+
58
+ Three dials go further when you want them. **Approvals** ("Ask before big edits") holds bulk writes and code execution until you press Approve in the panel. **Lock to selection** rejects edits outside whatever you've selected — the AI gets a clear error, not a silent pass. And **file conventions** are a little CLAUDE.md stored inside the Figma file itself: naming rules, spacing habits, do-not-touch pages — every future session, from any AI client, reads it before working. The UI speaks English, 日本語, and 中文.
59
+
60
+ ## How it works
61
+
62
+ ```
63
+ AI (any MCP client)
64
+ ↕ stdio
65
+ MCP server 30 tools · analysis · verification
66
+ (embedded relay) WebSocket room hub on 127.0.0.1:9055
67
+ ↕ WebSocket
68
+ Figma plugin executes Plugin API calls
69
+ ```
70
+
71
+ The relay lives inside the MCP server, so there is no extra process to keep alive. When several MCP clients run at once, the first one hosts the relay and the others connect to it; if the host exits, a survivor takes over. Both sides remember their room, rejoin after restarts or sleep, and find each other without any copy-pasting.
72
+
73
+ Ports are fixed by Figma's plugin sandbox: the manifest allowlists `ws://localhost:9055–9057`, and other ports cannot work without editing `manifest.json`. That's why there is no port setting in the UI.
74
+
75
+ ## The tools
76
+
77
+ | Group | Tools |
78
+ |-------|-------|
79
+ | Context | `get_document_overview` · `get_selection_context` · `get_node_details` · `search_nodes` · `get_design_tokens` · `screenshot` · `get_events` |
80
+ | Analysis | `analyze_design` (color / layout / components / accessibility / overall) · `diff_nodes` (compare, or checkpoint save/compare) |
81
+ | Verification | `verify_changes` · `validate_design_rules` · `verify_visual` |
82
+ | Read | `get_node_data` (summary / tree / full / css / variables) |
83
+ | Create & edit | `create_node` · `set_properties` · `set_text` · `edit_structure` |
84
+ | Components | `manage_components` |
85
+ | Design system | `get_design_system` · `manage_variables` · `manage_styles` · `import_from_library` · `manage_conventions` |
86
+ | Document | `manage_pages` · `navigate` |
87
+ | Assets | `export_asset` · `add_image` |
88
+ | Annotations | `annotate` |
89
+ | Comments | `manage_comments` (needs a token — see below) |
90
+ | Advanced | `batch_execute` · `execute_figma` · `join_room` |
91
+
92
+ Each tool is self-describing, so the AI sees full parameter docs. The same contract also exists as a file: `npx figma-relai manifest` prints a machine-readable JSON of every tool schema, plugin command, and known pitfall — generated from the running code on every build (committed as `docs/manifest.json`), so it cannot drift — and `npx figma-relai docs <tool>` renders it for humans. Nine skill documents ship alongside as MCP prompts: token strategy, component conventions, audit workflows, a Plugin API cheat sheet for `execute_figma`, and recipes for design-system-first building, bulk cleanup, and comment-driven collaboration.
93
+
94
+ ## Relai and Figma's official MCP
95
+
96
+ Figma's own AI has grown fast — the official MCP server now writes to the canvas, and the Figma Design Agent collaborates right inside the editor. Both are excellent, and both belong to full seats on paid plans, with usage metered in AI credits and models chosen by Figma. Relai is the open-source counterpart on the other side of that line: every plan including free, whatever model and subscription you already use, everything running on your machine, and the designer holding the controls. If you have the seats, the two coexist happily — run both.
97
+
98
+ ## Optional: comments
99
+
100
+ Comments live behind Figma's REST API, which needs a personal access token. Generate one at figma.com → Settings → Security (enable comment scopes), then add it to your MCP config:
101
+
102
+ ```json
103
+ { "mcpServers": { "Relai": { "command": "npx", "args": ["-y", "figma-relai"],
104
+ "env": { "FIGMA_TOKEN": "figd_..." } } } }
105
+ ```
106
+
107
+ The token stays in your config file and is sent only to `api.figma.com`. Every other tool works without it. With it, "apply the feedback in the comments" becomes a thing the AI can actually do: read the threads, make the edits, reply. It also unlocks a quiet workflow: leave an @-comment on the canvas as a task, then tell your AI to "check the comments" — it claims the thread, does the work, and reports back on it.
108
+
109
+ ## Troubleshooting
110
+
111
+ Start with `npx figma-relai doctor` — one command that checks Node, the relay ports (and whether something foreign is squatting on them), plugin presence, the saved room, and the comments token, each with its fix.
112
+
113
+ **The plugin shows NO SERVER.** No MCP server is listening on ports 9055–9057, which usually means your AI client isn't running or Relai isn't registered in it. The panel shows the exact registration command; the plugin keeps dialing and connects the moment a server appears.
114
+
115
+ **RELAY says LINK but AGENT says WAITING.** The plumbing is fine — the AI just hasn't called a Figma tool yet in this session. Ask it something about the file.
116
+
117
+ **"Multiple Figma plugins are connected."** Two files are running the plugin. Tell the AI to `join_room` with the room name shown in the plugin you want to control.
118
+
119
+ **The first `npx` run is slow.** It downloads the package once; later starts are fast.
120
+
121
+ ## Security
122
+
123
+ The relay binds to `127.0.0.1` only and has no authentication beyond room names, which carry a crypto-random suffix. `execute_figma` runs AI-written code inside Figma's plugin sandbox; it is on by default, every run is visible in the activity feed, and the designer can disable it. Scripts are not atomic — a failed script's earlier changes persist. Full threat model: [SECURITY.md](SECURITY.md).
124
+
125
+ ## For contributors
126
+
127
+ ```bash
128
+ git clone https://github.com/syoooo/figma-relai.git
129
+ cd figma-relai
130
+ bun setup # install, build, write local MCP configs
131
+ bun test
132
+ ```
133
+
134
+ Requires [Bun](https://bun.sh/) v1.0+ (the setup script is bash; on Windows use WSL). Load the plugin via **Plugins → Development → Import plugin from manifest…** → `packages/figma-plugin/manifest.json`. A standalone relay (`bun socket`) exists for the unusual case where the relay must run on another machine. More in [CONTRIBUTING.md](CONTRIBUTING.md); manual QA lives in [docs/smoke-checklist.md](docs/smoke-checklist.md).
135
+
136
+ ## License
137
+
138
+ MIT — see [LICENSE](LICENSE).
@@ -9,7 +9,7 @@ function createServer() {
9
9
  return new McpServer(
10
10
  {
11
11
  name: "Relai",
12
- version: "0.2.0"
12
+ version: "0.2.2"
13
13
  },
14
14
  {
15
15
  instructions: `
@@ -116,6 +116,7 @@ var FIGMA_COMMANDS = [
116
116
  "get_relaunch_data",
117
117
  // Styling
118
118
  "set_fill_color",
119
+ "set_fills",
119
120
  "set_stroke_color",
120
121
  "set_corner_radius",
121
122
  "set_blend_mode",
@@ -154,6 +155,9 @@ var FIGMA_COMMANDS = [
154
155
  "get_component_properties",
155
156
  "set_component_properties",
156
157
  "detach_instance",
158
+ "reset_instance",
159
+ "audit_colors",
160
+ "find_orphan_instances",
157
161
  // Variables
158
162
  "get_variable_collections",
159
163
  "get_variables",
@@ -310,6 +314,21 @@ var PITFALLS = [
310
314
  pattern: null,
311
315
  hint: "",
312
316
  doc: "**Instance children can't be added or removed** \u2014 detach first, or edit the main component."
317
+ },
318
+ {
319
+ pattern: null,
320
+ hint: "",
321
+ doc: `**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.`
322
+ },
323
+ {
324
+ pattern: null,
325
+ hint: "",
326
+ doc: '**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.'
327
+ },
328
+ {
329
+ pattern: null,
330
+ hint: "",
331
+ doc: '**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"`.'
313
332
  }
314
333
  ];
315
334
 
@@ -705,6 +724,8 @@ var propertiesSchema = z3.object({
705
724
  blendMode: blendModeSchema.optional(),
706
725
  // Paint
707
726
  fillColor: colorSchema.optional(),
727
+ fills: z3.array(z3.record(z3.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole fill list; [] clears all fills"),
728
+ strokes: z3.array(z3.record(z3.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole stroke list; [] clears all strokes"),
708
729
  strokeColor: colorSchema.optional(),
709
730
  strokeWeight: z3.number().min(0).optional(),
710
731
  cornerRadius: z3.number().min(0).optional(),
@@ -780,6 +801,11 @@ function mapPropertiesToCommands(nodeId, p) {
780
801
  layoutSizingHorizontal: p.layoutSizingHorizontal,
781
802
  layoutSizingVertical: p.layoutSizingVertical
782
803
  });
804
+ if (p.fills !== void 0 || p.strokes !== void 0)
805
+ add("set_fills", {
806
+ ...p.fills !== void 0 ? { fills: p.fills } : {},
807
+ ...p.strokes !== void 0 ? { strokes: p.strokes } : {}
808
+ });
783
809
  if (p.fillColor !== void 0) add("set_fill_color", { color: p.fillColor });
784
810
  if (p.strokeColor !== void 0 || p.strokeWeight !== void 0)
785
811
  add("set_stroke_color", {
@@ -1016,7 +1042,7 @@ import { z as z6 } from "zod";
1016
1042
  function register5(server, sendCommand) {
1017
1043
  server.tool(
1018
1044
  "manage_components",
1019
- "Component workflow: list local components, create a component from a node, create_set (combine components as variants), instantiate (place an instance by componentKey \u2014 imports from the team library if needed), get_props / set_props (component properties on an instance), get_overrides / set_overrides (copy overrides from a source instance to targets), detach.",
1045
+ "Component workflow: list local components, create a component from a node, create_set (combine components as variants), instantiate (place an instance by componentKey \u2014 imports from the team library if needed), get_props / set_props (component properties on an instance), get_overrides / set_overrides (copy overrides from a source instance to targets), reset_instance (clear ALL overrides so the instance re-inherits its main component \u2014 returns property snapshots before/after so you can re-apply what mattered), detach.",
1020
1046
  {
1021
1047
  action: z6.enum([
1022
1048
  "list",
@@ -1027,9 +1053,10 @@ function register5(server, sendCommand) {
1027
1053
  "set_props",
1028
1054
  "get_overrides",
1029
1055
  "set_overrides",
1056
+ "reset_instance",
1030
1057
  "detach"
1031
1058
  ]),
1032
- nodeId: z6.string().optional().describe("Target node (create/get_props/set_props/detach)"),
1059
+ nodeId: z6.string().optional().describe("Target node (create/get_props/set_props/reset_instance/detach)"),
1033
1060
  componentIds: z6.array(z6.string()).optional().describe("create_set: components to combine"),
1034
1061
  componentKey: z6.string().optional().describe("instantiate: component key (or node id)"),
1035
1062
  x: z6.number().optional().describe("instantiate: position"),
@@ -1079,6 +1106,9 @@ function register5(server, sendCommand) {
1079
1106
  targetNodeIds: args.targetNodeIds
1080
1107
  });
1081
1108
  break;
1109
+ case "reset_instance":
1110
+ result = await sendCommand("reset_instance", { nodeId: args.nodeId });
1111
+ break;
1082
1112
  case "detach":
1083
1113
  result = await sendCommand("detach_instance", { nodeId: args.nodeId });
1084
1114
  break;
@@ -1842,7 +1872,8 @@ function register17(server, sendCommand) {
1842
1872
  // src/tools/v2/context.ts
1843
1873
  var context_exports = {};
1844
1874
  __export(context_exports, {
1845
- register: () => register18
1875
+ register: () => register18,
1876
+ styleCountsFrom: () => styleCountsFrom
1846
1877
  });
1847
1878
  import { z as z19 } from "zod";
1848
1879
 
@@ -2020,6 +2051,22 @@ function hasBinding(boundVars, prefix) {
2020
2051
  }
2021
2052
 
2022
2053
  // src/tools/v2/context.ts
2054
+ function styleCountsFrom(styles) {
2055
+ const counts = { paint: 0, text: 0, effect: 0, grid: 0 };
2056
+ if (Array.isArray(styles)) {
2057
+ for (const s of styles) {
2058
+ const t = (s.type || "").toLowerCase();
2059
+ if (t in counts) counts[t]++;
2060
+ }
2061
+ } else if (styles && typeof styles === "object") {
2062
+ const o = styles;
2063
+ counts.paint = Array.isArray(o.paintStyles) ? o.paintStyles.length : 0;
2064
+ counts.text = Array.isArray(o.textStyles) ? o.textStyles.length : 0;
2065
+ counts.effect = Array.isArray(o.effectStyles) ? o.effectStyles.length : 0;
2066
+ counts.grid = Array.isArray(o.gridStyles) ? o.gridStyles.length : 0;
2067
+ }
2068
+ return { ...counts, total: counts.paint + counts.text + counts.effect + counts.grid };
2069
+ }
2023
2070
  function register18(server, sendCommand) {
2024
2071
  server.tool(
2025
2072
  "get_document_overview",
@@ -2035,30 +2082,21 @@ function register18(server, sendCommand) {
2035
2082
  // Older plugin builds don't have the handler — degrade quietly
2036
2083
  sendCommand("get_conventions", {}).catch(() => null)
2037
2084
  ]);
2038
- const componentCount = docInfo.componentCount ?? 0;
2039
- const stylesByType = { paint: 0, text: 0, effect: 0, grid: 0 };
2040
- if (Array.isArray(styles)) {
2041
- for (const s of styles) {
2042
- const t = (s.type || "").toLowerCase();
2043
- if (t === "paint") stylesByType.paint++;
2044
- else if (t === "text") stylesByType.text++;
2045
- else if (t === "effect") stylesByType.effect++;
2046
- else if (t === "grid") stylesByType.grid++;
2047
- }
2048
- }
2085
+ const componentCount = docInfo.componentCount ?? null;
2086
+ const stylesByType = styleCountsFrom(styles);
2049
2087
  const data = {
2050
2088
  name: docInfo.name,
2051
2089
  currentPage: docInfo.currentPage,
2052
2090
  pages: docInfo.pages || [],
2053
2091
  counts: {
2054
2092
  components: componentCount,
2055
- styles: Array.isArray(styles) ? styles.length : 0,
2093
+ styles: stylesByType.total,
2056
2094
  variableCollections: Array.isArray(collections) ? collections.length : 0
2057
2095
  }
2058
2096
  };
2059
2097
  const conventionsText = conventions?.content ?? null;
2060
2098
  return standardResult({
2061
- summary: `"${data.name}" \u2014 ${data.pages.length} pages, ${data.counts.components} components, ${data.counts.styles} styles, ${data.counts.variableCollections} variable collections` + (conventionsText ? ". This file has conventions \u2014 follow them (see data.conventions)." : ""),
2099
+ summary: `"${data.name}" \u2014 ${data.pages.length} pages, ` + (data.counts.components != null ? `${data.counts.components} components, ` : "") + `${data.counts.styles} styles, ${data.counts.variableCollections} variable collections` + (data.counts.components == null ? " (component inventory via get_design_system)" : "") + (conventionsText ? ". This file has conventions \u2014 follow them (see data.conventions)." : ""),
2062
2100
  data: { ...data, ...conventionsText ? { conventions: conventionsText } : {} },
2063
2101
  recommended_next: [
2064
2102
  { tool: "get_selection_context", reason: "Inspect currently selected nodes" },
@@ -2256,13 +2294,7 @@ function register18(server, sendCommand) {
2256
2294
  modes: Array.isArray(c.modes) ? c.modes : [],
2257
2295
  variableCount: Array.isArray(c.variableIds) ? c.variableIds.length : 0
2258
2296
  }));
2259
- const stylesByType = { paint: 0, text: 0, effect: 0, grid: 0 };
2260
- if (Array.isArray(styles)) {
2261
- for (const s of styles) {
2262
- const t = (s.type || "").toLowerCase();
2263
- if (t in stylesByType) stylesByType[t]++;
2264
- }
2265
- }
2297
+ const { total: _ignored, ...stylesByType } = styleCountsFrom(styles);
2266
2298
  const totalVars = collectionSummaries.reduce((sum, c) => sum + c.variableCount, 0);
2267
2299
  const data = {
2268
2300
  collections: collectionSummaries,
@@ -2535,7 +2567,25 @@ function registerAnalysisTools(server, sendCommand) {
2535
2567
  }
2536
2568
  const unboundColors = [];
2537
2569
  const counters = { totalProps: 0, boundCount: 0 };
2570
+ let hiddenCount = 0;
2571
+ let scanned = 0;
2572
+ let capped = false;
2538
2573
  for (const id of targetIds) {
2574
+ let audit = null;
2575
+ try {
2576
+ audit = await sendCommand("audit_colors", { nodeId: id }, 6e4);
2577
+ } catch {
2578
+ audit = null;
2579
+ }
2580
+ if (audit && typeof audit.totalProperties === "number") {
2581
+ counters.totalProps += audit.totalProperties;
2582
+ counters.boundCount += audit.boundCount ?? 0;
2583
+ hiddenCount += audit.hiddenCount ?? 0;
2584
+ scanned += audit.scanned ?? 0;
2585
+ capped = capped || !!audit.capped;
2586
+ for (const issue of audit.issues ?? []) unboundColors.push(issue);
2587
+ continue;
2588
+ }
2539
2589
  const [nodeInfo, boundVars] = await Promise.all([
2540
2590
  sendCommand("get_node_info", { nodeId: id, depth: 2, maxNodes: 200 }),
2541
2591
  sendCommand("get_bound_variables", { nodeId: id }).catch(() => null)
@@ -2557,11 +2607,15 @@ function registerAnalysisTools(server, sendCommand) {
2557
2607
  boundCount: counters.boundCount,
2558
2608
  unboundCount: unboundColors.length,
2559
2609
  tokenCoverage,
2560
- unboundColors
2610
+ unboundColors,
2611
+ ...hiddenCount ? { hiddenCount } : {},
2612
+ ...scanned ? { scanned } : {},
2613
+ ...capped ? { capped } : {}
2561
2614
  };
2562
- const warnings = unboundColors.length > 0 ? [{ category: "tokens", message: `${unboundColors.length} color(s) not bound to design tokens` }] : [];
2615
+ const visibleIssues = unboundColors.length - hiddenCount;
2616
+ const warnings = unboundColors.length > 0 ? [{ category: "tokens", message: `${unboundColors.length} color(s) not bound to design tokens${hiddenCount ? ` (${hiddenCount} on hidden paints)` : ""}` }] : [];
2563
2617
  return standardResult({
2564
- summary: `Color audit: ${unboundColors.length} unbound color(s) found. Token coverage: ${Math.round(data.tokenCoverage * 100)}%`,
2618
+ summary: `Color audit: ${visibleIssues} unbound color(s)` + (hiddenCount ? ` + ${hiddenCount} on hidden paints` : "") + `. Token coverage: ${Math.round(data.tokenCoverage * 100)}%` + (capped ? " (scan capped \u2014 large subtree)" : ""),
2565
2619
  data,
2566
2620
  warnings,
2567
2621
  recommended_next: unboundColors.length > 0 ? [
@@ -3159,6 +3213,21 @@ function register20(server, sendCommand) {
3159
3213
  fix: !adequate ? { tool: "update_node", reason: "Increase size to meet touch target minimum", args: { nodeId: targetId } } : void 0
3160
3214
  });
3161
3215
  }
3216
+ try {
3217
+ const orph = await sendCommand("find_orphan_instances", { nodeId: targetId }, 6e4);
3218
+ if (orph && typeof orph.scanned === "number") {
3219
+ const found = orph.orphans ?? [];
3220
+ results.push({
3221
+ rule: "orphaned_instances",
3222
+ passed: found.length === 0,
3223
+ severity: "warning",
3224
+ message: found.length ? `${found.length} instance(s) reference deleted components (of ${orph.scanned} scanned): ${found.slice(0, 3).map((o) => o.name).join(", ")}${found.length > 3 ? "\u2026" : ""}` : `No orphaned instances (${orph.scanned} scanned)`,
3225
+ nodeId: targetId,
3226
+ fix: found.length ? { tool: "manage_components", reason: "Re-instantiate from a living component, or detach deliberately", args: { action: "list" } } : void 0
3227
+ });
3228
+ }
3229
+ } catch {
3230
+ }
3162
3231
  const passed = results.filter((r) => r.passed).length;
3163
3232
  const failed = results.filter((r) => !r.passed).length;
3164
3233
  const data = {
@@ -3361,7 +3430,7 @@ function listToolCatalog() {
3361
3430
  }
3362
3431
 
3363
3432
  // ../../docs/skills/figma-plugin-api.md
3364
- 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- **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<!-- 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';
3433
+ 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- **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<!-- 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';
3365
3434
 
3366
3435
  // ../../docs/skills/design-tokens-strategy.md
3367
3436
  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";
@@ -3459,4 +3528,4 @@ export {
3459
3528
  listToolCatalog,
3460
3529
  registerPrompts
3461
3530
  };
3462
- //# sourceMappingURL=chunk-5YB7XMT6.js.map
3531
+ //# sourceMappingURL=chunk-2AVINEFS.js.map