figma-relai 0.7.11 → 0.8.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.
@@ -4,81 +4,13 @@ import {
4
4
  } from "./chunk-SDINVROO.js";
5
5
  import {
6
6
  loadToken,
7
- noteAuthFailure
8
- } from "./chunk-VS2FNFYC.js";
7
+ noteAuthFailure,
8
+ stateDir
9
+ } from "./chunk-INWZM5QK.js";
9
10
  import {
10
11
  __export
11
12
  } from "./chunk-2H7UOFLK.js";
12
13
 
13
- // src/version.ts
14
- var VERSION = "0.7.11";
15
-
16
- // src/server.ts
17
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
- function createServer() {
19
- return new McpServer(
20
- {
21
- name: "Relai",
22
- version: VERSION
23
- },
24
- {
25
- instructions: `
26
- You control Figma through ~30 consolidated tools. Pairing with the Figma plugin is automatic \u2014 just call any tool; join_room is only needed if an error reports multiple plugins.
27
-
28
- \u{1F4D6} UNDERSTAND (start here):
29
- get_document_overview \u2014 structure, page/component/style/variable counts
30
- get_selection_context \u2014 full context for what the designer selected
31
- get_node_details \u2014 deep single-node inspection (CSS, token bindings)
32
- search_nodes \u2014 find nodes by name/type \xB7 get_node_data \u2014 raw data/tree/css
33
- get_design_tokens \u2014 variable collections, modes, styles
34
- screenshot \u2014 see the canvas (use this to verify your work visually)
35
-
36
- \u{1F50D} DIAGNOSE:
37
- analyze_design \u2014 aspect: color/layout/components/accessibility, or
38
- "overall" for a weighted 0-100 health score across all four
39
- diff_nodes \u2014 compare two nodes, or checkpoint save/compare to audit
40
- what changed on a node over an editing session
41
-
42
- \u270F\uFE0F EDIT:
43
- create_node \u2014 any node type (rectangle/frame/text/svg/image/\u2026)
44
- set_properties \u2014 one call for geometry, fills, strokes, effects, text,
45
- auto-layout, constraints, style/variable bindings, on one or many nodes
46
- set_text \u2014 single, bulk, or character-range text edits
47
- edit_structure \u2014 group/reparent/reorder/clone/flatten/boolean/delete
48
- navigate \u2014 focus/select/viewport/switch_page \xB7 manage_pages
49
-
50
- \u{1F9F1} DESIGN SYSTEM:
51
- manage_components \u2014 create/variants/instantiate/overrides/props/detach
52
- manage_variables \u2014 collections, modes, tokens, bind/unbind
53
- manage_styles \u2014 paint/text/effect/grid styles
54
- import_from_library \u2014 bring in library components/styles/variables
55
-
56
- \u{1F4E6} ASSETS: export_asset (PNG/JPG/SVG/PDF) \xB7 add_image (URL or fill)
57
- \u{1F4DD} ANNOTATIONS: annotate (Dev Mode annotations)
58
- \u{1F4AC} COMMENTS: manage_comments \u2014 read/apply/reply to file comments
59
- (needs FIGMA_TOKEN env; the tool explains setup when missing)
60
-
61
- \u2705 VERIFY: verify_changes \xB7 validate_design_rules
62
-
63
- \u26A1 ADVANCED:
64
- batch_execute \u2014 run many plugin-level commands in one round-trip
65
- execute_figma \u2014 run JavaScript against the Figma Plugin API directly
66
- (the escape hatch when no tool fits; small incremental scripts,
67
- screenshot between steps; the designer can disable it)
68
-
69
- OPERATING PRINCIPLES:
70
- - Inspect context BEFORE changing things; don't assume selection state
71
- - After visual edits, verify: screenshot or verify_changes
72
- - Colors are RGBA in 0-1 range (Figma format), not 0-255
73
- - layoutSizing FILL requires the node's PARENT to have auto-layout;
74
- set layoutMode on a frame before padding/alignment/spacing
75
- - Responses include recommended_next tools \u2014 follow them
76
- - Errors name the node, its type, and the fix \u2014 read them, don't retry blindly
77
- `
78
- }
79
- );
80
- }
81
-
82
14
  // ../shared/src/types/commands.ts
83
15
  var FIGMA_COMMANDS = [
84
16
  // Document & Selection
@@ -678,9 +610,157 @@ var PITFALLS = [
678
610
  pattern: null,
679
611
  hint: "",
680
612
  doc: "**Renaming a variant axis with `editComponentProperty` unbinds the instances, and can reset their plain overrides too.** Instances bound to that property keep their value but lose the `boundVariables.componentProperties` alias, nested explicit overrides included. In one measured rename, 71 instances whose value for the axis was a plain override \u2014 not bound to anything \u2014 fell back to the set's default variant. Right after rebinding, some instances kept an old value that disagreed with what the mode resolves to; writing the value first and then rebinding fixed them. Before renaming an axis, record every instance's value and binding for it; afterwards write the values back, rebind, and read both back."
613
+ },
614
+ {
615
+ pattern: null,
616
+ hint: "",
617
+ doc: "**`figma.skipInvisibleInstanceChildren` is plugin-wide, not per script.** Setting it to `true` \u2014 a common speed-up for `findAll` \u2014 hides invisible sublayers of instances from every search that runs while it is set, not only the script that set it. Relai puts the previous value back after each `execute_figma` script, so it does not leak into later commands; set it again in every script that wants it, and while it is on, do not read an instance sublayer missing from a search as deleted."
681
618
  }
682
619
  ];
683
620
 
621
+ // ../shared/src/types/call.ts
622
+ var ERROR_CODES = [
623
+ // Server: reaching the relay and the plugin
624
+ "not_connected",
625
+ // no live link to the relay when the call was made; nothing was sent
626
+ "relay_unresponsive",
627
+ // the relay did not answer a room listing or join in time; nothing changed
628
+ "plugin_absent",
629
+ // no Figma plugin is in the target room
630
+ "room_ambiguous",
631
+ // several plugins are open and the call did not say which room
632
+ "room_changed",
633
+ // the session was re-paired to a room on a different file; resend to confirm
634
+ "wrong_file",
635
+ // the target file's name is not the expected one (server precheck or plugin recheck)
636
+ "cannot_verify_file",
637
+ // an expected file was given but the plugin did not report its file name
638
+ // Plugin: refused before the command ran
639
+ "unknown_command",
640
+ // the plugin has no handler for the command (plugin older than the server)
641
+ "denied_by_designer",
642
+ // the designer declined the approval card
643
+ "approval_timeout",
644
+ // nobody answered the approval card before it timed out
645
+ "cancelled",
646
+ // Stop pressed before the command started or while its card waited; also skipped batch items
647
+ "guard_blocked",
648
+ // the write touches a page the designer marked as a no-go zone
649
+ "scope_locked",
650
+ // the write falls outside the designer's scope lock
651
+ "designer_only",
652
+ // the command is reserved for the designer's panel
653
+ "code_exec_disabled",
654
+ // code execution is switched off in the panel
655
+ "node_not_found",
656
+ // a referenced node does not exist (checked before any change)
657
+ "precondition_failed",
658
+ // a referenced node has the wrong type or is locked (checked before any change)
659
+ "capacity_exceeded",
660
+ // file memory (precedents or rulesets) is full
661
+ "invalid_params",
662
+ // the arguments are malformed (from the plugin or the server's own checks)
663
+ "script_compile_error",
664
+ // the script did not compile, so none of it ran
665
+ // Plugin: failed while running
666
+ "script_error",
667
+ // the script threw while running; scripts are not atomic, earlier changes stay
668
+ "handler_error",
669
+ // any other error thrown by a command handler
670
+ "figma_internal",
671
+ // Figma itself reported an internal error (it reads like a connection error but is not one)
672
+ // Server: lost track of a request in flight
673
+ "timeout",
674
+ // no result arrived in time; the command may have run
675
+ "link_dropped",
676
+ // the link to the plugin closed while the request was in flight
677
+ "cancel_unconfirmed",
678
+ // Stop was pressed but no real result followed in time
679
+ // Server: tools that do not go through the plugin
680
+ "tool_error"
681
+ // a tool that talks to Figma's REST API or local state failed
682
+ ];
683
+ function isErrorCode(value) {
684
+ return typeof value === "string" && ERROR_CODES.includes(value);
685
+ }
686
+ var ERROR_DEFAULTS = {
687
+ not_connected: { outcome: "not_applied", retry: "retryable" },
688
+ relay_unresponsive: { outcome: "not_applied", retry: "retryable" },
689
+ plugin_absent: { outcome: "not_applied", retry: "needs_human" },
690
+ room_ambiguous: { outcome: "not_applied", retry: "fix_input" },
691
+ room_changed: { outcome: "not_applied", retry: "retryable" },
692
+ wrong_file: { outcome: "not_applied", retry: "fix_input" },
693
+ cannot_verify_file: { outcome: "not_applied", retry: "fix_input" },
694
+ unknown_command: { outcome: "not_applied", retry: "needs_human" },
695
+ denied_by_designer: { outcome: "not_applied", retry: "needs_human" },
696
+ approval_timeout: { outcome: "not_applied", retry: "needs_human" },
697
+ cancelled: { outcome: "not_applied", retry: "needs_human" },
698
+ guard_blocked: { outcome: "not_applied", retry: "needs_human" },
699
+ scope_locked: { outcome: "not_applied", retry: "needs_human" },
700
+ designer_only: { outcome: "not_applied", retry: "needs_human" },
701
+ code_exec_disabled: { outcome: "not_applied", retry: "needs_human" },
702
+ node_not_found: { outcome: "not_applied", retry: "read_canvas_first" },
703
+ precondition_failed: { outcome: "not_applied", retry: "fix_input" },
704
+ capacity_exceeded: { outcome: "not_applied", retry: "fix_input" },
705
+ invalid_params: { outcome: "not_applied", retry: "fix_input" },
706
+ script_compile_error: { outcome: "not_applied", retry: "fix_input" },
707
+ script_error: { outcome: "unknown", retry: "read_canvas_first" },
708
+ handler_error: { outcome: "unknown", retry: "read_canvas_first" },
709
+ figma_internal: { outcome: "unknown", retry: "read_canvas_first" },
710
+ timeout: { outcome: "unknown", retry: "read_canvas_first" },
711
+ link_dropped: { outcome: "unknown", retry: "read_canvas_first" },
712
+ cancel_unconfirmed: { outcome: "unknown", retry: "read_canvas_first" },
713
+ tool_error: { outcome: "not_applied", retry: "fix_input" }
714
+ };
715
+ var CATEGORY_CODES = {
716
+ precondition_failed: { code: "precondition_failed" },
717
+ invalid_input: { code: "invalid_params" },
718
+ type_mismatch: { code: "precondition_failed" },
719
+ partial_failure: { code: "tool_error", outcome: "applied", retry: "read_canvas_first", partial: true },
720
+ state_changed: { code: "tool_error", outcome: "not_applied", retry: "read_canvas_first" },
721
+ capability_limit: { code: "capacity_exceeded" },
722
+ connection_error: { code: "not_connected" }
723
+ };
724
+ var RelaiError = class extends Error {
725
+ code;
726
+ outcome;
727
+ retry;
728
+ partial;
729
+ constructor(code, message, options = {}) {
730
+ super(message);
731
+ this.name = "RelaiError";
732
+ this.code = code;
733
+ this.outcome = options.outcome ?? ERROR_DEFAULTS[code].outcome;
734
+ this.retry = options.retry ?? ERROR_DEFAULTS[code].retry;
735
+ if (options.partial) this.partial = true;
736
+ }
737
+ };
738
+ var PROTOCOL_VERSION = 2;
739
+ var CALL_META_KEY = "io.github.syoooo.figma-relai/call";
740
+ function aggregateOutcome(outcomes) {
741
+ if (outcomes.includes("unknown")) return { outcome: "unknown" };
742
+ if (outcomes.includes("applied")) {
743
+ return outcomes.includes("not_applied") ? { outcome: "applied", partial: true } : { outcome: "applied" };
744
+ }
745
+ return { outcome: "not_applied" };
746
+ }
747
+ function inferLegacyCode(error) {
748
+ const message = typeof error === "string" ? error : error?.message ?? "";
749
+ const pick = (code) => ({ code, ...ERROR_DEFAULTS[code] });
750
+ if (message.includes("no-go zone")) return pick("guard_blocked");
751
+ if (message.includes("Scope lock:")) return pick("scope_locked");
752
+ if (message.includes("Code execution is disabled")) return pick("code_exec_disabled");
753
+ if (message.includes("Node not found") || / has been deleted\. /.test(message)) {
754
+ return pick("node_not_found");
755
+ }
756
+ if (message.includes("Denied by designer")) return pick("denied_by_designer");
757
+ if (typeof error !== "string" && error?.cancelled === true && message.includes("Cancelled by designer")) {
758
+ return pick("cancel_unconfirmed");
759
+ }
760
+ if (message.includes("Unknown command")) return pick("unknown_command");
761
+ return pick("handler_error");
762
+ }
763
+
684
764
  // ../shared/src/utils/color.ts
685
765
  function rgbaToHex(color) {
686
766
  const r = Math.round(color.r * 255);
@@ -771,6 +851,9 @@ var reorderDirectionSchema = z.enum([
771
851
  ]);
772
852
 
773
853
  // ../shared/src/relay-core.ts
854
+ function isAllowedOrigin(origin) {
855
+ return origin === void 0 || origin === null || origin === "" || origin === "null";
856
+ }
774
857
  var RelayCore = class {
775
858
  rooms = /* @__PURE__ */ new Map();
776
859
  lastActivity = /* @__PURE__ */ new Map();
@@ -955,105 +1038,298 @@ var RelayCore = class {
955
1038
  }
956
1039
  };
957
1040
 
958
- // src/tools/core/file-identity.ts
959
- function addressableKey(identity) {
960
- return identity.branchKey ?? identity.fileKey;
1041
+ // src/version.ts
1042
+ var VERSION = "0.8.0";
1043
+
1044
+ // src/tools/index.ts
1045
+ import { z as z24 } from "zod";
1046
+
1047
+ // src/tools/core/helpers.ts
1048
+ function textResult(text) {
1049
+ return { content: [{ type: "text", text }] };
961
1050
  }
962
- function describeIdentity(identity) {
963
- return identity.branchName ? `${identity.fileName} (branch "${identity.branchName}")` : identity.fileName;
1051
+ function jsonResult(value) {
1052
+ return textResult(typeof value === "string" ? value : JSON.stringify(value, null, 2));
964
1053
  }
965
- async function figmaApi(token, path) {
966
- const res = await fetch(`https://api.figma.com/v1${path}`, {
967
- headers: { "X-Figma-Token": token }
968
- });
969
- noteAuthFailure(res.status);
970
- const json = await res.json().catch(() => ({}));
971
- return { ok: res.ok, status: res.status, json };
1054
+ function errorResult(error) {
1055
+ const code = error instanceof RelaiError ? error.code : "tool_error";
1056
+ const retry = error instanceof RelaiError ? error.retry : ERROR_DEFAULTS.tool_error.retry;
1057
+ const meta = { v: 1, error: { code, retry }, calls: [] };
1058
+ return {
1059
+ ...textResult(`Error: ${error instanceof Error ? error.message : String(error)}`),
1060
+ isError: true,
1061
+ _meta: { [CALL_META_KEY]: meta }
1062
+ };
972
1063
  }
973
- var FileIdentityError = class extends Error {
974
- };
975
- async function resolveFileIdentity(sendCommand) {
976
- const probe = await sendCommand("get_file_identity", {});
977
- const cached = probe?.identity?.fileKey ? probe.identity : null;
978
- if (cached && cached.by === VERSION) return cached;
979
- const rootName = probe?.rootName ?? "";
980
- const candidates = probe?.candidates ?? [];
981
- if (candidates.length === 0) {
982
- if (cached) return cached;
983
- throw new FileIdentityError(
984
- "This file publishes no components, so its key cannot be resolved from the plugin (figma.fileKey is private-plugin-only)."
985
- );
1064
+
1065
+ // src/call-envelope.ts
1066
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
1067
+ import { z as z2 } from "zod";
1068
+
1069
+ // src/call-scope.ts
1070
+ import { AsyncLocalStorage } from "async_hooks";
1071
+ var slots = new AsyncLocalStorage();
1072
+ function runInCommandSlot(slot, fn) {
1073
+ return slots.run(slot, fn);
1074
+ }
1075
+ function currentCommandSlot() {
1076
+ return slots.getStore();
1077
+ }
1078
+ function noteSettled(slot, settled) {
1079
+ if (slot && !slot.closed) slot.settled = settled;
1080
+ }
1081
+
1082
+ // src/result-files.ts
1083
+ import { mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "fs";
1084
+ import { join } from "path";
1085
+ import { randomUUID } from "crypto";
1086
+ var KEEP_FILES = 50;
1087
+ var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
1088
+ function resultsDir() {
1089
+ return join(stateDir(), "results");
1090
+ }
1091
+ function writeResultFile(callId, text) {
1092
+ try {
1093
+ const dir = resultsDir();
1094
+ mkdirSync(dir, { recursive: true, mode: 448 });
1095
+ pruneResultFiles(dir, KEEP_FILES - 1);
1096
+ const id = (callId ?? "").replace(/[^A-Za-z0-9_-]/g, "") || randomUUID();
1097
+ const path = join(dir, `${Date.now()}-${id}.${looksLikeJson(text) ? "json" : "txt"}`);
1098
+ writeFileSync(path, text, { mode: 384 });
1099
+ return path;
1100
+ } catch {
1101
+ return void 0;
986
1102
  }
987
- const token = loadToken();
988
- if (!token) {
989
- throw new FileIdentityError(
990
- "Resolving the file key needs a Figma token \u2014 store one with `npx -y figma-relai@latest login`."
991
- );
1103
+ }
1104
+ function pruneResultFiles(dir = resultsDir(), keep = KEEP_FILES) {
1105
+ const now = Date.now();
1106
+ const files = readdirSync(dir).filter((name) => name.endsWith(".json") || name.endsWith(".txt")).map((name) => ({ path: join(dir, name), mtime: statSync(join(dir, name)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
1107
+ files.forEach((file, index) => {
1108
+ if (index >= keep || now - file.mtime > MAX_AGE_MS) {
1109
+ try {
1110
+ unlinkSync(file.path);
1111
+ } catch {
1112
+ }
1113
+ }
1114
+ });
1115
+ }
1116
+ function looksLikeJson(text) {
1117
+ try {
1118
+ JSON.parse(text);
1119
+ return true;
1120
+ } catch {
1121
+ return false;
992
1122
  }
993
- let main = null;
994
- const statuses = [];
995
- for (const c of candidates) {
996
- const paths = c.kind === "set" ? ["/component_sets/", "/components/"] : ["/components/", "/component_sets/"];
997
- for (const p of paths) {
998
- const res = await figmaApi(token, `${p}${c.key}`);
999
- statuses.push(res.status);
1000
- const meta = res.json.meta ?? {};
1001
- if (res.ok && typeof meta.file_key === "string") {
1002
- main = { fileKey: meta.file_key, via: c.key };
1003
- break;
1123
+ }
1124
+
1125
+ // src/call-envelope.ts
1126
+ var MAX_TEXT_CHARS = 5e4;
1127
+ var RECEIPT_LABEL = "[relai]";
1128
+ var scopes = new AsyncLocalStorage2();
1129
+ var WRAPPED = /* @__PURE__ */ Symbol("relai.callEnvelope");
1130
+ var ENVELOPED_SERVER = /* @__PURE__ */ Symbol("relai.envelopedServer");
1131
+ var TARGET_SHAPE = {
1132
+ room: z2.string().optional().describe("Target this plugin room for this call only (see join_room)."),
1133
+ expect_file: z2.string().optional().describe("Refuse unless the target file's name is exactly this.")
1134
+ };
1135
+ function currentCallTarget() {
1136
+ const scope = scopes.getStore();
1137
+ return {
1138
+ ...scope?.room !== void 0 ? { room: scope.room } : {},
1139
+ ...scope?.expectFile !== void 0 ? { expectFile: scope.expectFile } : {}
1140
+ };
1141
+ }
1142
+ function withCallEnvelope(server, sendCommand) {
1143
+ if (server[ENVELOPED_SERVER]) return { server, sendCommand };
1144
+ const wrappedServer = new Proxy(server, {
1145
+ get(target, prop, receiver) {
1146
+ if (prop === ENVELOPED_SERVER) return true;
1147
+ if (prop !== "tool") return Reflect.get(target, prop, receiver);
1148
+ return (name, ...rest) => {
1149
+ if (rest.length !== 3 && rest.length !== 4 || typeof rest[0] !== "string") {
1150
+ throw new Error(
1151
+ `Tool "${name}" must be registered as (name, description, shape, [annotations,] handler) to get a call envelope`
1152
+ );
1153
+ }
1154
+ const handler = rest[rest.length - 1];
1155
+ const annotations = rest.length === 4 ? rest[2] : void 0;
1156
+ const wrapped = envelopeHandler(name, handler, annotations?.readOnlyHint === true);
1157
+ const shape = { ...TARGET_SHAPE, ...rest[1] };
1158
+ return target.tool.call(target, name, rest[0], shape, ...rest.slice(2, -1), wrapped);
1159
+ };
1160
+ }
1161
+ });
1162
+ const wrappedSend = async (command, params, timeoutMs) => {
1163
+ const scope = scopes.getStore();
1164
+ const slot = { closed: false };
1165
+ try {
1166
+ const result = await runInCommandSlot(slot, () => sendCommand(command, params, timeoutMs));
1167
+ slot.closed = true;
1168
+ if (scope) recordSuccess(scope, command, slot, result);
1169
+ return result;
1170
+ } catch (error) {
1171
+ slot.closed = true;
1172
+ const coded = toRelaiError(error);
1173
+ if (scope) {
1174
+ scope.receipts.push({ id: slot.settled?.id ?? "", command, outcome: coded.outcome, code: coded.code });
1175
+ if (coded.partial) scope.partial = true;
1176
+ if (slot.settled?.cancelRequested) scope.cancelRequested = true;
1177
+ scope.lastError = coded;
1004
1178
  }
1179
+ throw coded;
1180
+ }
1181
+ };
1182
+ return { server: wrappedServer, sendCommand: wrappedSend };
1183
+ }
1184
+ function envelopeHandler(name, handler, readOnly) {
1185
+ const wrapped = async (...args) => {
1186
+ const scope = { receipts: [], partial: false, cancelRequested: false };
1187
+ const { room, expect_file: expectFile, ...own } = args[0] ?? {};
1188
+ if (typeof room === "string") scope.room = room;
1189
+ if (typeof expectFile === "string") scope.expectFile = expectFile;
1190
+ const toolArgs = args[0] === void 0 ? args : [own, ...args.slice(1)];
1191
+ let result;
1192
+ try {
1193
+ result = await scopes.run(scope, () => Promise.resolve(handler(...toolArgs)));
1194
+ } catch (error) {
1195
+ result = errorResult(error);
1196
+ }
1197
+ return finalizeResult(name, result, scope, readOnly);
1198
+ };
1199
+ wrapped[WRAPPED] = true;
1200
+ return wrapped;
1201
+ }
1202
+ function toRelaiError(error) {
1203
+ if (error instanceof RelaiError) return error;
1204
+ const message = error instanceof Error ? error.message : String(error);
1205
+ const { code, outcome, retry } = inferLegacyCode(message);
1206
+ return new RelaiError(code, message, { outcome, retry });
1207
+ }
1208
+ function recordSuccess(scope, command, slot, result) {
1209
+ const meta = slot.settled?.meta;
1210
+ const receipt = { id: slot.settled?.id ?? "", command, outcome: meta?.outcome ?? "applied" };
1211
+ if (typeof meta?.write === "boolean") receipt.write = meta.write;
1212
+ if (meta?.partial) scope.partial = true;
1213
+ if (meta?.file) scope.file = meta.file;
1214
+ if (slot.settled?.cancelRequested) scope.cancelRequested = true;
1215
+ if (command === "batch_execute" && Array.isArray(result)) {
1216
+ receipt.items = result.map(batchItem);
1217
+ if (!meta) {
1218
+ const judged = aggregateOutcome(receipt.items.map((item) => item.outcome));
1219
+ receipt.outcome = judged.outcome;
1220
+ if (judged.partial) scope.partial = true;
1005
1221
  }
1006
- if (main) break;
1007
1222
  }
1008
- if (!main) {
1009
- const denied = statuses.length > 0 && statuses.every((s) => s === 401 || s === 403);
1010
- throw new FileIdentityError(
1011
- denied ? "Figma rejected the token on every probe (401/403) \u2014 it is expired or lacks the file content read scope. Re-run `npx -y figma-relai@latest login`." : "None of this file's published component keys resolved."
1012
- );
1223
+ scope.receipts.push(receipt);
1224
+ }
1225
+ function batchItem(raw) {
1226
+ const item = raw ?? {};
1227
+ const command = typeof item.command === "string" ? item.command : "";
1228
+ const code = isErrorCode(item.code) ? item.code : void 0;
1229
+ const stated = item.outcome === "applied" || item.outcome === "not_applied" || item.outcome === "unknown" ? item.outcome : void 0;
1230
+ if (item.success === true) return { command, outcome: stated ?? "applied" };
1231
+ if (code) return { command, outcome: stated ?? ERROR_DEFAULTS[code].outcome, code };
1232
+ const legacy = inferLegacyCode(typeof item.error === "string" ? item.error : "");
1233
+ return { command, outcome: stated ?? legacy.outcome, code: legacy.code };
1234
+ }
1235
+ function finalizeResult(name, result, scope, readOnly) {
1236
+ const declared = result._meta?.[CALL_META_KEY] ?? void 0;
1237
+ const content = [...result.content ?? []];
1238
+ const bodyIndex = content.findIndex((item) => item.type === "text" && typeof item.text === "string");
1239
+ const body = bodyIndex >= 0 ? content[bodyIndex].text : void 0;
1240
+ const receipts = scope.receipts;
1241
+ const last = receipts[receipts.length - 1];
1242
+ let isError = result.isError === true;
1243
+ if (!isError && last?.code && body?.startsWith("Error: ")) isError = true;
1244
+ let error = declared?.error;
1245
+ if (isError && last?.code && scope.lastError) {
1246
+ error = { code: scope.lastError.code, retry: scope.lastError.retry };
1247
+ } else if (isError && !error) {
1248
+ error = { code: "tool_error", retry: ERROR_DEFAULTS.tool_error.retry };
1013
1249
  }
1014
- const file = await figmaApi(token, `/files/${main.fileKey}?branch_data=true&depth=1`);
1015
- const identity = {
1016
- fileKey: main.fileKey,
1017
- fileName: typeof file.json.name === "string" ? file.json.name : rootName,
1018
- by: VERSION
1019
- };
1020
- if (rootName && rootName !== identity.fileName) {
1021
- const branches = file.json.branches ?? [];
1022
- const hit = branches.find((b) => b.name === rootName);
1023
- if (hit?.key) {
1024
- identity.branchKey = hit.key;
1025
- identity.branchName = hit.name;
1250
+ const isRead = (r) => r.write === false || r.write === void 0 && readOnly;
1251
+ const calls = receipts.map(
1252
+ (r) => isRead(r) && r.outcome !== "applied" ? { ...r, outcome: "not_applied" } : r
1253
+ );
1254
+ let outcome;
1255
+ let partial = scope.partial;
1256
+ if (calls.length > 0) {
1257
+ const writes = calls.filter((r) => !isRead(r));
1258
+ if (writes.length > 0) {
1259
+ const judged = aggregateOutcome(writes.map((r) => r.outcome));
1260
+ outcome = judged.outcome;
1261
+ partial = partial || judged.partial === true;
1262
+ } else {
1263
+ outcome = !isError && calls.some((r) => r.outcome === "applied") ? "applied" : "not_applied";
1026
1264
  }
1027
1265
  }
1028
- try {
1029
- await sendCommand("set_file_identity", { identity, componentKey: main.via });
1030
- } catch {
1266
+ if (declared?.outcome) {
1267
+ outcome = outcome === "unknown" ? "unknown" : declared.outcome;
1268
+ partial = partial || declared.partial === true;
1031
1269
  }
1032
- return identity;
1270
+ if (error && outcome === "unknown") error = { ...error, retry: "read_canvas_first" };
1271
+ let truncated = declared?.truncated;
1272
+ if (body !== void 0 && body.length > MAX_TEXT_CHARS) {
1273
+ const whole = !truncated;
1274
+ const file = truncated?.file ?? (whole ? writeResultFile(last?.id, body) : void 0);
1275
+ const cut = {
1276
+ total: whole ? body.length : truncated.total,
1277
+ returned: MAX_TEXT_CHARS,
1278
+ unit: "chars",
1279
+ ...file ? { file } : {}
1280
+ };
1281
+ truncated = cut;
1282
+ content[bodyIndex] = {
1283
+ ...content[bodyIndex],
1284
+ text: `${body.slice(0, MAX_TEXT_CHARS)}
1285
+ \u2026 [truncated: returned ${cut.returned} of ${cut.total} chars; ` + (file ? `the full result is in ${file}]` : "the full result could not be saved]")
1286
+ };
1287
+ }
1288
+ const meta = {
1289
+ v: 1,
1290
+ ...outcome ? { outcome } : {},
1291
+ ...outcome && partial ? { partial: true } : {},
1292
+ ...error ? { error } : {},
1293
+ calls,
1294
+ ...scope.file || scope.room ? { target: { ...scope.room ? { room: scope.room } : {}, ...scope.file ? { file: scope.file } : {} } } : {},
1295
+ ...truncated ? { truncated } : {}
1296
+ };
1297
+ const wrote = calls.some((r) => !isRead(r));
1298
+ const showReceipt = isError || outcome === "unknown" || partial || scope.room !== void 0 || scope.expectFile !== void 0 || wrote && outcome !== void 0;
1299
+ if (showReceipt) {
1300
+ content.push({ type: "text", text: receiptLine(meta, last?.id, scope.cancelRequested) });
1301
+ }
1302
+ const { _meta: existingMeta, ...rest } = result;
1303
+ return {
1304
+ ...rest,
1305
+ content,
1306
+ ...isError ? { isError: true } : {},
1307
+ _meta: { ...existingMeta ?? {}, [CALL_META_KEY]: meta }
1308
+ };
1309
+ }
1310
+ var OUTCOME_WORDS = {
1311
+ applied: "applied \u2014 the command ran",
1312
+ not_applied: "not_applied \u2014 nothing was changed",
1313
+ unknown: "unknown \u2014 it may have changed the file; read the canvas before retrying"
1314
+ };
1315
+ function receiptLine(meta, callId, cancelRequested) {
1316
+ const parts = [
1317
+ meta.outcome ? `${RECEIPT_LABEL} ${OUTCOME_WORDS[meta.outcome]}${meta.partial ? " (partly: some of it was written)" : ""}` : `${RECEIPT_LABEL} failed`
1318
+ ];
1319
+ if (meta.error) parts.push(meta.error.code);
1320
+ if (cancelRequested) parts.push("the designer pressed Stop");
1321
+ if (callId) parts.push(`call ${callId.slice(0, 8)}`);
1322
+ if (meta.target?.file) parts.push(`file "${meta.target.file}"`);
1323
+ if (meta.target?.room) parts.push(`room ${meta.target.room}`);
1324
+ return parts.join(" \xB7 ");
1033
1325
  }
1034
-
1035
- // src/tools/index.ts
1036
- import { z as z23 } from "zod";
1037
1326
 
1038
1327
  // src/tools/core/create.ts
1039
1328
  var create_exports = {};
1040
1329
  __export(create_exports, {
1041
1330
  register: () => register
1042
1331
  });
1043
- import { z as z2 } from "zod";
1044
-
1045
- // src/tools/core/helpers.ts
1046
- function textResult(text) {
1047
- return { content: [{ type: "text", text }] };
1048
- }
1049
- function jsonResult(value) {
1050
- return textResult(typeof value === "string" ? value : JSON.stringify(value, null, 2));
1051
- }
1052
- function errorResult(error) {
1053
- return textResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
1054
- }
1055
-
1056
- // src/tools/core/create.ts
1332
+ import { z as z3 } from "zod";
1057
1333
  var NODE_KINDS = [
1058
1334
  "rectangle",
1059
1335
  "frame",
@@ -1093,33 +1369,33 @@ function register(server, sendCommand) {
1093
1369
  "create_node",
1094
1370
  "Create a node in Figma: rectangle, frame, text, ellipse, polygon, star, line, section, slice, svg (from markup), image (from URL), or FigJam connector/table/sticky/code_block. Returns the new node's id \u2014 style it further with set_properties. For component instances use manage_components.",
1095
1371
  {
1096
- type: z2.enum(NODE_KINDS).describe("What to create"),
1097
- x: z2.number().optional().describe("X position (default 0)"),
1098
- y: z2.number().optional().describe("Y position (default 0)"),
1372
+ type: z3.enum(NODE_KINDS).describe("What to create"),
1373
+ x: z3.number().optional().describe("X position (default 0)"),
1374
+ y: z3.number().optional().describe("Y position (default 0)"),
1099
1375
  width: dimensionSchema.optional(),
1100
1376
  height: dimensionSchema.optional(),
1101
- name: z2.string().optional(),
1102
- parentId: z2.string().optional().describe("Parent node id (default: current page)"),
1103
- text: z2.string().optional().describe("Text content (text/sticky)"),
1104
- fontSize: z2.number().positive().optional().describe("text only"),
1105
- fontWeight: z2.number().optional().describe("text only (e.g. 400, 700)"),
1377
+ name: z3.string().optional(),
1378
+ parentId: z3.string().optional().describe("Parent node id (default: current page)"),
1379
+ text: z3.string().optional().describe("Text content (text/sticky)"),
1380
+ fontSize: z3.number().positive().optional().describe("text only"),
1381
+ fontWeight: z3.number().optional().describe("text only (e.g. 400, 700)"),
1106
1382
  fontColor: colorSchema.optional().describe("text only"),
1107
1383
  fillColor: colorSchema.optional().describe("frame/rectangle/section fill"),
1108
1384
  strokeColor: colorSchema.optional().describe("frame/rectangle/section stroke"),
1109
- cornerRadius: z2.number().min(0).optional().describe("section corner radius \u2014 a new section defaults to radius 2 with a 10% black stroke, which matches no design system; pass what yours uses (for other node types, set radius with set_properties)"),
1110
- pointCount: z2.number().int().min(3).optional().describe("polygon/star points"),
1111
- innerRadius: z2.number().min(0).max(1).optional().describe("star inner radius ratio"),
1112
- length: z2.number().positive().optional().describe("line length"),
1113
- rotation: z2.number().optional().describe("line rotation in degrees"),
1114
- strokeWeight: z2.number().min(0).optional().describe("line stroke weight"),
1115
- svg: z2.string().optional().describe("SVG markup (type=svg)"),
1116
- url: z2.string().optional().describe("Image URL (type=image)"),
1117
- scaleMode: z2.enum(["FILL", "FIT", "CROP", "TILE"]).optional().describe("image only"),
1118
- rows: z2.number().int().positive().optional().describe("table only"),
1119
- cols: z2.number().int().positive().optional().describe("table only"),
1120
- startNodeId: z2.string().optional().describe("connector only"),
1121
- endNodeId: z2.string().optional().describe("connector only"),
1122
- lineType: z2.enum(["ELBOWED", "STRAIGHT"]).optional().describe("connector only")
1385
+ cornerRadius: z3.number().min(0).optional().describe("section corner radius \u2014 a new section defaults to radius 2 with a 10% black stroke, which matches no design system; pass what yours uses (for other node types, set radius with set_properties)"),
1386
+ pointCount: z3.number().int().min(3).optional().describe("polygon/star points"),
1387
+ innerRadius: z3.number().min(0).max(1).optional().describe("star inner radius ratio"),
1388
+ length: z3.number().positive().optional().describe("line length"),
1389
+ rotation: z3.number().optional().describe("line rotation in degrees"),
1390
+ strokeWeight: z3.number().min(0).optional().describe("line stroke weight"),
1391
+ svg: z3.string().optional().describe("SVG markup (type=svg)"),
1392
+ url: z3.string().optional().describe("Image URL (type=image)"),
1393
+ scaleMode: z3.enum(["FILL", "FIT", "CROP", "TILE"]).optional().describe("image only"),
1394
+ rows: z3.number().int().positive().optional().describe("table only"),
1395
+ cols: z3.number().int().positive().optional().describe("table only"),
1396
+ startNodeId: z3.string().optional().describe("connector only"),
1397
+ endNodeId: z3.string().optional().describe("connector only"),
1398
+ lineType: z3.enum(["ELBOWED", "STRAIGHT"]).optional().describe("connector only")
1123
1399
  },
1124
1400
  async ({ type, ...params }) => {
1125
1401
  try {
@@ -1142,69 +1418,69 @@ __export(properties_exports, {
1142
1418
  propertiesSchema: () => propertiesSchema,
1143
1419
  register: () => register2
1144
1420
  });
1145
- import { z as z3 } from "zod";
1146
- var effectSchema = z3.object({
1147
- type: z3.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]),
1421
+ import { z as z4 } from "zod";
1422
+ var effectSchema = z4.object({
1423
+ type: z4.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]),
1148
1424
  color: colorSchema.optional().describe("Shadow color (shadows only)"),
1149
- offsetX: z3.number().optional(),
1150
- offsetY: z3.number().optional(),
1151
- radius: z3.number().min(0).optional().describe("Blur radius"),
1152
- spread: z3.number().optional()
1425
+ offsetX: z4.number().optional(),
1426
+ offsetY: z4.number().optional(),
1427
+ radius: z4.number().min(0).optional().describe("Blur radius"),
1428
+ spread: z4.number().optional()
1153
1429
  });
1154
- var propertiesSchema = z3.object({
1430
+ var propertiesSchema = z4.object({
1155
1431
  // Geometry
1156
- x: z3.number().optional(),
1157
- y: z3.number().optional(),
1432
+ x: z4.number().optional(),
1433
+ y: z4.number().optional(),
1158
1434
  width: dimensionSchema.optional(),
1159
1435
  height: dimensionSchema.optional(),
1160
- rotation: z3.number().optional().describe("Degrees"),
1436
+ rotation: z4.number().optional().describe("Degrees"),
1161
1437
  // Identity / visibility
1162
- name: z3.string().optional(),
1163
- visible: z3.boolean().optional(),
1164
- locked: z3.boolean().optional(),
1165
- opacity: z3.number().min(0).max(1).optional(),
1438
+ name: z4.string().optional(),
1439
+ visible: z4.boolean().optional(),
1440
+ locked: z4.boolean().optional(),
1441
+ opacity: z4.number().min(0).max(1).optional(),
1166
1442
  blendMode: blendModeSchema.optional(),
1167
1443
  // Paint
1168
1444
  fillColor: colorSchema.optional(),
1169
- fills: z3.array(z3.record(z3.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole fill list; [] clears all fills"),
1170
- strokes: z3.array(z3.record(z3.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole stroke list; [] clears all strokes"),
1445
+ fills: z4.array(z4.record(z4.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole fill list; [] clears all fills"),
1446
+ strokes: z4.array(z4.record(z4.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole stroke list; [] clears all strokes"),
1171
1447
  strokeColor: colorSchema.optional(),
1172
- strokeWeight: z3.number().min(0).optional(),
1173
- cornerRadius: z3.number().min(0).optional(),
1174
- corners: z3.array(z3.boolean()).length(4).optional().describe("With cornerRadius: apply per corner [TL, TR, BR, BL]"),
1175
- gradient: z3.object({ gradientType: gradientTypeSchema, stops: z3.array(gradientStopSchema).min(2) }).optional(),
1448
+ strokeWeight: z4.number().min(0).optional(),
1449
+ cornerRadius: z4.number().min(0).optional(),
1450
+ corners: z4.array(z4.boolean()).length(4).optional().describe("With cornerRadius: apply per corner [TL, TR, BR, BL]"),
1451
+ gradient: z4.object({ gradientType: gradientTypeSchema, stops: z4.array(gradientStopSchema).min(2) }).optional(),
1176
1452
  // Effects (replaces existing effects list entry of same type)
1177
- effects: z3.array(effectSchema).optional(),
1178
- removeEffects: z3.boolean().optional(),
1453
+ effects: z4.array(effectSchema).optional(),
1454
+ removeEffects: z4.boolean().optional(),
1179
1455
  // Text
1180
- text: z3.string().optional().describe("Text content (TEXT nodes)"),
1456
+ text: z4.string().optional().describe("Text content (TEXT nodes)"),
1181
1457
  // Auto-layout (frame itself)
1182
1458
  layoutMode: layoutModeSchema.optional(),
1183
1459
  layoutWrap: layoutWrapSchema.optional().describe("WRAP requires layoutMode HORIZONTAL"),
1184
- paddingTop: z3.number().min(0).optional(),
1185
- paddingRight: z3.number().min(0).optional(),
1186
- paddingBottom: z3.number().min(0).optional(),
1187
- paddingLeft: z3.number().min(0).optional(),
1460
+ paddingTop: z4.number().min(0).optional(),
1461
+ paddingRight: z4.number().min(0).optional(),
1462
+ paddingBottom: z4.number().min(0).optional(),
1463
+ paddingLeft: z4.number().min(0).optional(),
1188
1464
  primaryAxisAlignItems: primaryAxisAlignSchema.optional(),
1189
1465
  counterAxisAlignItems: counterAxisAlignSchema.optional(),
1190
- itemSpacing: z3.number().optional(),
1191
- counterAxisSpacing: z3.number().optional(),
1466
+ itemSpacing: z4.number().optional(),
1467
+ counterAxisSpacing: z4.number().optional(),
1192
1468
  // Auto-layout (as a child; FILL requires the PARENT to have auto-layout)
1193
1469
  layoutSizingHorizontal: sizingModeSchema.optional(),
1194
1470
  layoutSizingVertical: sizingModeSchema.optional(),
1195
1471
  // Constraints & sizing limits
1196
1472
  constraintsHorizontal: constraintTypeSchema.optional(),
1197
1473
  constraintsVertical: constraintTypeSchema.optional(),
1198
- minWidth: z3.number().min(0).optional(),
1199
- maxWidth: z3.number().min(0).optional(),
1200
- minHeight: z3.number().min(0).optional(),
1201
- maxHeight: z3.number().min(0).optional(),
1202
- aspectRatioLocked: z3.boolean().optional(),
1203
- clipsContent: z3.boolean().optional(),
1474
+ minWidth: z4.number().min(0).optional(),
1475
+ maxWidth: z4.number().min(0).optional(),
1476
+ minHeight: z4.number().min(0).optional(),
1477
+ maxHeight: z4.number().min(0).optional(),
1478
+ aspectRatioLocked: z4.boolean().optional(),
1479
+ clipsContent: z4.boolean().optional(),
1204
1480
  // Design-system bindings
1205
- styleId: z3.string().optional().describe("Apply a paint/text/effect/grid style by id"),
1206
- styleType: z3.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional(),
1207
- boundVariables: z3.record(z3.string()).optional().describe('Bind variables: {"fills": "VariableID:...", "width": "..."}')
1481
+ styleId: z4.string().optional().describe("Apply a paint/text/effect/grid style by id"),
1482
+ styleType: z4.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional(),
1483
+ boundVariables: z4.record(z4.string()).optional().describe('Bind variables: {"fills": "VariableID:...", "width": "..."}')
1208
1484
  }).strict();
1209
1485
  function defined(obj) {
1210
1486
  return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
@@ -1301,9 +1577,9 @@ function register2(server, sendCommand) {
1301
1577
  "set_properties",
1302
1578
  "Set any visual/layout/text properties on one or more nodes in a single call: geometry (x/y/width/height/rotation), fills/strokes/gradients/cornerRadius/opacity/effects, text content, auto-layout (layoutMode, padding, alignment, spacing, layoutSizing \u2014 FILL requires the parent to have auto-layout), constraints, style application, and variable bindings. Groups map to focused operations with per-operation success/error reporting. dryRun:true previews the mapped command list without touching the canvas.",
1303
1579
  {
1304
- nodeIds: z3.array(z3.string()).min(1).describe("Node IDs to update"),
1580
+ nodeIds: z4.array(z4.string()).min(1).describe("Node IDs to update"),
1305
1581
  properties: propertiesSchema.describe("Properties to apply to every listed node"),
1306
- dryRun: z3.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
1582
+ dryRun: z4.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
1307
1583
  },
1308
1584
  { idempotentHint: true },
1309
1585
  async ({ nodeIds, properties, dryRun }) => {
@@ -1342,13 +1618,13 @@ var structure_exports = {};
1342
1618
  __export(structure_exports, {
1343
1619
  register: () => register3
1344
1620
  });
1345
- import { z as z4 } from "zod";
1621
+ import { z as z5 } from "zod";
1346
1622
  function register3(server, sendCommand) {
1347
1623
  server.tool(
1348
1624
  "edit_structure",
1349
1625
  "Structural operations on nodes: group/ungroup, reparent (move into another parent), reorder (z-order), clone, flatten to vector, boolean operations (union/subtract/intersect/exclude), delete. group/boolean/delete take nodeIds; the rest take nodeId.",
1350
1626
  {
1351
- operation: z4.enum([
1627
+ operation: z5.enum([
1352
1628
  "group",
1353
1629
  "ungroup",
1354
1630
  "reparent",
@@ -1358,14 +1634,14 @@ function register3(server, sendCommand) {
1358
1634
  "boolean",
1359
1635
  "delete"
1360
1636
  ]),
1361
- nodeId: z4.string().optional().describe("Target node (ungroup/reparent/reorder/clone/flatten)"),
1362
- nodeIds: z4.array(z4.string()).optional().describe("Target nodes (group/boolean/delete)"),
1363
- parentId: z4.string().optional().describe("reparent: new parent"),
1364
- index: z4.number().int().min(0).optional().describe("reparent: insertion index"),
1637
+ nodeId: z5.string().optional().describe("Target node (ungroup/reparent/reorder/clone/flatten)"),
1638
+ nodeIds: z5.array(z5.string()).optional().describe("Target nodes (group/boolean/delete)"),
1639
+ parentId: z5.string().optional().describe("reparent: new parent"),
1640
+ index: z5.number().int().min(0).optional().describe("reparent: insertion index"),
1365
1641
  direction: reorderDirectionSchema.optional().describe("reorder direction"),
1366
1642
  booleanOperation: booleanOperationSchema.optional().describe("boolean: which operation"),
1367
- x: z4.number().optional().describe("clone: position of the copy"),
1368
- y: z4.number().optional()
1643
+ x: z5.number().optional().describe("clone: position of the copy"),
1644
+ y: z5.number().optional()
1369
1645
  },
1370
1646
  async ({ operation, nodeId, nodeIds, parentId, index, direction, booleanOperation, x, y }) => {
1371
1647
  try {
@@ -1429,22 +1705,22 @@ var text_exports = {};
1429
1705
  __export(text_exports, {
1430
1706
  register: () => register4
1431
1707
  });
1432
- import { z as z5 } from "zod";
1708
+ import { z as z6 } from "zod";
1433
1709
  function register4(server, sendCommand) {
1434
1710
  server.tool(
1435
1711
  "set_text",
1436
1712
  "Edit text nodes. Modes: single (nodeId + text), bulk (items \u2014 efficient for many nodes, e.g. translations), or character-range styling (nodeId + range with fontSize/fontWeight/letterSpacing/lineHeight). Fonts load automatically with fallbacks. To find text nodes first, use search_nodes or get_selection_context.",
1437
1713
  {
1438
- nodeId: z5.string().optional().describe("Target text node (single / range mode)"),
1439
- text: z5.string().optional().describe("New content (single mode)"),
1440
- items: z5.array(z5.object({ nodeId: z5.string(), text: z5.string() })).optional().describe("Bulk replacements"),
1441
- range: z5.object({
1442
- start: z5.number().int().min(0),
1443
- end: z5.number().int().positive(),
1444
- fontSize: z5.number().positive().optional(),
1445
- fontWeight: z5.number().optional(),
1446
- letterSpacing: z5.number().optional(),
1447
- lineHeight: z5.number().optional()
1714
+ nodeId: z6.string().optional().describe("Target text node (single / range mode)"),
1715
+ text: z6.string().optional().describe("New content (single mode)"),
1716
+ items: z6.array(z6.object({ nodeId: z6.string(), text: z6.string() })).optional().describe("Bulk replacements"),
1717
+ range: z6.object({
1718
+ start: z6.number().int().min(0),
1719
+ end: z6.number().int().positive(),
1720
+ fontSize: z6.number().positive().optional(),
1721
+ fontWeight: z6.number().optional(),
1722
+ letterSpacing: z6.number().optional(),
1723
+ lineHeight: z6.number().optional()
1448
1724
  }).refine((r) => r.end > r.start, { message: "end must be greater than start" }).optional().describe("Style a character range [start, end)")
1449
1725
  },
1450
1726
  async ({ nodeId, text, items, range }) => {
@@ -1480,13 +1756,13 @@ var components_exports = {};
1480
1756
  __export(components_exports, {
1481
1757
  register: () => register5
1482
1758
  });
1483
- import { z as z6 } from "zod";
1759
+ import { z as z7 } from "zod";
1484
1760
  function register5(server, sendCommand) {
1485
1761
  server.tool(
1486
1762
  "manage_components",
1487
1763
  "Component workflow: list local components, create a component from a node, create_set (combine components as variants), instantiate (place an instance by componentKey OR by the node id of a local component \u2014 an unpublished component has no importable key, so the id is the way), add_property / bind_property (declare a TEXT/BOOLEAN/INSTANCE_SWAP property on a SET \u2014 never on a single variant \u2014 then point a layer's characters/visible at it, in every variant at once with layerName), get_props / set_props (component properties on an instance; get_props on a variant answers with its set's definitions), get_overrides / set_overrides (copy overrides from a source instance to targets), reset_instance (clear ALL overrides so the instance re-inherits its main component \u2014 returns property snapshots before/after so you can re-apply what mattered), detach.",
1488
1764
  {
1489
- action: z6.enum([
1765
+ action: z7.enum([
1490
1766
  "list",
1491
1767
  "create",
1492
1768
  "create_set",
@@ -1500,21 +1776,21 @@ function register5(server, sendCommand) {
1500
1776
  "add_property",
1501
1777
  "bind_property"
1502
1778
  ]),
1503
- nodeId: z6.string().optional().describe("Target node (create/get_props/set_props/reset_instance/detach)"),
1504
- componentIds: z6.array(z6.string()).optional().describe("create_set: components to combine"),
1505
- componentKey: z6.string().optional().describe("instantiate: component key (or node id)"),
1506
- x: z6.number().optional().describe("instantiate: position"),
1507
- y: z6.number().optional(),
1508
- properties: z6.record(z6.union([z6.string(), z6.boolean()])).optional().describe("set_props: property name \u2192 value (variant/text/boolean/swap)"),
1509
- sourceInstanceId: z6.string().optional().describe("set_overrides: copy from this instance"),
1510
- targetNodeIds: z6.array(z6.string()).optional().describe("set_overrides: apply to these"),
1511
- instanceNodeId: z6.string().optional().describe("get_overrides: instance to inspect"),
1512
- propertyName: z6.string().optional().describe("add_property: the name designers will see (e.g. hasLabel, text)"),
1513
- propertyType: z6.enum(["TEXT", "BOOLEAN", "INSTANCE_SWAP"]).optional().describe("add_property: VARIANT axes come from variant names, so they are not addable here"),
1514
- defaultValue: z6.union([z6.string(), z6.boolean()]).optional().describe("add_property: the value a fresh instance starts with"),
1515
- propertyKey: z6.string().optional().describe("bind_property: the key add_property returned (name#id)"),
1516
- field: z6.enum(["characters", "visible", "mainComponent"]).optional().describe("bind_property: which part of the layer the property drives \u2014 text, show/hide, or which component sits in a swap slot"),
1517
- layerName: z6.string().optional().describe("bind_property: bind this layer in EVERY variant of the set at once")
1779
+ nodeId: z7.string().optional().describe("Target node (create/get_props/set_props/reset_instance/detach)"),
1780
+ componentIds: z7.array(z7.string()).optional().describe("create_set: components to combine"),
1781
+ componentKey: z7.string().optional().describe("instantiate: component key (or node id)"),
1782
+ x: z7.number().optional().describe("instantiate: position"),
1783
+ y: z7.number().optional(),
1784
+ properties: z7.record(z7.union([z7.string(), z7.boolean()])).optional().describe("set_props: property name \u2192 value (variant/text/boolean/swap)"),
1785
+ sourceInstanceId: z7.string().optional().describe("set_overrides: copy from this instance"),
1786
+ targetNodeIds: z7.array(z7.string()).optional().describe("set_overrides: apply to these"),
1787
+ instanceNodeId: z7.string().optional().describe("get_overrides: instance to inspect"),
1788
+ propertyName: z7.string().optional().describe("add_property: the name designers will see (e.g. hasLabel, text)"),
1789
+ propertyType: z7.enum(["TEXT", "BOOLEAN", "INSTANCE_SWAP"]).optional().describe("add_property: VARIANT axes come from variant names, so they are not addable here"),
1790
+ defaultValue: z7.union([z7.string(), z7.boolean()]).optional().describe("add_property: the value a fresh instance starts with"),
1791
+ propertyKey: z7.string().optional().describe("bind_property: the key add_property returned (name#id)"),
1792
+ field: z7.enum(["characters", "visible", "mainComponent"]).optional().describe("bind_property: which part of the layer the property drives \u2014 text, show/hide, or which component sits in a swap slot"),
1793
+ layerName: z7.string().optional().describe("bind_property: bind this layer in EVERY variant of the set at once")
1518
1794
  },
1519
1795
  async (args) => {
1520
1796
  try {
@@ -1594,7 +1870,7 @@ __export(variables_exports, {
1594
1870
  scopeWarning: () => scopeWarning,
1595
1871
  suggestScopes: () => suggestScopes
1596
1872
  });
1597
- import { z as z7 } from "zod";
1873
+ import { z as z8 } from "zod";
1598
1874
  var SCOPE_BY_NAME = [
1599
1875
  [/radius|corner/i, ["CORNER_RADIUS"]],
1600
1876
  [/border-width|stroke-width|stroke\b/i, ["STROKE_FLOAT"]],
@@ -1617,9 +1893,9 @@ var SCOPE_MENU = {
1617
1893
  function suggestScopes(name, resolvedType) {
1618
1894
  const menu = SCOPE_MENU[resolvedType];
1619
1895
  if (!menu) return null;
1620
- for (const [pattern, scopes] of SCOPE_BY_NAME) {
1896
+ for (const [pattern, scopes2] of SCOPE_BY_NAME) {
1621
1897
  if (!pattern.test(name)) continue;
1622
- const usable = scopes.filter((s) => menu.indexOf(s) >= 0);
1898
+ const usable = scopes2.filter((s) => menu.indexOf(s) >= 0);
1623
1899
  if (usable.length) return usable;
1624
1900
  }
1625
1901
  return null;
@@ -1661,26 +1937,26 @@ function register6(server, sendCommand) {
1661
1937
  "manage_variables",
1662
1938
  "Design-token variables: list_collections / list (variables in a collection) / snapshot (compact whole-file inventory \u2014 every collection and variable name/type in one call; take one before branch merges or bulk edits, diff after) / create_collection / update_collection / delete_collection / create (pass valuesByMode to set every mode \u2014 and alias by variable NAME \u2014 in the same call) / update / delete / add_mode / remove_mode / rename_mode / set_scopes / set_code_syntax / remove_code_syntax / create_alias / bind (variable\u2192node property) / unbind / set_node_mode / get_node_modes / tokenize (find hardcoded colors & numbers that match existing variables and bind them \u2014 fix:false to preview, fix:true to apply; scope with nodeId, default current page). Pass only the fields the action needs.",
1663
1939
  {
1664
- action: z7.enum(Object.keys(ACTIONS)),
1665
- collectionId: z7.string().optional(),
1666
- variableId: z7.string().optional(),
1667
- targetVariableId: z7.string().optional().describe("create_alias: variable to alias to"),
1668
- nodeId: z7.string().optional().describe("bind/unbind/set_node_mode/get_node_modes"),
1669
- name: z7.string().optional(),
1670
- description: z7.string().optional(),
1671
- modes: z7.array(z7.string()).optional().describe("create_collection: mode names"),
1672
- modeId: z7.string().optional(),
1673
- resolvedType: z7.enum(["COLOR", "FLOAT", "STRING", "BOOLEAN"]).optional(),
1674
- value: z7.unknown().optional().describe("Variable value (color object, number, string, bool)"),
1675
- valuesByMode: z7.record(z7.unknown()).optional().describe(
1940
+ action: z8.enum(Object.keys(ACTIONS)),
1941
+ collectionId: z8.string().optional(),
1942
+ variableId: z8.string().optional(),
1943
+ targetVariableId: z8.string().optional().describe("create_alias: variable to alias to"),
1944
+ nodeId: z8.string().optional().describe("bind/unbind/set_node_mode/get_node_modes"),
1945
+ name: z8.string().optional(),
1946
+ description: z8.string().optional(),
1947
+ modes: z8.array(z8.string()).optional().describe("create_collection: mode names"),
1948
+ modeId: z8.string().optional(),
1949
+ resolvedType: z8.enum(["COLOR", "FLOAT", "STRING", "BOOLEAN"]).optional(),
1950
+ value: z8.unknown().optional().describe("Variable value (color object, number, string, bool)"),
1951
+ valuesByMode: z8.record(z8.unknown()).optional().describe(
1676
1952
  'create: every mode in one call, keyed by mode NAME or id \u2014 {"Light": {"r":1,"g":1,"b":1}, "Dark": {"aliasOf": "Color/gray/900"}}. `aliasOf` takes a variable id or its exact name, so a brand-mode token lands in one round-trip instead of create + one alias per mode.'
1677
1953
  ),
1678
- property: z7.string().optional().describe('bind/unbind: node property (e.g. "fills", "width")'),
1679
- scopes: z7.array(z7.string()).optional(),
1680
- platform: z7.enum(["WEB", "ANDROID", "iOS"]).optional(),
1681
- hiddenFromPublishing: z7.boolean().optional(),
1682
- fix: z7.boolean().optional().describe("tokenize: apply the bindings (false = report only)"),
1683
- tolerance: z7.number().optional().describe("tokenize: OKLab \u0394E for color matches (default 0.02 \u2248 visually identical)")
1954
+ property: z8.string().optional().describe('bind/unbind: node property (e.g. "fills", "width")'),
1955
+ scopes: z8.array(z8.string()).optional(),
1956
+ platform: z8.enum(["WEB", "ANDROID", "iOS"]).optional(),
1957
+ hiddenFromPublishing: z8.boolean().optional(),
1958
+ fix: z8.boolean().optional().describe("tokenize: apply the bindings (false = report only)"),
1959
+ tolerance: z8.number().optional().describe("tokenize: OKLab \u0394E for color matches (default 0.02 \u2248 visually identical)")
1684
1960
  },
1685
1961
  async (args) => {
1686
1962
  try {
@@ -1711,13 +1987,13 @@ var styles_exports = {};
1711
1987
  __export(styles_exports, {
1712
1988
  register: () => register7
1713
1989
  });
1714
- import { z as z8 } from "zod";
1990
+ import { z as z9 } from "zod";
1715
1991
  function register7(server, sendCommand) {
1716
1992
  server.tool(
1717
1993
  "manage_styles",
1718
1994
  "Shared styles: list all local styles; create_paint / create_text / create_effect / create_grid; update (name/properties); delete; apply (style to a node); reorder. To bind variables instead of styles, use manage_variables.",
1719
1995
  {
1720
- action: z8.enum([
1996
+ action: z9.enum([
1721
1997
  "list",
1722
1998
  "create_paint",
1723
1999
  "create_text",
@@ -1728,26 +2004,26 @@ function register7(server, sendCommand) {
1728
2004
  "apply",
1729
2005
  "reorder"
1730
2006
  ]),
1731
- name: z8.string().optional().describe("Style name (create_*/update)"),
1732
- styleId: z8.string().optional().describe("Target style (update/delete/apply/reorder)"),
1733
- styleType: z8.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional().describe("apply/reorder"),
1734
- nodeId: z8.string().optional().describe("apply: target node"),
2007
+ name: z9.string().optional().describe("Style name (create_*/update)"),
2008
+ styleId: z9.string().optional().describe("Target style (update/delete/apply/reorder)"),
2009
+ styleType: z9.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional().describe("apply/reorder"),
2010
+ nodeId: z9.string().optional().describe("apply: target node"),
1735
2011
  color: colorSchema.optional().describe("create_paint/create_effect"),
1736
2012
  gradientType: gradientTypeSchema.optional(),
1737
- gradientStops: z8.array(gradientStopSchema).optional(),
1738
- fontFamily: z8.string().optional().describe("create_text"),
1739
- fontSize: z8.number().positive().optional(),
1740
- fontWeight: z8.number().optional(),
1741
- lineHeight: z8.number().optional(),
1742
- letterSpacing: z8.number().optional(),
1743
- effectType: z8.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]).optional().describe("create_effect"),
1744
- offsetX: z8.number().optional(),
1745
- offsetY: z8.number().optional(),
1746
- radius: z8.number().min(0).optional(),
1747
- spread: z8.number().optional(),
1748
- grids: z8.array(z8.record(z8.unknown())).optional().describe("create_grid: layout grid objects"),
1749
- properties: z8.record(z8.unknown()).optional().describe("update: properties to change"),
1750
- afterStyleId: z8.string().optional().describe("reorder: place after this style")
2013
+ gradientStops: z9.array(gradientStopSchema).optional(),
2014
+ fontFamily: z9.string().optional().describe("create_text"),
2015
+ fontSize: z9.number().positive().optional(),
2016
+ fontWeight: z9.number().optional(),
2017
+ lineHeight: z9.number().optional(),
2018
+ letterSpacing: z9.number().optional(),
2019
+ effectType: z9.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]).optional().describe("create_effect"),
2020
+ offsetX: z9.number().optional(),
2021
+ offsetY: z9.number().optional(),
2022
+ radius: z9.number().min(0).optional(),
2023
+ spread: z9.number().optional(),
2024
+ grids: z9.array(z9.record(z9.unknown())).optional().describe("create_grid: layout grid objects"),
2025
+ properties: z9.record(z9.unknown()).optional().describe("update: properties to change"),
2026
+ afterStyleId: z9.string().optional().describe("reorder: place after this style")
1751
2027
  },
1752
2028
  async (args) => {
1753
2029
  try {
@@ -1826,18 +2102,18 @@ var pages_exports = {};
1826
2102
  __export(pages_exports, {
1827
2103
  register: () => register8
1828
2104
  });
1829
- import { z as z9 } from "zod";
2105
+ import { z as z10 } from "zod";
1830
2106
  function register8(server, sendCommand) {
1831
2107
  server.tool(
1832
2108
  "manage_pages",
1833
2109
  "Page operations: list all pages, create/rename/delete a page, or set a page's background color. To make a page current, use navigate with switch_page. Guards: list_guards shows the designer's AI no-go zones; guard/unguard change them \u2014 ONLY do that when the designer explicitly asks (guards are theirs; every change shows in their activity feed). Writes into a guarded page are rejected at dispatch.",
1834
2110
  {
1835
- action: z9.enum(["list", "create", "rename", "delete", "set_background", "list_guards", "guard", "unguard"]),
1836
- pageId: z9.string().optional().describe("Target page (rename/delete/set_background/guard/unguard)"),
1837
- name: z9.string().optional().describe("Page name (create/rename)"),
2111
+ action: z10.enum(["list", "create", "rename", "delete", "set_background", "list_guards", "guard", "unguard"]),
2112
+ pageId: z10.string().optional().describe("Target page (rename/delete/set_background/guard/unguard)"),
2113
+ name: z10.string().optional().describe("Page name (create/rename)"),
1838
2114
  color: colorSchema.optional().describe("Background color (set_background)"),
1839
- afterPageId: z9.string().optional().describe("create: put the new page right after this one \u2014 pages are ordered by meaning, and a new one lands at the bottom otherwise"),
1840
- index: z9.number().int().min(0).optional().describe("create: exact position instead of afterPageId")
2115
+ afterPageId: z10.string().optional().describe("create: put the new page right after this one \u2014 pages are ordered by meaning, and a new one lands at the bottom otherwise"),
2116
+ index: z10.number().int().min(0).optional().describe("create: exact position instead of afterPageId")
1841
2117
  },
1842
2118
  async ({ action, pageId, name, color, afterPageId, index }) => {
1843
2119
  try {
@@ -1884,19 +2160,19 @@ var navigate_exports = {};
1884
2160
  __export(navigate_exports, {
1885
2161
  register: () => register9
1886
2162
  });
1887
- import { z as z10 } from "zod";
2163
+ import { z as z11 } from "zod";
1888
2164
  function register9(server, sendCommand) {
1889
2165
  server.tool(
1890
2166
  "navigate",
1891
2167
  "Control the designer's view: focus (scroll+zoom to a node), select (set the selection), get_viewport / set_viewport (x/y/zoom), or switch_page.",
1892
2168
  {
1893
- action: z10.enum(["focus", "select", "get_viewport", "set_viewport", "switch_page"]),
1894
- nodeId: z10.string().optional().describe("focus target"),
1895
- nodeIds: z10.array(z10.string()).optional().describe("select targets"),
1896
- x: z10.number().optional().describe("set_viewport center x"),
1897
- y: z10.number().optional(),
1898
- zoom: z10.number().positive().optional(),
1899
- pageId: z10.string().optional().describe("switch_page target")
2169
+ action: z11.enum(["focus", "select", "get_viewport", "set_viewport", "switch_page"]),
2170
+ nodeId: z11.string().optional().describe("focus target"),
2171
+ nodeIds: z11.array(z11.string()).optional().describe("select targets"),
2172
+ x: z11.number().optional().describe("set_viewport center x"),
2173
+ y: z11.number().optional(),
2174
+ zoom: z11.number().positive().optional(),
2175
+ pageId: z11.string().optional().describe("switch_page target")
1900
2176
  },
1901
2177
  async ({ action, nodeId, nodeIds, x, y, zoom, pageId }) => {
1902
2178
  try {
@@ -1931,15 +2207,15 @@ var assets_exports = {};
1931
2207
  __export(assets_exports, {
1932
2208
  register: () => register10
1933
2209
  });
1934
- import { z as z11 } from "zod";
2210
+ import { z as z12 } from "zod";
1935
2211
  function register10(server, sendCommand) {
1936
2212
  server.tool(
1937
2213
  "export_asset",
1938
2214
  "Export a node as PNG/JPG/SVG/PDF. Returns base64 data for saving to disk. For a quick visual check of your work, prefer the screenshot tool (returns a viewable image).",
1939
2215
  {
1940
- nodeId: z11.string().describe("Node to export"),
2216
+ nodeId: z12.string().describe("Node to export"),
1941
2217
  format: exportFormatSchema.optional().describe("Default PNG"),
1942
- scale: z11.number().positive().optional().describe("Export scale (default 1)")
2218
+ scale: z12.number().positive().optional().describe("Export scale (default 1)")
1943
2219
  },
1944
2220
  { readOnlyHint: true },
1945
2221
  async ({ nodeId, format, scale }) => {
@@ -1955,15 +2231,15 @@ function register10(server, sendCommand) {
1955
2231
  "add_image",
1956
2232
  "Place an image: from a URL (creates a new image node) or as a fill on an existing node (base64 imageData). scaleMode controls how the image fits.",
1957
2233
  {
1958
- url: z11.string().optional().describe("Image URL \u2014 creates a new node"),
1959
- nodeId: z11.string().optional().describe("Existing node \u2014 sets its fill from imageData"),
1960
- imageData: z11.string().optional().describe("Base64 image data (with nodeId)"),
1961
- x: z11.number().optional(),
1962
- y: z11.number().optional(),
1963
- width: z11.number().positive().optional(),
1964
- height: z11.number().positive().optional(),
1965
- name: z11.string().optional(),
1966
- scaleMode: z11.enum(["FILL", "FIT", "CROP", "TILE"]).optional()
2234
+ url: z12.string().optional().describe("Image URL \u2014 creates a new node"),
2235
+ nodeId: z12.string().optional().describe("Existing node \u2014 sets its fill from imageData"),
2236
+ imageData: z12.string().optional().describe("Base64 image data (with nodeId)"),
2237
+ x: z12.number().optional(),
2238
+ y: z12.number().optional(),
2239
+ width: z12.number().positive().optional(),
2240
+ height: z12.number().positive().optional(),
2241
+ name: z12.string().optional(),
2242
+ scaleMode: z12.enum(["FILL", "FIT", "CROP", "TILE"]).optional()
1967
2243
  },
1968
2244
  async ({ url, nodeId, imageData, x, y, width, height, name, scaleMode }) => {
1969
2245
  try {
@@ -1992,14 +2268,14 @@ var library_exports = {};
1992
2268
  __export(library_exports, {
1993
2269
  register: () => register11
1994
2270
  });
1995
- import { z as z12 } from "zod";
2271
+ import { z as z13 } from "zod";
1996
2272
  function register11(server, sendCommand) {
1997
2273
  server.tool(
1998
2274
  "import_from_library",
1999
2275
  "Import a component, style, or variable from an enabled team library by its key. Keys come from get_design_system's libraryCatalog, which resolves each library this file draws from and lists everything it publishes \u2014 not just what the file already uses. If the library you want is not in that catalog, it is listed under librariesNotCatalogued with what to do about it. Imported components can then be instantiated with manage_components.",
2000
2276
  {
2001
- kind: z12.enum(["component", "style", "variable"]),
2002
- key: z12.string().describe("Library key")
2277
+ kind: z13.enum(["component", "style", "variable"]),
2278
+ key: z13.string().describe("Library key")
2003
2279
  },
2004
2280
  async ({ kind, key }) => {
2005
2281
  try {
@@ -2018,21 +2294,21 @@ var annotate_exports = {};
2018
2294
  __export(annotate_exports, {
2019
2295
  register: () => register12
2020
2296
  });
2021
- import { z as z13 } from "zod";
2297
+ import { z as z14 } from "zod";
2022
2298
  function register12(server, sendCommand) {
2023
2299
  server.tool(
2024
2300
  "annotate",
2025
2301
  "Read or write Dev Mode annotations. get: read annotations on a node. set: add one annotation (labelMarkdown); existing annotations are kept, and a node type that takes none is an error. set_multiple: batch annotations.",
2026
2302
  {
2027
- action: z13.enum(["get", "set", "set_multiple"]),
2028
- nodeId: z13.string().optional().describe("Target node (get/set)"),
2029
- labelMarkdown: z13.string().optional().describe("Annotation text (set)"),
2030
- categoryId: z13.string().optional(),
2031
- annotations: z13.array(
2032
- z13.object({
2033
- nodeId: z13.string(),
2034
- labelMarkdown: z13.string(),
2035
- categoryId: z13.string().optional()
2303
+ action: z14.enum(["get", "set", "set_multiple"]),
2304
+ nodeId: z14.string().optional().describe("Target node (get/set)"),
2305
+ labelMarkdown: z14.string().optional().describe("Annotation text (set)"),
2306
+ categoryId: z14.string().optional(),
2307
+ annotations: z14.array(
2308
+ z14.object({
2309
+ nodeId: z14.string(),
2310
+ labelMarkdown: z14.string(),
2311
+ categoryId: z14.string().optional()
2036
2312
  })
2037
2313
  ).optional().describe("Batch annotations (set_multiple)")
2038
2314
  },
@@ -2063,16 +2339,16 @@ var read_exports = {};
2063
2339
  __export(read_exports, {
2064
2340
  register: () => register13
2065
2341
  });
2066
- import { z as z14 } from "zod";
2342
+ import { z as z15 } from "zod";
2067
2343
  function register13(server, sendCommand) {
2068
2344
  server.tool(
2069
2345
  "get_node_data",
2070
2346
  "Raw node data at a chosen detail level: summary (shallow), tree (sparse structure for orientation), full (deeper serialization), css (CSS representation), variables (bound variable ids). For human-oriented summaries prefer get_node_details / get_selection_context.",
2071
2347
  {
2072
- nodeIds: z14.array(z14.string()).min(1).describe("One or more node ids"),
2073
- detail: z14.enum(["summary", "tree", "full", "css", "variables"]).optional().describe("Default summary"),
2074
- depth: z14.number().int().min(0).optional().describe("Override serialization depth"),
2075
- maxNodes: z14.number().int().positive().optional().describe("Override node budget")
2348
+ nodeIds: z15.array(z15.string()).min(1).describe("One or more node ids"),
2349
+ detail: z15.enum(["summary", "tree", "full", "css", "variables"]).optional().describe("Default summary"),
2350
+ depth: z15.number().int().min(0).optional().describe("Override serialization depth"),
2351
+ maxNodes: z15.number().int().positive().optional().describe("Override node budget")
2076
2352
  },
2077
2353
  { readOnlyHint: true },
2078
2354
  async ({ nodeIds, detail = "summary", depth, maxNodes }) => {
@@ -2112,22 +2388,42 @@ function register13(server, sendCommand) {
2112
2388
  // src/tools/core/execute.ts
2113
2389
  var execute_exports = {};
2114
2390
  __export(execute_exports, {
2391
+ pluginTruncation: () => pluginTruncation,
2115
2392
  register: () => register14
2116
2393
  });
2117
- import { z as z15 } from "zod";
2394
+ import { z as z16 } from "zod";
2395
+ var LEGACY_PLUGIN_LIMIT = 5e4;
2396
+ function pluginTruncation(result) {
2397
+ const r = result;
2398
+ if (!r || typeof r !== "object") return void 0;
2399
+ const t = r.truncated;
2400
+ if (t && typeof t === "object" && typeof t.total === "number" && typeof t.returned === "number") {
2401
+ return { total: t.total, returned: t.returned, unit: "chars" };
2402
+ }
2403
+ if (t === true && typeof r.result === "string") {
2404
+ const cut = /… \[truncated (\d+) chars/.exec(r.result);
2405
+ if (cut) {
2406
+ return { total: Number(cut[1]) + LEGACY_PLUGIN_LIMIT, returned: LEGACY_PLUGIN_LIMIT, unit: "chars" };
2407
+ }
2408
+ }
2409
+ return void 0;
2410
+ }
2118
2411
  function register14(server, sendCommand) {
2119
2412
  server.tool(
2120
2413
  "execute_figma",
2121
2414
  "Run JavaScript against the Figma Plugin API inside the plugin sandbox \u2014 the escape hatch for anything the other tools don't cover. The code runs in an async function with `figma` AND `relai` in scope. relai helpers avoid the classic pitfalls: relai.text(parent, chars, {font,size,color}) loads fonts first; relai.autoLayout(direction, props) makes a hugging auto-layout frame; relai.set(node, props) applies layoutMode first and routes width/height through resize; relai.hug(node); relai.focusRing(node); await relai.page(p => ...) finds pages by content, not name; relai.query('FRAME[name^=Card] > TEXT') is a CSS-like search (types, name matchers, descendant/child, comma); relai.placeholder(node) shows a construction veil \u2014 remove with (node, false) when done. Errors carry a Hint with the fix; scripts are NOT atomic \u2014 on error, partial changes persist, so keep scripts small and idempotent. Nodes created via relai plus any node ids you RETURN are linted for silent mistakes (e.g. spread shadows without clipsContent) and come back as `warnings`. Return ALL created/mutated node ids. Work incrementally; verify with screenshot. When BUILDING UI: call get_design_system first and instantiate the file's own components / bind its variables instead of drawing raw shapes \u2014 hand-drawn near-copies of existing components read as drift to the design team. The designer can disable this tool via the plugin's 'Allow code execution' toggle.",
2122
2415
  {
2123
- code: z15.string().describe("JavaScript source. May use await. Return a JSON-serializable value."),
2124
- description: z15.string().optional().describe("One line shown in the plugin's activity feed describing what this does"),
2125
- timeoutMs: z15.number().int().min(1e3).max(3e5).optional().describe("Execution timeout in ms (default 60000) \u2014 raise for scripts creating hundreds of nodes")
2416
+ code: z16.string().describe("JavaScript source. May use await. Return a JSON-serializable value."),
2417
+ description: z16.string().optional().describe("One line shown in the plugin's activity feed describing what this does"),
2418
+ timeoutMs: z16.number().int().min(1e3).max(3e5).optional().describe("Execution timeout in ms (default 60000) \u2014 raise for scripts creating hundreds of nodes")
2126
2419
  },
2127
2420
  async ({ code, description, timeoutMs }) => {
2128
2421
  try {
2129
2422
  const result = await sendCommand("execute_code", { code, description }, timeoutMs ?? 6e4);
2130
- return jsonResult(result);
2423
+ const truncated = pluginTruncation(result);
2424
+ if (!truncated) return jsonResult(result);
2425
+ const meta = { v: 1, calls: [], truncated };
2426
+ return { ...jsonResult(result), _meta: { [CALL_META_KEY]: meta } };
2131
2427
  } catch (error) {
2132
2428
  return errorResult(error);
2133
2429
  }
@@ -2141,7 +2437,86 @@ __export(comments_exports, {
2141
2437
  parseFileKey: () => parseFileKey,
2142
2438
  register: () => register15
2143
2439
  });
2144
- import { z as z16 } from "zod";
2440
+ import { z as z17 } from "zod";
2441
+
2442
+ // src/tools/core/file-identity.ts
2443
+ function addressableKey(identity) {
2444
+ return identity.branchKey ?? identity.fileKey;
2445
+ }
2446
+ function describeIdentity(identity) {
2447
+ return identity.branchName ? `${identity.fileName} (branch "${identity.branchName}")` : identity.fileName;
2448
+ }
2449
+ async function figmaApi(token, path) {
2450
+ const res = await fetch(`https://api.figma.com/v1${path}`, {
2451
+ headers: { "X-Figma-Token": token }
2452
+ });
2453
+ noteAuthFailure(res.status);
2454
+ const json = await res.json().catch(() => ({}));
2455
+ return { ok: res.ok, status: res.status, json };
2456
+ }
2457
+ var FileIdentityError = class extends Error {
2458
+ };
2459
+ async function resolveFileIdentity(sendCommand) {
2460
+ const probe = await sendCommand("get_file_identity", {});
2461
+ const cached = probe?.identity?.fileKey ? probe.identity : null;
2462
+ if (cached && cached.by === VERSION) return cached;
2463
+ const rootName = probe?.rootName ?? "";
2464
+ const candidates = probe?.candidates ?? [];
2465
+ if (candidates.length === 0) {
2466
+ if (cached) return cached;
2467
+ throw new FileIdentityError(
2468
+ "This file publishes no components, so its key cannot be resolved from the plugin (figma.fileKey is private-plugin-only)."
2469
+ );
2470
+ }
2471
+ const token = loadToken();
2472
+ if (!token) {
2473
+ throw new FileIdentityError(
2474
+ "Resolving the file key needs a Figma token \u2014 store one with `npx -y figma-relai@latest login`."
2475
+ );
2476
+ }
2477
+ let main = null;
2478
+ const statuses = [];
2479
+ for (const c of candidates) {
2480
+ const paths = c.kind === "set" ? ["/component_sets/", "/components/"] : ["/components/", "/component_sets/"];
2481
+ for (const p of paths) {
2482
+ const res = await figmaApi(token, `${p}${c.key}`);
2483
+ statuses.push(res.status);
2484
+ const meta = res.json.meta ?? {};
2485
+ if (res.ok && typeof meta.file_key === "string") {
2486
+ main = { fileKey: meta.file_key, via: c.key };
2487
+ break;
2488
+ }
2489
+ }
2490
+ if (main) break;
2491
+ }
2492
+ if (!main) {
2493
+ const denied = statuses.length > 0 && statuses.every((s) => s === 401 || s === 403);
2494
+ throw new FileIdentityError(
2495
+ denied ? "Figma rejected the token on every probe (401/403) \u2014 it is expired or lacks the file content read scope. Re-run `npx -y figma-relai@latest login`." : "None of this file's published component keys resolved."
2496
+ );
2497
+ }
2498
+ const file = await figmaApi(token, `/files/${main.fileKey}?branch_data=true&depth=1`);
2499
+ const identity = {
2500
+ fileKey: main.fileKey,
2501
+ fileName: typeof file.json.name === "string" ? file.json.name : rootName,
2502
+ by: VERSION
2503
+ };
2504
+ if (rootName && rootName !== identity.fileName) {
2505
+ const branches = file.json.branches ?? [];
2506
+ const hit = branches.find((b) => b.name === rootName);
2507
+ if (hit?.key) {
2508
+ identity.branchKey = hit.key;
2509
+ identity.branchName = hit.name;
2510
+ }
2511
+ }
2512
+ try {
2513
+ await sendCommand("set_file_identity", { identity, componentKey: main.via });
2514
+ } catch {
2515
+ }
2516
+ return identity;
2517
+ }
2518
+
2519
+ // src/tools/core/comments.ts
2145
2520
  var API = "https://api.figma.com/v1";
2146
2521
  function parseFileKey(input) {
2147
2522
  const url = input.match(/figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/);
@@ -2195,15 +2570,15 @@ function register15(server, sendCommand) {
2195
2570
  "manage_comments",
2196
2571
  "Read and write comments on the Figma file: list (with node anchors \u2014 great for 'apply the feedback in the comments' or polling for designer requests; filter with unresolved:true and since:<ISO date>), add (optionally pinned to a node), reply, delete. Comment-driven workflow: designers leave asks as comments in Figma; poll list (unresolved + since last check), do the work, then reply on the thread with what was done. Requires a FIGMA_TOKEN env var (personal access token with comment scopes, generated at figma.com Settings \u2192 Security); the canvas tools work without it. The file is resolved from the plugin automatically (via a published component key, and the branch you are on \u2014 comments live per branch); pass fileUrl if this file publishes nothing.",
2197
2572
  {
2198
- action: z16.enum(["list", "add", "reply", "delete"]),
2199
- fileUrl: z16.string().optional().describe("Figma file URL or key (auto-detected when omitted)"),
2200
- message: z16.string().optional().describe("Comment text (add/reply)"),
2201
- commentId: z16.string().optional().describe("Target comment (reply/delete)"),
2202
- nodeId: z16.string().optional().describe("add: pin the comment to this node"),
2203
- x: z16.number().optional().describe("add: canvas position (with y, when not pinning to a node)"),
2204
- y: z16.number().optional(),
2205
- since: z16.string().optional().describe("list: only comments created after this ISO 8601 timestamp"),
2206
- unresolved: z16.boolean().optional().describe("list: only unresolved threads")
2573
+ action: z17.enum(["list", "add", "reply", "delete"]),
2574
+ fileUrl: z17.string().optional().describe("Figma file URL or key (auto-detected when omitted)"),
2575
+ message: z17.string().optional().describe("Comment text (add/reply)"),
2576
+ commentId: z17.string().optional().describe("Target comment (reply/delete)"),
2577
+ nodeId: z17.string().optional().describe("add: pin the comment to this node"),
2578
+ x: z17.number().optional().describe("add: canvas position (with y, when not pinning to a node)"),
2579
+ y: z17.number().optional(),
2580
+ since: z17.string().optional().describe("list: only comments created after this ISO 8601 timestamp"),
2581
+ unresolved: z17.boolean().optional().describe("list: only unresolved threads")
2207
2582
  },
2208
2583
  async ({ action, fileUrl, message, commentId, nodeId, x, y, since, unresolved }) => {
2209
2584
  const token = loadToken();
@@ -2310,14 +2685,14 @@ __export(design_system_exports, {
2310
2685
  probeFailureNote: () => probeFailureNote,
2311
2686
  register: () => register16
2312
2687
  });
2313
- import { z as z17 } from "zod";
2688
+ import { z as z18 } from "zod";
2314
2689
  function register16(server, sendCommand) {
2315
2690
  server.tool(
2316
2691
  "get_design_system",
2317
2692
  "Inventory the design system available to this file \u2014 call this BEFORE building UI, then prefer instantiating existing components (manage_components action:instantiate takes local and library keys) and binding existing variables over drawing raw shapes. Reports: local components/styles/variable collections with usage counts, remote components/styles the file already uses, enabled libraries' variable collections, and \u2014 with a token stored (`npx figma-relai login`) \u2014 the FULL published catalog of every library this file consumes, resolved automatically. What the file already uses is never the whole library: a component the file has not placed yet is invisible until this catalog is read. A library the file has drawn from NOTHING at all cannot be resolved either \u2014 those are named under librariesNotCatalogued, with the one step that fixes it. Results are cached per session \u2014 pass refresh:true after big library changes.",
2318
2693
  {
2319
- refresh: z17.boolean().optional().describe("Rescan instead of using the session cache"),
2320
- libraryFileUrl: z17.string().optional().describe(
2694
+ refresh: z18.boolean().optional().describe("Rescan instead of using the session cache"),
2695
+ libraryFileUrl: z18.string().optional().describe(
2321
2696
  "Optional. Catalog THIS library file instead of the auto-resolved ones (figma.com URL or key)"
2322
2697
  )
2323
2698
  },
@@ -2356,7 +2731,7 @@ function register16(server, sendCommand) {
2356
2731
  "manage_conventions",
2357
2732
  "The file's law: conventions (statutes) + precedents (case law), stored INSIDE this Figma file so they travel with it to every future session from any AI client. action:get returns both \u2014 read them BEFORE working and follow them like user instructions. Conventions are a markdown doc (action:set overwrites). Precedents are single adjudications the designer made \u2014 record one (action:record_precedent, one sentence, with refs to the tokens/nodes/pages it concerns) whenever the designer rules on something durable ('this deviation is intent, not drift', 'never restructure this table'), and SAY in your reply that you recorded it. Write results automatically surface precedents whose refs they touch.",
2358
2733
  {
2359
- action: z17.enum([
2734
+ action: z18.enum([
2360
2735
  "get",
2361
2736
  "set",
2362
2737
  "record_precedent",
@@ -2364,18 +2739,18 @@ function register16(server, sendCommand) {
2364
2739
  "update_precedent",
2365
2740
  "remove_precedent"
2366
2741
  ]),
2367
- content: z17.string().optional().describe("set: the full markdown doc (overwrites; max 20k chars)"),
2368
- kind: z17.enum(["decision", "intent", "correction"]).optional().describe("record/update_precedent: decision (an approval/rejection), intent (a deviation that is deliberate), correction (a do-it-differently ruling). Default intent"),
2369
- text: z17.string().optional().describe("record/update_precedent: the adjudication, one sentence, \u2264280 chars"),
2370
- id: z17.string().optional().describe("update/remove_precedent: precedent id (from list_precedents)"),
2371
- refs: z17.object({
2372
- tokens: z17.array(z17.string()).optional().describe(
2742
+ content: z18.string().optional().describe("set: the full markdown doc (overwrites; max 20k chars)"),
2743
+ kind: z18.enum(["decision", "intent", "correction"]).optional().describe("record/update_precedent: decision (an approval/rejection), intent (a deviation that is deliberate), correction (a do-it-differently ruling). Default intent"),
2744
+ text: z18.string().optional().describe("record/update_precedent: the adjudication, one sentence, \u2264280 chars"),
2745
+ id: z18.string().optional().describe("update/remove_precedent: precedent id (from list_precedents)"),
2746
+ refs: z18.object({
2747
+ tokens: z18.array(z18.string()).optional().describe(
2373
2748
  'Variable IDs this precedent concerns, with the "VariableID:" prefix (a bare N:M gets it added; an id that names no variable is an error and nothing is saved)'
2374
2749
  ),
2375
- nodes: z17.array(z17.string()).optional().describe("Node IDs this precedent concerns"),
2376
- pages: z17.array(z17.string()).optional().describe("Page IDs this precedent concerns")
2750
+ nodes: z18.array(z18.string()).optional().describe("Node IDs this precedent concerns"),
2751
+ pages: z18.array(z18.string()).optional().describe("Page IDs this precedent concerns")
2377
2752
  }).optional().describe("What the precedent is anchored to \u2014 enables in-band surfacing when writes touch these"),
2378
- limit: z17.number().optional().describe("list_precedents: max entries returned (default 50)")
2753
+ limit: z18.number().optional().describe("list_precedents: max entries returned (default 50)")
2379
2754
  },
2380
2755
  async ({ action, content, kind, text, id, refs, limit }) => {
2381
2756
  try {
@@ -2557,7 +2932,7 @@ var rulesets_exports = {};
2557
2932
  __export(rulesets_exports, {
2558
2933
  register: () => register17
2559
2934
  });
2560
- import { z as z18 } from "zod";
2935
+ import { z as z19 } from "zod";
2561
2936
  var ACTIONS2 = {
2562
2937
  list: ["list_rulesets", []],
2563
2938
  save: ["save_ruleset", ["name", "conventions", "fromFile", "autoRestore", "provenance"]],
@@ -2576,18 +2951,18 @@ function register17(server, sendCommand) {
2576
2951
  "manage_rulesets",
2577
2952
  "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 \u2014 all or none: when the file memory can't hold them the law is still restored and precedentsSkipped says so; consolidate, then restore again \u2014 seeds merge by id, so only the missing ones are added; REFUSED when the file's law is newer than the kit's \u2014 status.newer says which side is, and push is usually the fix \u2014 pass force:true to override) / 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.",
2578
2953
  {
2579
- action: z18.enum(Object.keys(ACTIONS2)),
2580
- name: z18.string().optional().describe("Ruleset name (save/link/delete/export; import fallback)"),
2581
- conventions: z18.string().optional().describe("save: full conventions markdown (or use fromFile)"),
2582
- fromFile: z18.boolean().optional().describe("save: capture this file's current conventions"),
2583
- autoRestore: z18.boolean().optional().describe("save: restore without asking when a linked file's law is found wiped"),
2584
- provenance: z18.string().optional().describe("save: where this law comes from (author/repo)"),
2585
- restore: z18.boolean().optional().describe("link: immediately restore the ruleset into the file"),
2586
- force: z18.boolean().optional().describe(
2954
+ action: z19.enum(Object.keys(ACTIONS2)),
2955
+ name: z19.string().optional().describe("Ruleset name (save/link/delete/export; import fallback)"),
2956
+ conventions: z19.string().optional().describe("save: full conventions markdown (or use fromFile)"),
2957
+ fromFile: z19.boolean().optional().describe("save: capture this file's current conventions"),
2958
+ autoRestore: z19.boolean().optional().describe("save: restore without asking when a linked file's law is found wiped"),
2959
+ provenance: z19.string().optional().describe("save: where this law comes from (author/repo)"),
2960
+ restore: z19.boolean().optional().describe("link: immediately restore the ruleset into the file"),
2961
+ force: z19.boolean().optional().describe(
2587
2962
  "restore/link: replace the file's law even when it is NEWER than the kit's; push: overwrite a kit that has moved since this file last synced it. Without this, an overwrite that would discard someone else's version is refused \u2014 say which side you mean to keep."
2588
2963
  ),
2589
- id: z18.string().optional().describe("promote: precedent id (list_precedents shows them)"),
2590
- markdown: z18.string().optional().describe("import: a ruleset package exported earlier")
2964
+ id: z19.string().optional().describe("promote: precedent id (list_precedents shows them)"),
2965
+ markdown: z19.string().optional().describe("import: a ruleset package exported earlier")
2591
2966
  },
2592
2967
  async (args) => {
2593
2968
  try {
@@ -2609,7 +2984,7 @@ var batch_exports = {};
2609
2984
  __export(batch_exports, {
2610
2985
  register: () => register18
2611
2986
  });
2612
- import { z as z19 } from "zod";
2987
+ import { z as z20 } from "zod";
2613
2988
  function textResult2(text) {
2614
2989
  return { content: [{ type: "text", text }] };
2615
2990
  }
@@ -2618,13 +2993,13 @@ function register18(server, sendCommand) {
2618
2993
  "batch_execute",
2619
2994
  "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.",
2620
2995
  {
2621
- commands: z19.array(
2622
- z19.object({
2623
- command: z19.string().describe("Command name"),
2624
- params: z19.record(z19.string(), z19.unknown()).optional().describe("Command parameters")
2996
+ commands: z20.array(
2997
+ z20.object({
2998
+ command: z20.string().describe("Command name"),
2999
+ params: z20.record(z20.string(), z20.unknown()).optional().describe("Command parameters")
2625
3000
  })
2626
3001
  ).describe("Array of commands to execute sequentially"),
2627
- dryRun: z19.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
3002
+ dryRun: z20.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
2628
3003
  },
2629
3004
  async ({ commands, dryRun }) => {
2630
3005
  try {
@@ -2658,7 +3033,7 @@ __export(context_exports, {
2658
3033
  register: () => register19,
2659
3034
  styleCountsFrom: () => styleCountsFrom
2660
3035
  });
2661
- import { z as z20 } from "zod";
3036
+ import { z as z21 } from "zod";
2662
3037
 
2663
3038
  // src/semantic/response.ts
2664
3039
  function standardResult(opts) {
@@ -2683,8 +3058,18 @@ function errorResult2(category, message, recovery, extra) {
2683
3058
  };
2684
3059
  if (extra?.preconditions) error.preconditions = extra.preconditions;
2685
3060
  if (extra?.partial_result !== void 0) error.partial_result = extra.partial_result;
3061
+ const { code, outcome, retry, partial } = CATEGORY_CODES[category];
3062
+ const meta = {
3063
+ v: 1,
3064
+ ...outcome ? { outcome } : {},
3065
+ ...partial ? { partial } : {},
3066
+ error: { code, retry: retry ?? ERROR_DEFAULTS[code].retry },
3067
+ calls: []
3068
+ };
2686
3069
  return {
2687
- content: [{ type: "text", text: JSON.stringify({ error }, null, 2) }]
3070
+ content: [{ type: "text", text: JSON.stringify({ error }, null, 2) }],
3071
+ isError: true,
3072
+ _meta: { [CALL_META_KEY]: meta }
2688
3073
  };
2689
3074
  }
2690
3075
  function noSelectionError() {
@@ -2964,7 +3349,7 @@ function register19(server, sendCommand) {
2964
3349
  "get_node_details",
2965
3350
  "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 set_properties or analyze_design.",
2966
3351
  {
2967
- nodeId: z20.string().describe("The node ID to inspect")
3352
+ nodeId: z21.string().describe("The node ID to inspect")
2968
3353
  },
2969
3354
  { readOnlyHint: true },
2970
3355
  async ({ nodeId }) => {
@@ -3023,9 +3408,9 @@ function register19(server, sendCommand) {
3023
3408
  "search_nodes",
3024
3409
  "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 set_properties.",
3025
3410
  {
3026
- query: z20.string().optional().describe("Name substring to search for"),
3027
- types: z20.array(z20.string()).optional().describe("Node types to filter: FRAME, TEXT, COMPONENT, INSTANCE, RECTANGLE, etc."),
3028
- parentId: z20.string().optional().describe("Scope search to children of this node (default: current page)")
3411
+ query: z21.string().optional().describe("Name substring to search for"),
3412
+ types: z21.array(z21.string()).optional().describe("Node types to filter: FRAME, TEXT, COMPONENT, INSTANCE, RECTANGLE, etc."),
3413
+ parentId: z21.string().optional().describe("Scope search to children of this node (default: current page)")
3029
3414
  },
3030
3415
  { readOnlyHint: true },
3031
3416
  async ({ query, types, parentId }) => {
@@ -3112,7 +3497,7 @@ function register19(server, sendCommand) {
3112
3497
  "screenshot",
3113
3498
  "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.",
3114
3499
  {
3115
- nodeId: z20.string().optional().describe("Node ID to screenshot (default: current view)")
3500
+ nodeId: z21.string().optional().describe("Node ID to screenshot (default: current view)")
3116
3501
  },
3117
3502
  { readOnlyHint: true },
3118
3503
  async ({ nodeId }) => {
@@ -3173,7 +3558,7 @@ __export(analysis_exports, {
3173
3558
  collectColorIssues: () => collectColorIssues,
3174
3559
  register: () => register20
3175
3560
  });
3176
- import { z as z21 } from "zod";
3561
+ import { z as z22 } from "zod";
3177
3562
 
3178
3563
  // src/semantic/health.ts
3179
3564
  var WEIGHTS = { color: 0.3, layout: 0.25, components: 0.2, accessibility: 0.25 };
@@ -3245,10 +3630,10 @@ function register20(server, sendCommand) {
3245
3630
  "analyze_design",
3246
3631
  `Audit the design from one aspect: color (token coverage, unbound fills/strokes), layout (auto-layout quality, spacing consistency), components (detached instances, component health), accessibility (WCAG contrast incl. large-text thresholds, touch targets, minimum text sizes), tokens (hardcoded values that match an existing variable \u2014 each finding names the variable to bind; fix in one shot with manage_variables action:tokenize fix:true), overall (runs color/layout/components/accessibility and returns a weighted 0-100 health score \u2014 good for audits and reports), voice (the file's statistical fingerprint: radius/spacing/type signatures, tokenized-paint and instance rates \u2014 what "sounds like this file"), readiness (0-100 agent-readiness score with top gaps: conventions, precedents, semantic tokens, components), ghosts (stale references to soft-deleted variables \u2014 they still render but are invisible in pickers and dead on publish), or token_debt (component tokens nobody binds, and component tokens that alias ANOTHER component's token instead of the shared upstream \u2014 both look right on the canvas and bite later). Defaults to the current selection; tokens/voice/ghosts default to the current page.`,
3247
3632
  {
3248
- aspect: z21.enum(["color", "layout", "components", "accessibility", "tokens", "overall", "voice", "readiness", "ghosts", "token_debt"]),
3249
- nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)"),
3250
- pageIds: z21.array(z21.string()).optional().describe("voice/ghosts: pages to scan (default: current page; ghosts auto-chunks any count)"),
3251
- platform: z21.enum(["desktop", "mobile"]).optional().describe("accessibility: target-size profile \u2014 desktop 24px (default), mobile 44px")
3633
+ aspect: z22.enum(["color", "layout", "components", "accessibility", "tokens", "overall", "voice", "readiness", "ghosts", "token_debt"]),
3634
+ nodeId: z22.string().optional().describe("Root node to analyze (default: current selection)"),
3635
+ pageIds: z22.array(z22.string()).optional().describe("voice/ghosts: pages to scan (default: current page; ghosts auto-chunks any count)"),
3636
+ platform: z22.enum(["desktop", "mobile"]).optional().describe("accessibility: target-size profile \u2014 desktop 24px (default), mobile 44px")
3252
3637
  },
3253
3638
  { readOnlyHint: true },
3254
3639
  async ({ aspect, nodeId, pageIds, platform }) => {
@@ -3487,7 +3872,7 @@ function registerAnalysisTools(server, sendCommand) {
3487
3872
  "analyze_color_usage",
3488
3873
  "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 manage_variables action:tokenize to fix issues.",
3489
3874
  {
3490
- nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)")
3875
+ nodeId: z22.string().optional().describe("Root node to analyze (default: current selection)")
3491
3876
  },
3492
3877
  { readOnlyHint: true },
3493
3878
  async ({ nodeId }) => {
@@ -3573,7 +3958,7 @@ function registerAnalysisTools(server, sendCommand) {
3573
3958
  "analyze_layout_quality",
3574
3959
  "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_properties (auto-layout) to fix issues.",
3575
3960
  {
3576
- nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)")
3961
+ nodeId: z22.string().optional().describe("Root node to analyze (default: current selection)")
3577
3962
  },
3578
3963
  { readOnlyHint: true },
3579
3964
  async ({ nodeId }) => {
@@ -3626,7 +4011,7 @@ function registerAnalysisTools(server, sendCommand) {
3626
4011
  "analyze_component_health",
3627
4012
  "Check component system health: detached instances, unused components, and override consistency. Use to audit design system adherence. Follow with manage_components to fix issues.",
3628
4013
  {
3629
- nodeId: z21.string().optional().describe("Scope to analyze (default: current page)")
4014
+ nodeId: z22.string().optional().describe("Scope to analyze (default: current page)")
3630
4015
  },
3631
4016
  { readOnlyHint: true },
3632
4017
  async ({ nodeId }) => {
@@ -3679,8 +4064,8 @@ function registerAnalysisTools(server, sendCommand) {
3679
4064
  "analyze_accessibility",
3680
4065
  "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 set_properties to fix contrast or sizing issues.",
3681
4066
  {
3682
- nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)"),
3683
- platform: z21.enum(["desktop", "mobile"]).optional().describe("Target-size profile: desktop 24px (default), mobile 44px")
4067
+ nodeId: z22.string().optional().describe("Root node to analyze (default: current selection)"),
4068
+ platform: z22.enum(["desktop", "mobile"]).optional().describe("Target-size profile: desktop 24px (default), mobile 44px")
3684
4069
  },
3685
4070
  { readOnlyHint: true },
3686
4071
  async ({ nodeId, platform }) => {
@@ -3728,9 +4113,9 @@ function registerAnalysisTools(server, sendCommand) {
3728
4113
  "diff_nodes",
3729
4114
  `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. Checkpoints are kept in the Relai server's memory, never written to the file, and a server restart clears them.`,
3730
4115
  {
3731
- nodeIdA: z21.string().describe("First node ID (or the checkpoint target)"),
3732
- nodeIdB: z21.string().optional().describe("Second node ID (omit when using checkpoint)"),
3733
- checkpoint: z21.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now (held in server memory until the server restarts, not in the file); compare: diff current vs saved snapshot")
4116
+ nodeIdA: z22.string().describe("First node ID (or the checkpoint target)"),
4117
+ nodeIdB: z22.string().optional().describe("Second node ID (omit when using checkpoint)"),
4118
+ checkpoint: z22.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now (held in server memory until the server restarts, not in the file); compare: diff current vs saved snapshot")
3734
4119
  },
3735
4120
  { readOnlyHint: true },
3736
4121
  async ({ nodeIdA, nodeIdB, checkpoint }) => {
@@ -4021,14 +4406,14 @@ var verification_exports = {};
4021
4406
  __export(verification_exports, {
4022
4407
  register: () => register21
4023
4408
  });
4024
- import { z as z22 } from "zod";
4409
+ import { z as z23 } from "zod";
4025
4410
  function register21(server, sendCommand) {
4026
4411
  server.tool(
4027
4412
  "verify_changes",
4028
4413
  "Verify that a node's current properties match expected values. Use after set_properties or batch_execute to confirm changes applied correctly. Returns match/mismatch for each checked field.",
4029
4414
  {
4030
- nodeId: z22.string().describe("Node ID to verify"),
4031
- expected: z22.record(z22.string(), z22.unknown()).describe("Expected property values, e.g. { fill: '#3B82F6', cornerRadius: 8 }")
4415
+ nodeId: z23.string().describe("Node ID to verify"),
4416
+ expected: z23.record(z23.string(), z23.unknown()).describe("Expected property values, e.g. { fill: '#3B82F6', cornerRadius: 8 }")
4032
4417
  },
4033
4418
  { readOnlyHint: true },
4034
4419
  async ({ nodeId, expected }) => {
@@ -4079,8 +4464,8 @@ function register21(server, sendCommand) {
4079
4464
  "validate_design_rules",
4080
4465
  "Run design quality rules on a node: token coverage, token scopes, cross-component token borrowing, dimension stability across states, fixed-size text that would clip, auto-layout usage, naming conventions, and accessibility basics. Use after modifications and before calling a component done \u2014 these rules catch the faults that look perfect on the canvas and surface weeks later in someone else's file. Returns pass/fail for each rule with fix suggestions.",
4081
4466
  {
4082
- nodeId: z22.string().optional().describe("Root node to validate (default: current selection)"),
4083
- platform: z22.enum(["desktop", "mobile"]).optional().describe("Target-size profile for the touch rule: desktop 24px (default), mobile 44px")
4467
+ nodeId: z23.string().optional().describe("Root node to validate (default: current selection)"),
4468
+ platform: z23.enum(["desktop", "mobile"]).optional().describe("Target-size profile for the touch rule: desktop 24px (default), mobile 44px")
4084
4469
  },
4085
4470
  { readOnlyHint: true },
4086
4471
  async ({ nodeId, platform }) => {
@@ -4281,8 +4666,8 @@ function register21(server, sendCommand) {
4281
4666
  "verify_visual",
4282
4667
  "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.",
4283
4668
  {
4284
- nodeId: z22.string().describe("Node to verify"),
4285
- expected: z22.record(z22.string(), z22.unknown()).optional().describe("Expected property values, e.g. { cornerRadius: 8, width: 320 }")
4669
+ nodeId: z23.string().describe("Node to verify"),
4670
+ expected: z23.record(z23.string(), z23.unknown()).optional().describe("Expected property values, e.g. { cornerRadius: 8, width: 320 }")
4286
4671
  },
4287
4672
  { readOnlyHint: true },
4288
4673
  async ({ nodeId, expected }) => {
@@ -4380,50 +4765,51 @@ function registerRoomTool(server, joinRoom, listRooms) {
4380
4765
  "join_room",
4381
4766
  "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.",
4382
4767
  {
4383
- room: z23.string().optional().describe("Room name shown in the Figma plugin. Omit to list connected rooms.")
4768
+ room: z24.string().optional().describe("Room name shown in the Figma plugin. Omit to list connected rooms.")
4384
4769
  },
4385
4770
  async ({ room }) => {
4386
4771
  try {
4387
4772
  if (!room) {
4388
- const rooms2 = (await listRooms()).filter((r) => r.hasPlugin);
4389
- if (rooms2.length === 0) {
4773
+ const rooms = (await listRooms()).filter((r) => r.hasPlugin);
4774
+ if (rooms.length === 0) {
4390
4775
  return {
4391
4776
  content: [{ type: "text", text: "No Figma plugin is connected. Open the Relai plugin in Figma (it connects automatically), then try again." }]
4392
4777
  };
4393
4778
  }
4394
- const lines = rooms2.map(
4779
+ const lines = rooms.map(
4395
4780
  (r) => `- ${r.room}${r.fileName ? ` \u2014 "${r.fileName}"` : ""}`
4396
4781
  );
4397
4782
  return {
4398
4783
  content: [{
4399
4784
  type: "text",
4400
- text: rooms2.length === 1 ? `One plugin connected (auto-pairing will use it):
4401
- ${lines[0]}` : `${rooms2.length} plugins connected \u2014 call join_room with the room of the file you want:
4785
+ text: rooms.length === 1 ? `One plugin connected (auto-pairing will use it):
4786
+ ${lines[0]}` : `${rooms.length} plugins connected \u2014 call join_room with the room of the file you want:
4402
4787
  ${lines.join("\n")}`
4403
4788
  }]
4404
4789
  };
4405
4790
  }
4406
- await joinRoom(room);
4407
- const rooms = await listRooms().catch(() => null);
4408
- const here = rooms?.find((r) => r.room === room && r.hasPlugin);
4409
- const where = !rooms ? "." : here ? ` \u2014 "${here.fileName ?? "unnamed file"}".` : " \u2014 no plugin is there yet; commands will fail until that file's plugin opens.";
4791
+ const report = await joinRoom(room);
4792
+ const where = report.pluginPresent ? ` \u2014 "${report.file ?? "unnamed file"}"${report.branchOf ? `, a branch of "${report.branchOf}"` : ""}.` : " \u2014 no plugin is there yet; commands will fail until that file's plugin opens.";
4793
+ const meta = {
4794
+ v: 1,
4795
+ calls: [],
4796
+ target: {
4797
+ room: report.room,
4798
+ ...report.file ? { file: report.file } : {},
4799
+ ...report.branchOf ? { branchOf: report.branchOf } : {}
4800
+ }
4801
+ };
4410
4802
  return {
4411
4803
  content: [
4412
4804
  {
4413
4805
  type: "text",
4414
4806
  text: `Joined room ${room}${where} Every agent in this MCP server process (one client session) shares this room \u2014 a join_room from any of them moves them all.`
4415
4807
  }
4416
- ]
4808
+ ],
4809
+ _meta: { [CALL_META_KEY]: meta }
4417
4810
  };
4418
4811
  } catch (error) {
4419
- return {
4420
- content: [
4421
- {
4422
- type: "text",
4423
- text: `Error joining room: ${error instanceof Error ? error.message : String(error)}`
4424
- }
4425
- ]
4426
- };
4812
+ return errorResult(error);
4427
4813
  }
4428
4814
  }
4429
4815
  );
@@ -4431,9 +4817,9 @@ ${lines.join("\n")}`
4431
4817
  function registerEventsTool(server, consumeEvents, getSessionLog) {
4432
4818
  server.tool(
4433
4819
  "get_events",
4434
- "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.",
4820
+ "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, outcome (applied/not_applied/unknown) and error code \u2014 useful for summarizing what was changed. 'all': both.",
4435
4821
  {
4436
- scope: z23.enum(["designer", "agent", "all"]).optional()
4822
+ scope: z24.enum(["designer", "agent", "all"]).optional()
4437
4823
  },
4438
4824
  { readOnlyHint: true },
4439
4825
  async ({ scope = "designer" }) => {
@@ -4457,8 +4843,9 @@ function registerEventsTool(server, consumeEvents, getSessionLog) {
4457
4843
  );
4458
4844
  }
4459
4845
  function registerAllTools(server, sendCommand) {
4846
+ const env = withCallEnvelope(server, sendCommand);
4460
4847
  for (const [mod] of moduleCategories) {
4461
- mod.register(server, sendCommand);
4848
+ mod.register(env.server, env.sendCommand);
4462
4849
  }
4463
4850
  }
4464
4851
  function listToolCatalog() {
@@ -4477,8 +4864,92 @@ function listToolCatalog() {
4477
4864
  return catalog;
4478
4865
  }
4479
4866
 
4867
+ // src/server.ts
4868
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4869
+ function createServer() {
4870
+ return new McpServer(
4871
+ {
4872
+ name: "Relai",
4873
+ version: VERSION
4874
+ },
4875
+ {
4876
+ instructions: `
4877
+ You control Figma through ~30 consolidated tools. Pairing with the Figma plugin is automatic \u2014 just call any tool; join_room is only needed if an error reports multiple plugins. With several files open, pass room (or expect_file) on each call instead of relying on the session's default room.
4878
+
4879
+ \u{1F4D6} UNDERSTAND (start here):
4880
+ get_document_overview \u2014 structure, page/component/style/variable counts
4881
+ get_selection_context \u2014 full context for what the designer selected
4882
+ get_node_details \u2014 deep single-node inspection (CSS, token bindings)
4883
+ search_nodes \u2014 find nodes by name/type \xB7 get_node_data \u2014 raw data/tree/css
4884
+ get_design_tokens \u2014 variable collections, modes, styles
4885
+ screenshot \u2014 see the canvas (use this to verify your work visually)
4886
+
4887
+ \u{1F50D} DIAGNOSE:
4888
+ analyze_design \u2014 aspect: color/layout/components/accessibility, or
4889
+ "overall" for a weighted 0-100 health score across all four
4890
+ diff_nodes \u2014 compare two nodes, or checkpoint save/compare to audit
4891
+ what changed on a node over an editing session
4892
+
4893
+ \u270F\uFE0F EDIT:
4894
+ create_node \u2014 any node type (rectangle/frame/text/svg/image/\u2026)
4895
+ set_properties \u2014 one call for geometry, fills, strokes, effects, text,
4896
+ auto-layout, constraints, style/variable bindings, on one or many nodes
4897
+ set_text \u2014 single, bulk, or character-range text edits
4898
+ edit_structure \u2014 group/reparent/reorder/clone/flatten/boolean/delete
4899
+ navigate \u2014 focus/select/viewport/switch_page \xB7 manage_pages
4900
+
4901
+ \u{1F9F1} DESIGN SYSTEM:
4902
+ manage_components \u2014 create/variants/instantiate/overrides/props/detach
4903
+ manage_variables \u2014 collections, modes, tokens, bind/unbind
4904
+ manage_styles \u2014 paint/text/effect/grid styles
4905
+ import_from_library \u2014 bring in library components/styles/variables
4906
+
4907
+ \u{1F4E6} ASSETS: export_asset (PNG/JPG/SVG/PDF) \xB7 add_image (URL or fill)
4908
+ \u{1F4DD} ANNOTATIONS: annotate (Dev Mode annotations)
4909
+ \u{1F4AC} COMMENTS: manage_comments \u2014 read/apply/reply to file comments
4910
+ (needs FIGMA_TOKEN env; the tool explains setup when missing)
4911
+
4912
+ \u2705 VERIFY: verify_changes \xB7 validate_design_rules
4913
+
4914
+ \u26A1 ADVANCED:
4915
+ batch_execute \u2014 run many plugin-level commands in one round-trip
4916
+ execute_figma \u2014 run JavaScript against the Figma Plugin API directly
4917
+ (the escape hatch when no tool fits; small incremental scripts,
4918
+ screenshot between steps; the designer can disable it)
4919
+
4920
+ OPERATING PRINCIPLES:
4921
+ - Inspect context BEFORE changing things; don't assume selection state
4922
+ - After visual edits, verify: screenshot or verify_changes
4923
+ - Colors are RGBA in 0-1 range (Figma format), not 0-255
4924
+ - layoutSizing FILL requires the node's PARENT to have auto-layout;
4925
+ set layoutMode on a frame before padding/alignment/spacing
4926
+ - Responses include recommended_next tools \u2014 follow them
4927
+ - Errors name the node, its type, and the fix \u2014 read them, don't retry blindly
4928
+
4929
+ READING RESULTS:
4930
+ - Writes and failures end with a "[relai]" line saying whether Figma changed:
4931
+ applied (the command ran) \xB7 not_applied (nothing changed) \xB7
4932
+ unknown (it may have changed \u2014 read the canvas before retrying;
4933
+ never resend a write blindly)
4934
+ - Failures set isError and name an error code (e.g. timeout, guard_blocked);
4935
+ the full receipt is in _meta["${CALL_META_KEY}"]
4936
+ - Text over 50,000 characters is cut; the cut notice names a local file
4937
+ that holds the full result
4938
+
4939
+ TARGETING A FILE (every tool except join_room and get_events):
4940
+ - room: send this one call to that plugin room; the session's default room
4941
+ (set by join_room, shared by every agent in this server process) stays put
4942
+ - expect_file: refuse unless the target file's name is exactly this
4943
+ (a branch has its own name); a refusal changes nothing (not_applied)
4944
+ - room_changed: the plugin moved to a different file since the last call;
4945
+ nothing was sent, and sending again continues in the new file
4946
+ `
4947
+ }
4948
+ );
4949
+ }
4950
+
4480
4951
  // ../../docs/skills/figma-plugin-api.md
4481
- 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. Two things make this bite later than it should. `getNodeByIdAsync` resolves a node on an unloaded page perfectly well, so a handler handed a node id looks healthy right up to the moment it walks children. And the page is often already loaded by whatever ran first, so identical code passes in a session where another tool touched that page and throws in one where it did not \u2014 which reads as flakiness rather than a missing load. Load the page you need yourself; and if the caller swallows handler errors, this stops being a failure and becomes a check that silently disappears.\n- **`layoutSizingHorizontal/Vertical` throw without auto-layout** \u2014 FILL requires the parent to have a `layoutMode`, HUG requires it on the node itself.\n- **`createPage()` throws on the free plan once a file has 3 pages** \u2014 reuse an existing page instead.\n- **`width`/`height` are read-only** \u2014 use `node.resize(w, h)`.\n- **`figma.mixed` is a Symbol** returned by `fontSize`, `cornerRadius`, `fills` etc. when values differ across ranges/children \u2014 check `=== figma.mixed` before use; never JSON-serialize it.\n- **Exact-name lookups are fragile** \u2014 designers rename pages and layers freely; locate nodes by type/content instead of `name ===`.\n- **Stale node ids throw `does not exist`** \u2014 nodes get deleted while you work; re-read before editing and check `node.removed`.\n- **Nodes are non-extensible** \u2014 `node.myCustomProp = x` throws `object is not extensible`; use `setPluginData` or return the data instead.\n- **Shadow `spread` renders only on shapes or frames with `clipsContent: true`** \u2014 a focus ring built from spread shadows is invisible on a non-clipping frame/component.\n- **`resize()` on an auto-layout frame silently pins that axis to FIXED**, overriding `primaryAxisSizingMode: \"AUTO\"`. Append children first, then set `layoutSizingHorizontal = \"HUG\"`; use `resize` only for the fixed cross-axis.\n- **Per-corner radius** (`topLeftRadius` \u2026) only exists on RectangleCornerMixin nodes (rectangles, frames, components) \u2014 polygons, stars and lines throw.\n- **Instance children can't be added or removed** \u2014 detach first, or edit the main component.\n- **Figma's slot feature (Convert to slot, \u21E7\u2318S) has no Plugin API** \u2014 no createSlot/convertToSlot exists (verified against typings 1.123 and at runtime, 2026-07). Scaffold a frame named \"Slot\", select it for the designer (navigate select), and let them press the shortcut.\n- **A converted SLOT node stops hugging** \u2014 after the designer converts a frame to a slot it carries its own fixed size. Re-assert `layoutSizingVertical = \"HUG\"` (and the intended horizontal mode) on the SLOT node afterwards; the API can still edit its layout props.\n- **Variantizing can silently pin the hug axis to FIXED** \u2014 observed after clone \u2192 counterAxisSizingMode/width-variable changes \u2192 combineAsVariants: the variants' `primaryAxisSizingMode` ended up FIXED though the source hugged. Always verify sizing modes on every variant after combineAsVariants and re-assert `\"AUTO\"`.\n- **A backgrounded Figma window suspends the plugin iframe** \u2014 timers freeze and the socket can't pong, so the relay's staleness sweep kicks the plugin and the frozen 2s redial never fires until the window regains attention. The panel redials on visibilitychange/focus (ui.html \u22650.2.4); when a paired plugin goes missing mid-session, ask the designer to click the Figma window before assuming anything is broken.\n- **A freshly-converted slot can still report `type: \"FRAME\"` to the Plugin API** \u2014 the layer panel shows the slot badge while getNodeByIdAsync returns FRAME (a reload boundary was observed to fix it). Never gate automation on `type === \"SLOT\"`; match the frame by name and trust the designer's visual confirmation.\n- **Deleted variables are soft-deleted ghosts** \u2014 `getVariableByIdAsync` still resolves them with name AND working values (`Variable` has no `removed` property), so bindings to them keep rendering while being invisible in pickers and dead on publish. To test aliveness, check membership in `getLocalVariablesAsync()` (remote variables are legitimately absent \u2014 check `variable.remote`).\n- **Variables cannot be reordered via the API** (`variableIds` is read-only; the `move\u2026After` methods exist only for styles) \u2014 new variables always append last; panel order is manual-drag only. NEVER delete-and-recreate variables to reorder: bindings reference variable IDs, so every binding in the file silently dies (observed in production; the ghosts keep rendering, hiding the damage).\n- **A remote (library) text style dominates variable bindings** \u2014 on text with a remote style applied, `setBoundVariable` writes appear to succeed but snap back to the style's own bindings. Clear the style first (`await setTextStyleIdAsync(\"\")`), then bind.\n- **Binding writes inside instances silently revert \u2014 with two escape hatches.** `setBoundVariable` on a node nested inside an INSTANCE succeeds without error, then reverts (instance roots placed directly in a main ARE writable; anything deeper is not). What does persist: reassigning the whole `fills`/`strokes` array (variable-bound paints ride inside), and range-level `setRangeBoundVariable` on text. Verify after writing; never trust the call alone.\n- **A bound `strokeWeight` renders nothing while `strokes` is empty, and wrappers don't hug stroke thickness.** A LINE's visible weight lives entirely in its stroke paint; the frame around it keeps its own raw height/width, so the panel shows an unbound number with empty Fill/Stroke \u2014 looking broken when it isn't. Inspect the LINE child's stroke bindings, not the wrapper; and bind the wrapper's thickness-axis dimension to the same variable so nothing stays raw.\n- **`setBoundVariable(\"strokeWeight\", \u2026)` is a silent no-op on FRAME-family nodes.** Frames and components bind stroke weight per side \u2014 `strokeTopWeight` / `strokeRightWeight` / `strokeBottomWeight` / `strokeLeftWeight`; the uniform `strokeWeight` field only binds on stroke-geometry nodes like LINE. No error is thrown, the binding just never appears \u2014 read back `boundVariables` after writing.\n- **Cloning a variant escapes its component set \u2014 onto whatever page the user is viewing.** `variant.clone()` parents the copy to `figma.currentPage` as a standalone COMPONENT (not into the set) \u2014 plain nodes inside components clone to the current page too. Every `componentPropertyReferences` pointing at set-level properties is silently severed, and native SLOT children are demoted to plain FRAMEs (slot-ness needs set context). Recover by `componentSet.appendChild(clone)`, re-assigning ALL references (`visible`, `slotContentId`, \u2026) \u2014 a SLOT can be restored by cloning a surviving SLOT node from a sibling variant (SLOT clones keep their type) and relinking `componentPropertyReferences.slotContentId`. Audit every set-scoped feature after adoption; none of it comes back on its own.\n- **Uneven variant renames/deletes poison the whole component set.** If variants end up with mismatched axis segments (some carry `axis=value`, others don't) or duplicate names, the set enters an error state where `componentPropertyDefinitions` AND every instance's `variantProperties`/`setProperties` throw (`Component set has existing errors`). Escape: rename stragglers so all variants share the same axes with unique values (temporary `legacy-*` values work), which heals the set \u2014 then migrate and delete.\n- **`setProperties` DOES work on instances nested inside other instances** \u2014 it records as an override on the outer instance. Only variable-binding writes (`setBoundVariable`) silently revert inside instance internals. Don't skip nested instances when migrating variant or boolean properties; skip them only for binding writes.\n- **`Plugin runtime aborted` is an out-of-memory, not a timeout.** The stack shows `deepWrap` recursing into `newString` before `abort`. Reading deep-wrapped properties \u2014 `componentProperties`, `overrides`, `boundVariables`, `exportSettings` \u2014 copies a fresh object into the QuickJS sandbox on EVERY access, so a loop over tens of thousands of instances kills the runtime even though each read looks free. Chunk the scan and keep only the fields you need. (Real case: `componentProperties` over 29,861 instances aborted; the same scan over 7,139 was fine.)\n- **What a file USES is not what its library HAS.** `get_design_system`'s remote-used list only reports components the file already placed \u2014 a library component nobody has dragged in yet is invisible to the plugin, and no amount of scanning the document will reveal it. Read the published catalog before concluding a component doesn't exist (one real case: a design system's `TextArea` was declared missing and hand-rebuilt from a stretched `Text Field`, because the file had never used the real one).\n- **A component property's identity is the `#id` suffix, not its display name.** Renaming `show label#4525:247` to `hasLabel#4525:247` in the library keeps every instance's override \u2014 text and boolean values survive a library update untouched. VARIANT axes are the opposite: they have no id and are matched by name, so renaming an axis or its values (`layout=horizontal` \u2192 `labelPosition=side`) is the only thing that actually drops instances back to a default. When auditing a library migration, snapshot the variant axes and ignore the renamed text/boolean props.\n- **`importComponentSetByKeyAsync` returns the file's LOCAL copy, not the latest published version.** If the file holds a stale copy of a library component, the import hands back the stale one and looks authoritative. To see what is actually published, read `componentPropertyDefinitions` over REST (`/v1/files/{key}/nodes?ids={node_id}&depth=1`) and compare. Importing is also expensive \u2014 each call materialises the whole set (a 72-variant set is thousands of nodes), and a handful in one script will abort the sandbox.\n- **GRID auto-layout has read-only positions and imperative setters.** `gridRowAnchorIndex` / `gridColumnAnchorIndex` are readonly \u2014 move a child with `node.setGridChildPosition(row, column)` (spans stay writable: `gridColumnSpan`). `gridColumnSizes` / `gridRowSizes` reject array assignment (`no setter for property`), but `gridColumnCount` / `gridRowCount` are writable and each returned `GridTrackSize` has `.type` / `.value` setters \u2014 so add a track by raising the count, then set the new track's type. Shrinking the row count is how you drop a leftover empty row.\n- **`figma.root.findAll` throws in dynamic-page mode until you `await figma.loadAllPagesAsync()`.** Page-scoped work never needs it: `figma.currentPage` is already loaded and `relai.query` searches from a node you pass. Load the whole document only when the search genuinely spans pages \u2014 on a 40-page file it is seconds of work and memory.\n- **`instance.mainComponent` throws in dynamic-page mode** \u2014 `await instance.getMainComponentAsync()`. The same rule catches every sync sibling of an async API (`getNodeById`, `textStyleId =`, `page.children` on an unloaded page): when the message says *use X instead*, it means the whole traversal from that point is async.\n- **A variant has no component property definitions of its own** (`Can only get component property definitions of a component set or non-variant component`). They belong to the COMPONENT_SET \u2014 read `node.parent` when a variant is what you have, and add new TEXT/BOOLEAN properties to the set too: adding them to a single variant either fails or leaves the other variants without them.\n- **`SPACE_BETWEEN` centres a lone child.** A header built as label + value with the value hidden by default looks correct in the variant you were staring at and lands centred everywhere else \u2014 because Figma spreads a single item to the middle rather than leaving it at the start. When one side is optional, align the row `MIN` and let the first child FILL: the optional one stays pinned to the far edge when it appears, and the other stays put when it does not.\n- **Applying a text style changes the node's font, so a `characters` write right after it can throw for a font nobody loaded.** `await node.setTextStyleIdAsync(id)` may swap the family or weight; load the *new* font (`await figma.loadFontAsync(node.fontName)`) before writing text. Applying the style is also the last word on properties it owns \u2014 a `textDecoration` written in the same breath is overwritten by the style landing, so set it in a later call.\n- **Text edits inside a component SET propagate to every variant.** Applying a text style to one variant's label retypes the matching layer in all of them \u2014 same for `textDecoration` and text fills, at node level and via `setRange*` alike. Renaming the layer does not break the link (matching is positional). So a set whose sizes need DIFFERENT text styles cannot be styled in place: apply the styles while the components are still standalone and combine afterwards \u2014 an atom does not save you either unless the atom's variants are standalone components rather than a set of their own. Layers bound to one TEXT component property are linked through the property as well: every layer whose `characters` references it shares a single `textDecoration` \u2014 write it on one variant and all of them change, re-binding a layer pulls it back to the shared value, and only a layer whose reference is removed keeps its own. The binding step writes too: pointing five variants' titles at a new TEXT property replaced their `characters` with the property's default and reset their underline to `NONE`, while the `combineAsVariants` just before it had kept every variant's underline. So a label whose decoration differs per variant cannot sit on a shared TEXT property. And a decoration written in the same execution as `setTextStyleIdAsync` is clobbered by the style landing \u2014 set it in a later call.\n- **Writing `leadingTrim` on a text node detaches its text style** and strands the style's typography variables as node-level bindings \u2014 the node keeps rendering but no longer follows the style. `textDecoration` is safe (the style survives); `leadingTrim` is not. When a cap-trimmed label needs to sit in a full line box (so an icon beside it doesn't hang below the baseline, and toggling the icon doesn't change the height), leave the trim alone and bind the PARENT frame's height to the same leading variable the style uses.\n- **A variable bound to a component property's VALUE hides from `boundVariables`.** `node.boundVariables.componentProperties` lists the VARIANT properties and nothing else \u2014 a token bound to a TEXT or INSTANCE_SWAP property (an icon glyph name, a swapped instance) lives only in `instance.componentProperties[key].boundVariables.value`. Read one location and every such token looks unreferenced. Bindings authored as a property's *default* sit in a third place, `componentPropertyDefinitions[key].boundVariables`, on the SET.\n- **\"Plugin runtime aborted\" is out of memory, not a timeout**, and reading `instance.componentProperties` in bulk is the fastest way there: the getter deep-wraps every property object, so 1,000 instances costs about 4 seconds and 5,700 kills the sandbox. Memory is only reclaimed when the command returns, so the fix is slices \u2014 walk a fixed number of nodes per command and have the caller ask again \u2014 not a tidier traversal inside one script.\n- **`component.clone()` loses every `componentPropertyReferences` until it is back inside a SET** \u2014 and it does not get them back when you append it. Cloning a variant to make a new one therefore yields a variant whose label, `show*` toggles and swaps are all dead: the layers are there, the wiring is not. The clone renders convincingly because each layer keeps the *value* it had. After `set.appendChild(clone)`, walk the source variant and the clone in `findAll` order and copy `componentPropertyReferences` node for node.\n- **An instance whose variant you deleted becomes a ghost, and `setProperties` cannot reach it.** It still reports a `mainComponent` \u2014 named after the *deleted* variant, parented to the live set \u2014 but its `componentProperties` no longer carry the set's variant axes, so writes are dropped without an error. Figma does not fall back to the default variant. The repair is `instance.swapComponent(targetVariant)` and then `setProperties`; a variant-count audit will not find these, because the set itself looks healthy.\n- **SLOT properties are per-node and giving two of them the same name does not merge them.** A set whose variants each carry their own slot ends up with two entries \u2014 `Items Slot#1938:15` and `Items Slot#1938:18` \u2014 and renaming them alike only makes the panel show two rows nobody can tell apart, which is worse than the honest `Sm`/`Md` names. The consequence worth knowing before designing around it: content placed in one variant's slot does **not** survive switching to a variant that owns the other slot, and there is no way to express \"one slot, shared across variants\" today.\n- **`componentPropertyReferences` cannot be set on a layer inside a nested instance \u2014 but almost everything else on that layer can.** The references belong to that instance's own main component, so writing them throws `Cannot set component property references on instance sublayer`. The trap is over-correcting: excluding instance sublayers from the *paint* loop as well leaves half the job done and no error, because fills, characters and visibility on those same sublayers are perfectly writable \u2014 and in a component whose label or icon lives inside a nested atom, that is exactly where the colour you meant to change lives. Skip sublayers when copying references; include them when painting.\n- **Deleting a layer from a component can silently orphan instance-side overrides on layers you did not touch.** Figma re-creates part of the instance's subtree with fresh node ids, and any override keyed to the old ids is dropped \u2014 on the instance only. The component still renders correctly, which is exactly why nobody notices: the master is where you look. After removing layers from a set, diff each instance against its main property by property; a screenshot of the component proves nothing about its instances.\n- **Setting `layoutSizingHorizontal/Vertical = \"FIXED\"` right after `insertChild` freezes the node at the size the row just stretched it to**, not the size it wants. An icon instance that is 12\xD712 on its own lands in a 16-tall row, gets stretched, and the FIXED you meant as *don't grow* records 16\xD716 permanently. Insert, let the layout settle, and set `layoutAlign`/`layoutGrow` instead \u2014 or copy the posture of a healthy sibling in the same row rather than asserting sizing modes from scratch.\n- **A component that sizes itself from the inside must not be re-sized from the outside.** Where a wrapper hugs a child whose own width/height are bound to size tokens, the wrapper carries no bindings at all \u2014 that absence *is* the mechanism. Binding the outer instance's width/height to look tidy overrides the child and pins one size across every variant, and the mistake is invisible because the number is right in whichever variant you were looking at. Before binding a dimension, check whether the node already derives it.\n- **A long editing session leaves `findAll` enumerating instance sublayers that no longer exist.** After a few hundred `swapComponent` calls and nested component-property writes, `page.findAll` still returns proxies whose ids are gone: touching one \u2014 `getMainComponentAsync`, or merely `.name` \u2014 throws *The node (instance sublayer or table cell) with id \u2026 does not exist*, while `getNodeByIdAsync` on the same id answers `null`. The document is fine (it renders and validates); it is the enumeration that is stale, and a plugin reload clears it. Two consequences for doc-wide sweeps: wrap each node in its own try/catch, and never read the failing node inside the catch to report it.\n- **A plain write clears a variable-bound component property only on the node you write to \u2014 read back to confirm which node still holds the alias.** `instance.setProperties({ key: \"diamond\" })` does drop the `VARIABLE_ALIAS` on that instance (measured both with a different value and with the token's own resolved string). What surprises people is where the binding *else* lives: set it on one instance and the main component keeps its own alias, so re-reading the master still reports bound; and an instance nested two levels down (an icon instance inside a `Body Cell` inside a `Column`) carries its own override that the parent write never touches. Always read the property back on the exact node you meant to change, and sweep nested instances separately.\n- **Component property keys are `name#id`, and the name may itself contain `#`** \u2014 an icon component can ship `Icon Name#4866:0`, `Icon Name w/##4866:28` and `Icon Name w/###4866:56`. Splitting on the *first* `#` maps the last two to the same base name, so a lookup silently binds only one of them and the duotone layers keep their placeholder glyph. Split on `lastIndexOf(\"#\")`. The mirror trick is useful: matching on the `#id` tail alone follows a property across a rename, since `editComponentProperty` keeps the id.\n- **An instance under an explicit variable mode can get stuck on the placeholder \u2014 when a plugin creates it there, or switches its variant there \u2014 and waiting does not fix it.** Create an instance, drop it under a node whose Theme mode differs from the page's, and read it back: the nested variant *does* swap (`iconFamily=Phosphor` \u2192 `FA7`), but a component property bound to a variable resolves to the new variant's **default** rather than to the token, so every icon reads as the placeholder glyph. Measured: still wrong after three minutes untouched \u2014 in `characters` and in the rendered screenshot alike \u2014 and setting the *same* mode again changes nothing. What unsticks it is `clearExplicitVariableModeForCollection` and then `setExplicitVariableModeForCollection` **in two separate `execute_figma` calls**: clearing and re-setting inside one script leaves it stuck (measured both when read 1.5 seconds later in the same script and when read in the next call). The binding was never destroyed, only unresolved. The order does not matter: appending to a parent with no explicit mode and setting the parent's mode in a later call leaves the same placeholder. Nor does it take a new instance: switching an existing instance's variant with `setProperties` under a pinned parent drops the same way \u2014 glyph properties fall back to the unbound placeholder, icon fills to the default variable, a bound size to none \u2014 but only in modes that swap a nested instance to a different variant than its main uses; where the mode resolves to the main's own icon family, nothing drops. Clear-then-set in separate calls restored every binding in both cases. It is not a sure cure: in another probe (five button instances created in one call, their parent's mode set in the next), clearing and setting the mode in two further calls, then setting it on each instance too, left the two loading buttons' variable-bound glyph names on the placeholder. Writing the resolved values with `setProperties` did stick there, as long as only properties whose value differs are written: writes that repeated the variant properties' current values flipped the nested icon to the main's own variant in 2 of 7 cases (both on the instances with bound glyph names), and writing the differing value back fixed it. That write costs the binding \u2014 a plain write drops the alias on that node (see the plain-write entry) \u2014 so it suits a throwaway probe, not a file's instances. (Distinct from the few-second repaint lag a designer sees after switching a theme by hand on content that already exists.) A brand-switch probe built and screenshotted in one pass will therefore report that a whole file's icon tokens are broken when nothing is wrong: clear in one call, set in the next, read after that \u2014 and confirm against a node that was already in the file.\n- **`variable.remove()` succeeds even when instances still bind it, and the variable keeps working \u2014 so a retirement can look clean and change nothing.** After removing it, `getLocalVariablesAsync()` no longer lists it (the designer sees it gone from the panel) but `getVariableByIdAsync(id)` still resolves it, the bindings still render its last value, and no error is raised anywhere. The trap is upstream, in the consumer scan that told you it was safe: a walker that does `if (node.type === 'INSTANCE') { check its componentProperties; return }` never descends into nested instances, and component-property bindings mostly live there \u2014 an icon instance inside a `Body Cell` inside a `Column`. One retirement measured 0 consumers by that walk and 160 by a walk that recursed. Before removing a variable, recurse through instance children; afterwards the id keeps resolving forever (it is a tombstone, not proof of failure), so verify instead that it has left `getLocalVariablesAsync()` and that a recursive scan finds zero bindings.\n- **`clone()` on a variant downgrades a SLOT child to a plain FRAME and drops its `slotContentId` reference** \u2014 the copy looks right and quietly loses the slot. Re-assigning `componentPropertyReferences = { slotContentId: '<Slot#id>' }` to the cloned frame restores the behaviour: the master still reports `FRAME`, but instances of it materialise a real `SLOT` child, expose the SLOT property, and accept appended content. There is no `figma.createSlot()`, so this reassignment is the only repair from code; duplicating the variant in the Figma UI keeps the SLOT type intact. Relai's post-run lint now names the slotless variants, but only for component sets it can reach \u2014 return the ids of what you cloned.\n- **`setBoundVariable` takes a Variable object, not an id string** \u2014 the string form throws `Cannot call setBoundVariable with a variable id. Please pass a variable object`. Fetch the variable first (`getVariableByIdAsync`, or a map built from `getLocalVariablesAsync`) and pass the object itself.\n- **Giving a node INSIDE an instance a new width/height from code does nothing \u2014 rebinding width/height and `resize()` are silent no-ops, and `rescale()` throws.** `setBoundVariable` returns without error and `boundVariables` still shows the old id afterwards. Only `setBoundVariable(field, null)` (unbind) persists there. On a nested instance (a logo inside a footer instance), `resize()`, `resizeWithoutConstraints()` and `resize()` after `unlockAspectRatio()` all returned normally and read back the old 105.3\xD748, and `rescale(0.75)` threw `This property cannot be overridden in an instance: size` \u2014 change the size in the main (a variant with that size) instead. To back out, `removeOverrides()` on the nested instance clears only the overrides from that layer down; the outer instance keeps its own. Stuck overrides \u2014 bindings an instance kept from a main that has since been cleaned \u2014 can therefore never be re-pointed from code, only cleared; and if the main is already clean, clearing IS the correct repair (the node falls back to the main's shape, pixels stay put). Measured on 301 soft-deleted `Size/height/12` bindings (2026-08-12): rebind reported success \xD7301 and changed nothing; unbind cleared every one. Always read the field back after writing \u2014 success without verification is how this one hides. One sizing write does persist inside an instance: setting `layoutSizingHorizontal` / `layoutSizingVertical` to `HUG` on the inner node is kept, and it also clears stale overrides that point at nodes which no longer exist.\n- **One broken component set crashes a whole-file property scan**: `instance.componentProperties` (and a set's `componentPropertyDefinitions`) throw `Component set has existing errors` when the backing set has variant conflicts \u2014 and the set may not even be on any page (a soft-deleted draft still referenced by one forgotten instance). A file-wide audit that reads properties uncaught dies on the first such instance. Guard each read with try/catch, report skips, and find the culprit cheaply: collect unique `mainComponent.parent` sets across instances and probe `componentPropertyDefinitions` on each (a few hundred sets) instead of touching every instance.\n- **Dot-prefixed components are never published**, so their keys 404 on every REST endpoint. Figma hides `.`-prefixed assets from the publish dialog; they exist only nested inside published parents. When resolving a key \u2192 file via `/v1/components/{key}`, skip names starting with `.` \u2014 and note a component SET's key resolves only at `/v1/component_sets/{key}`, never at `/v1/components/{key}` (and vice versa). Relatedly, `/v1/files/{key}/components` returns the individual VARIANTS (`size=md, state=default`); the components a designer names live in `/component_sets`.\n- **Freshly created frames and components count their strokes into hug sizing** \u2014 `strokesIncludedInLayout` defaults to `true`, so a hairline bottom divider makes an auto-layout row report 40.5 instead of 40, and an outlined container reports two extra pixels. The half-pixel survives into every instance and into any math you do on the height. Components that should keep integer dimensions need `strokesIncludedInLayout = false` set explicitly; existing hand-built components in a file often already have it off, which is why cloning them behaves differently from building from scratch.\n- **Converting a frame to a Slot in the Figma UI freezes its sizing at FIXED and strips the property references of everything inside it.** The converted node keeps the width/height it had at conversion time as a hard FIXED size (a later `layoutSizingHorizontal = \"HUG\"` is rejected; `\"FILL\"` is accepted), and a TEXT child that carried a `characters` reference (`label#\u2026`) comes out with the reference gone while the component property itself survives on the set \u2014 the panel shows the control, typing into it does nothing. After any frame\u2192Slot conversion: re-assert the slot's sizing explicitly and re-wire `componentPropertyReferences` on its former children. (`figma.createSlot` does not exist \u2014 conversion is UI-only, so this cleanup is always manual-plus-script.)\n- **`combineAsVariants` re-keys every component property and can drop a TEXT property's `characters` reference on the way.** The ids in property keys (`title#1033:0`) belong to the standalone components; combining mints new set-level keys (`title#7527:4`) \u2014 so any key strings you saved before combining are stale afterwards. Worse, the re-wiring is lossy: in one measured combine the `title` reference survived on all variants while the `description` reference vanished from all of them, with no error. Existing instances do ride through the re-keying: when `combineAsVariants` turned a lone component into a set of one variant (the second was added afterwards), its TEXT key changed (`text#N:61` \u2192 `text#N:62`), the reference survived, and all 176 instances kept their text, size and position. After every combine, audit `componentPropertyReferences` across all variants against the set's `componentPropertyDefinitions` and re-wire the orphans (survival is luck, not a rule \u2014 this is the same disease as duplicated variants keeping no references, in a different coat).\n- **Writing `textAutoResize` resets a text node's truncation.** Assigning `NONE`, `HEIGHT` or `WIDTH_AND_HEIGHT` \u2014 including the value it already holds \u2014 sets `textTruncation` back to `DISABLED` and `maxLines` to `null`, with no error (24/24 cases, auto-layout children and free nodes alike). The deprecated `TRUNCATE` keeps `ENDING` but still drops `maxLines`; don't lean on it. `resize()` and `layoutSizingHorizontal` leave `textTruncation` alone, but they can flip `textAutoResize` between `HEIGHT` and `WIDTH_AND_HEIGHT` on the way. Order the writes: `textAutoResize` first, then `textTruncation` and `maxLines`, and re-read all three after any sizing change. On a node whose `textAutoResize` is `NONE`, writing `ENDING` reads back as `TRUNCATE`, and `maxLines = 1` is accepted but reads back `null`.\n- **Removing an instance that has slots leaves undeletable orphans behind \u2014 whether it came from `createInstance()` or `clone()`.** An instance with SLOT content keeps each slot's content in a `FRAME` with `parent === null`, holding the slotted instances; while the instance exists that frame is its live storage, not litter. Delete the instance and the frame stays. It sits on no page and nothing shows it, but `remove()` on it (or on the instances inside it) throws `Removing this node is not allowed`, and `component.getInstancesAsync()` keeps returning those instances: one probe that cloned slot-bearing instances to check variable modes took a component set's instance count (summed over its variants) from 545 to 959, and two probes built with `createInstance()` and deleted again left 24 such frames. Whether reopening the file clears them has not been measured. Don't build probes out of slot-bearing instances \u2014 to see one under another mode, resolve its variables against an existing node in that mode (`variable.resolveForConsumer(node)`) \u2014 and in instance audits drop any result whose ancestor chain never reaches a PAGE.\n- **Overriding an instance's text freezes the layer's styling along with it.** Writing `characters` on a text layer inside an instance stores the layer's `styledTextSegments` in the override, so the layer stops following its main per variant: an instance made from a variant whose title has a 1px underline, switched to a variant whose title has a 2px one, kept its own text *and* the 1px \u2014 and it will not follow a later change to the main's text styling either. Binding the layer to a TEXT property instead has its own edge: switch to a variant whose layer is not bound to that property and the text reverts to that variant's own. Create the instance from the variant it should show before overriding its text, and read the decoration back after any variant switch.\n- **An instance reads its main component's pluginData as its own, and emptying a key on the instance leaves an override behind.** `instance.getPluginData(key)` returns the main's value when the instance never set one \u2014 clear the key on the main and it disappears from every instance, and a sweep that counts keys node by node counts each inherited copy again (one cleanup found 188 keys, 94 of them inherited). Writing `''` on an instance does empty its value, but `pluginData` stays in that instance's `overriddenFields`, and nothing removes that entry alone: `removeOverrides()` also discards every rename and resize beside it. Clear a marker on the node you set it on, and don't read a key found on an instance as proof that the instance itself was marked.\n- **`getNodeByIdAsync` throws on the bare id of a variable or collection.** Variables and collections draw from the same `N:M` counter as nodes, so a scan over an id range lands on them: `getNodeByIdAsync('N:M')` for a variable's number throws `unsupported type in createVMObject: VARIABLE` (`VARIABLE_SET` for a collection) instead of returning `null`, while the prefixed `VariableID:N:M` just returns `null`. Catch per id when sweeping ranges, and resolve variables through `figma.variables.getVariableByIdAsync`.\n- **Range reads throw on an empty range, so an empty text node breaks the usual font-loading loop.** `getRangeAllFontNames(0, node.characters.length)` on a node whose `characters` is `\"\"` throws `Empty range selected. 'end' must be greater than 'start'`, and so does any `getRange*` read with `start === end` (`getRangeFontName(0, 0)`, `getRangeAllFontNames(1, 1)` on `\"ab\"`). `node.fontName` answers normally there and `getStyledTextSegments(['fontName'])` returns `[]`. Load `fontName` when the text is empty, and call range APIs only when `end > start`.\n- **`exportAsync` renders only what a clipping ancestor leaves visible, so pixel diffs of clipped content compare nothing.** By default a node is exported at its `absoluteRenderBounds`, which a `clipsContent` ancestor cuts: a 100\xD7300 child of a clipping 100\xD7100 frame exports as 100\xD7100, and a child clipped away entirely (its `absoluteRenderBounds` is `null`) exports as a 1\xD71 image \u2014 so the hidden rows of a clipped table \"match\" before and after whatever you changed in them. `exportAsync({ format: \"PNG\", useAbsoluteBounds: true })` renders the whole node, byte-identical to exporting it with the ancestor unclipped; use it for before/after comparisons, or compare `absoluteRenderBounds` with `absoluteBoundingBox` before trusting a diff.\n- **Switching a nested instance's variant can discard the overrides its container put inside it.** Inside a component, `setProperties({ state: \"default\" })` on a nested icon button moved it to the new variant but dropped what the component had set on the button's icon: the three glyph properties bound to variables came back as the new variant's unbound default, and the icon's bound fill as the default variable \u2014 only the binding on the icon's variant property (`style`) survived. No error, and instances of the component show the placeholder from then on. After switching a nested instance's variant, re-read its content node by node and rebind from a sibling variant that is already in the target state.\n- **Slot content that is already on another variant when it enters the slot can come back with ids that resolve to nothing.** If a nested instance in the content is already on a different variant than the component defines for it when the content lands in the slot \u2014 an explicit mode swapped an icon to another family, or an override set a menu item to `state=hovered` \u2014 its descendants get ids missing the slot's segment: `getNodeByIdAsync` answers `null` for them, and two items can report the same id. Switching the variant after the content is in the slot keeps the ids whole. The repair measured on a 311-node menu: clear the mode in one call, set the swapped item to its default variant in place in the next and back in the one after, then set the mode \u2014 14 unreadable nodes went to 0; clearing and re-setting the mode alone fixed 13 of them, not a hidden layer. Walk slot content with a per-node try/catch that records the path instead of stopping.\n- **When a variable mode swaps a nested instance to another variant, the swap is stored as overrides, and later binding changes on the main stop reaching it.** Eighteen instances were created in one call and their parents' Theme modes set in the next: in the mode that resolves the nested icon to the main's own variant, the nested instance carried no overrides; in each of the five modes that swapped it, it carried `boundVariables`, `componentProperties`, `fills`, `name`, both axis sizing modes and `targetAspectRatio` as overrides. Rebinding that nested instance's `style` property to a new variable in the main then reached only the unswapped instance \u2014 the other five kept reading the old variable \u2014 while fresh instances with the modes set again all showed the new binding. Instances that were already in the file carried the same overrides. After changing a binding in a main, re-create probe instances rather than re-reading old ones, and read `overrides` on the nested layer before trusting that the change reached instances under other modes.\n- **A VECTOR whose corners are rounded per vertex ignores `cornerRadius` writes on an instance, and only `rescale` scales those corners.** When the radius lives in `vectorNetwork.vertices[i].cornerRadius` (a glyph outline with 12 and 9 rounded vertices), writing the node's `cornerRadius` inside an instance adds a `cornerRadius` entry to `overrides` and nothing else: it reads back the main's 0.449, the vertices keep it, and the render is pixel-identical. Writing the main's value back leaves the entry behind; only `removeOverrides()` clears it, along with every other override on that instance. `resize()` scales the outline but not the vertex radii, so an instance resized 2.4\xD7 draws sharper corners than the main scaled 2.4\xD7 (alpha differs by up to 175/255 at the corners). `rescale(2.4)` scales the vertex radii too (0.449 \u2192 1.079, within 16/255 of the scaled main), but it does not coexist with a variable-bound height: binding `height` after `rescale` resets `scaleFactor` to 1 and the radii to 0.449 (with the aspect ratio locked as well), and `rescale` after binding multiplies the bound height (72 \u2192 259.2). Pick either the bound height or proportional corners.\n- **A bound paint's `color` is only a fallback \u2014 and one component set kept the fallback on screen.** `figma.variables.setBoundVariableForPaint(paint, 'color', v)` returns the paint with your `color` untouched (a `{0,0,0}` placeholder stays `{0,0,0}`). Assigned through `fills` or `setRangeFills`, the stored color is normally replaced by the variable's resolved value: in eight probes (node fills on text and on a rectangle; `setRangeFills` over a whole text, part of one, a text in a set variant, in a standalone component and on a TEXT-property layer) it read back as the resolved gray, and the exported ones rendered it. But four ranges written with `setRangeFills` in two variants of a working component set kept `{0,0,0}` while still bound, and rendered black; a scan of the 168 bound paints around them found no other mismatch, and the trigger was not isolated. Build bound paints with the resolved color (take `{ r, g, b }` from `v.resolveForConsumer(node).value` \u2014 it carries `a`, and a paint's `color` has no alpha) instead of a dummy, and after writing compare each bound paint's stored `color` with the resolved value. The same showed up on an instance whose fill override was bound to the very variable its main uses but stored `{0,0,0}`: it exported black where the main exported light.\n- **A blank symbol in a text node is not proof the font lacks it \u2014 the node can be holding a stale layout.** Key labels drew `\u2318` as empty space on some nodes and fine on others, all in the same monospace font, which has no `\u2318`, `\u21E7` or `\u23CE`: Figma draws those with its own fallback glyph, and the blank nodes held an old layout \u2014 `width` 7, the missing-glyph advance, where drawn ones measured 9 for `\u2318`/`\u23CE` and 8 for `\u21E7`. Switching the font does not help: a font whose file does contain `\u2318` and `\u23CE` still drew the same fallback glyph, and writing that font on the key text inside the masters' nested instances spread wrongly \u2014 145 of the file's 301 keys came out wrong, including keys with no symbol at all. What brought the glyph back was a fresh layout, reached the long way: on the five keys that had been given that font, `removeOverrides()` (which also returned the TEXT property to the main's default), then restoring the layer name and writing the property back to its symbol \u2014 the glyph appeared and the five keys grew 1\u20132 px. Writing the same value again without clearing first was not tried. Before blaming the font, compare the node's `width` with a node that renders; to repair, clear the overrides, write the text property back, and re-read the width.\n- **`remove()` on a COMPONENT leaves its id resolving, so a `null` lookup cannot confirm the cleanup.** After removing a component \u2014 standalone, or a variant removed along with its set \u2014 `getNodeByIdAsync(id)` still returns it, children included, with `parent === null` and `removed === false`; calling `remove()` on it again returns normally and changes nothing. Measured on four components removed in the call that created them; the COMPONENT_SET itself, and plain frames, text and rectangles removed the same way, did resolve to `null`. The component sits on no page and no page's children list it, but id sweeps find it; whether it goes away later has not been measured. Confirm component cleanup by ancestry (the chain must no longer reach a PAGE) rather than `null`, and build temporary probes from frames or instances of existing components rather than new components.\n- **Renaming a variant axis with `editComponentProperty` unbinds the instances, and can reset their plain overrides too.** Instances bound to that property keep their value but lose the `boundVariables.componentProperties` alias, nested explicit overrides included. In one measured rename, 71 instances whose value for the axis was a plain override \u2014 not bound to anything \u2014 fell back to the set's default variant. Right after rebinding, some instances kept an old value that disagreed with what the mode resolves to; writing the value first and then rebinding fixed them. Before renaming an axis, record every instance's value and binding for it; afterwards write the values back, rebind, and read both back.\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";
4952
+ 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. Two things make this bite later than it should. `getNodeByIdAsync` resolves a node on an unloaded page perfectly well, so a handler handed a node id looks healthy right up to the moment it walks children. And the page is often already loaded by whatever ran first, so identical code passes in a session where another tool touched that page and throws in one where it did not \u2014 which reads as flakiness rather than a missing load. Load the page you need yourself; and if the caller swallows handler errors, this stops being a failure and becomes a check that silently disappears.\n- **`layoutSizingHorizontal/Vertical` throw without auto-layout** \u2014 FILL requires the parent to have a `layoutMode`, HUG requires it on the node itself.\n- **`createPage()` throws on the free plan once a file has 3 pages** \u2014 reuse an existing page instead.\n- **`width`/`height` are read-only** \u2014 use `node.resize(w, h)`.\n- **`figma.mixed` is a Symbol** returned by `fontSize`, `cornerRadius`, `fills` etc. when values differ across ranges/children \u2014 check `=== figma.mixed` before use; never JSON-serialize it.\n- **Exact-name lookups are fragile** \u2014 designers rename pages and layers freely; locate nodes by type/content instead of `name ===`.\n- **Stale node ids throw `does not exist`** \u2014 nodes get deleted while you work; re-read before editing and check `node.removed`.\n- **Nodes are non-extensible** \u2014 `node.myCustomProp = x` throws `object is not extensible`; use `setPluginData` or return the data instead.\n- **Shadow `spread` renders only on shapes or frames with `clipsContent: true`** \u2014 a focus ring built from spread shadows is invisible on a non-clipping frame/component.\n- **`resize()` on an auto-layout frame silently pins that axis to FIXED**, overriding `primaryAxisSizingMode: \"AUTO\"`. Append children first, then set `layoutSizingHorizontal = \"HUG\"`; use `resize` only for the fixed cross-axis.\n- **Per-corner radius** (`topLeftRadius` \u2026) only exists on RectangleCornerMixin nodes (rectangles, frames, components) \u2014 polygons, stars and lines throw.\n- **Instance children can't be added or removed** \u2014 detach first, or edit the main component.\n- **Figma's slot feature (Convert to slot, \u21E7\u2318S) has no Plugin API** \u2014 no createSlot/convertToSlot exists (verified against typings 1.123 and at runtime, 2026-07). Scaffold a frame named \"Slot\", select it for the designer (navigate select), and let them press the shortcut.\n- **A converted SLOT node stops hugging** \u2014 after the designer converts a frame to a slot it carries its own fixed size. Re-assert `layoutSizingVertical = \"HUG\"` (and the intended horizontal mode) on the SLOT node afterwards; the API can still edit its layout props.\n- **Variantizing can silently pin the hug axis to FIXED** \u2014 observed after clone \u2192 counterAxisSizingMode/width-variable changes \u2192 combineAsVariants: the variants' `primaryAxisSizingMode` ended up FIXED though the source hugged. Always verify sizing modes on every variant after combineAsVariants and re-assert `\"AUTO\"`.\n- **A backgrounded Figma window suspends the plugin iframe** \u2014 timers freeze and the socket can't pong, so the relay's staleness sweep kicks the plugin and the frozen 2s redial never fires until the window regains attention. The panel redials on visibilitychange/focus (ui.html \u22650.2.4); when a paired plugin goes missing mid-session, ask the designer to click the Figma window before assuming anything is broken.\n- **A freshly-converted slot can still report `type: \"FRAME\"` to the Plugin API** \u2014 the layer panel shows the slot badge while getNodeByIdAsync returns FRAME (a reload boundary was observed to fix it). Never gate automation on `type === \"SLOT\"`; match the frame by name and trust the designer's visual confirmation.\n- **Deleted variables are soft-deleted ghosts** \u2014 `getVariableByIdAsync` still resolves them with name AND working values (`Variable` has no `removed` property), so bindings to them keep rendering while being invisible in pickers and dead on publish. To test aliveness, check membership in `getLocalVariablesAsync()` (remote variables are legitimately absent \u2014 check `variable.remote`).\n- **Variables cannot be reordered via the API** (`variableIds` is read-only; the `move\u2026After` methods exist only for styles) \u2014 new variables always append last; panel order is manual-drag only. NEVER delete-and-recreate variables to reorder: bindings reference variable IDs, so every binding in the file silently dies (observed in production; the ghosts keep rendering, hiding the damage).\n- **A remote (library) text style dominates variable bindings** \u2014 on text with a remote style applied, `setBoundVariable` writes appear to succeed but snap back to the style's own bindings. Clear the style first (`await setTextStyleIdAsync(\"\")`), then bind.\n- **Binding writes inside instances silently revert \u2014 with two escape hatches.** `setBoundVariable` on a node nested inside an INSTANCE succeeds without error, then reverts (instance roots placed directly in a main ARE writable; anything deeper is not). What does persist: reassigning the whole `fills`/`strokes` array (variable-bound paints ride inside), and range-level `setRangeBoundVariable` on text. Verify after writing; never trust the call alone.\n- **A bound `strokeWeight` renders nothing while `strokes` is empty, and wrappers don't hug stroke thickness.** A LINE's visible weight lives entirely in its stroke paint; the frame around it keeps its own raw height/width, so the panel shows an unbound number with empty Fill/Stroke \u2014 looking broken when it isn't. Inspect the LINE child's stroke bindings, not the wrapper; and bind the wrapper's thickness-axis dimension to the same variable so nothing stays raw.\n- **`setBoundVariable(\"strokeWeight\", \u2026)` is a silent no-op on FRAME-family nodes.** Frames and components bind stroke weight per side \u2014 `strokeTopWeight` / `strokeRightWeight` / `strokeBottomWeight` / `strokeLeftWeight`; the uniform `strokeWeight` field only binds on stroke-geometry nodes like LINE. No error is thrown, the binding just never appears \u2014 read back `boundVariables` after writing.\n- **Cloning a variant escapes its component set \u2014 onto whatever page the user is viewing.** `variant.clone()` parents the copy to `figma.currentPage` as a standalone COMPONENT (not into the set) \u2014 plain nodes inside components clone to the current page too. Every `componentPropertyReferences` pointing at set-level properties is silently severed, and native SLOT children are demoted to plain FRAMEs (slot-ness needs set context). Recover by `componentSet.appendChild(clone)`, re-assigning ALL references (`visible`, `slotContentId`, \u2026) \u2014 a SLOT can be restored by cloning a surviving SLOT node from a sibling variant (SLOT clones keep their type) and relinking `componentPropertyReferences.slotContentId`. Audit every set-scoped feature after adoption; none of it comes back on its own.\n- **Uneven variant renames/deletes poison the whole component set.** If variants end up with mismatched axis segments (some carry `axis=value`, others don't) or duplicate names, the set enters an error state where `componentPropertyDefinitions` AND every instance's `variantProperties`/`setProperties` throw (`Component set has existing errors`). Escape: rename stragglers so all variants share the same axes with unique values (temporary `legacy-*` values work), which heals the set \u2014 then migrate and delete.\n- **`setProperties` DOES work on instances nested inside other instances** \u2014 it records as an override on the outer instance. Only variable-binding writes (`setBoundVariable`) silently revert inside instance internals. Don't skip nested instances when migrating variant or boolean properties; skip them only for binding writes.\n- **`Plugin runtime aborted` is an out-of-memory, not a timeout.** The stack shows `deepWrap` recursing into `newString` before `abort`. Reading deep-wrapped properties \u2014 `componentProperties`, `overrides`, `boundVariables`, `exportSettings` \u2014 copies a fresh object into the QuickJS sandbox on EVERY access, so a loop over tens of thousands of instances kills the runtime even though each read looks free. Chunk the scan and keep only the fields you need. (Real case: `componentProperties` over 29,861 instances aborted; the same scan over 7,139 was fine.)\n- **What a file USES is not what its library HAS.** `get_design_system`'s remote-used list only reports components the file already placed \u2014 a library component nobody has dragged in yet is invisible to the plugin, and no amount of scanning the document will reveal it. Read the published catalog before concluding a component doesn't exist (one real case: a design system's `TextArea` was declared missing and hand-rebuilt from a stretched `Text Field`, because the file had never used the real one).\n- **A component property's identity is the `#id` suffix, not its display name.** Renaming `show label#4525:247` to `hasLabel#4525:247` in the library keeps every instance's override \u2014 text and boolean values survive a library update untouched. VARIANT axes are the opposite: they have no id and are matched by name, so renaming an axis or its values (`layout=horizontal` \u2192 `labelPosition=side`) is the only thing that actually drops instances back to a default. When auditing a library migration, snapshot the variant axes and ignore the renamed text/boolean props.\n- **`importComponentSetByKeyAsync` returns the file's LOCAL copy, not the latest published version.** If the file holds a stale copy of a library component, the import hands back the stale one and looks authoritative. To see what is actually published, read `componentPropertyDefinitions` over REST (`/v1/files/{key}/nodes?ids={node_id}&depth=1`) and compare. Importing is also expensive \u2014 each call materialises the whole set (a 72-variant set is thousands of nodes), and a handful in one script will abort the sandbox.\n- **GRID auto-layout has read-only positions and imperative setters.** `gridRowAnchorIndex` / `gridColumnAnchorIndex` are readonly \u2014 move a child with `node.setGridChildPosition(row, column)` (spans stay writable: `gridColumnSpan`). `gridColumnSizes` / `gridRowSizes` reject array assignment (`no setter for property`), but `gridColumnCount` / `gridRowCount` are writable and each returned `GridTrackSize` has `.type` / `.value` setters \u2014 so add a track by raising the count, then set the new track's type. Shrinking the row count is how you drop a leftover empty row.\n- **`figma.root.findAll` throws in dynamic-page mode until you `await figma.loadAllPagesAsync()`.** Page-scoped work never needs it: `figma.currentPage` is already loaded and `relai.query` searches from a node you pass. Load the whole document only when the search genuinely spans pages \u2014 on a 40-page file it is seconds of work and memory.\n- **`instance.mainComponent` throws in dynamic-page mode** \u2014 `await instance.getMainComponentAsync()`. The same rule catches every sync sibling of an async API (`getNodeById`, `textStyleId =`, `page.children` on an unloaded page): when the message says *use X instead*, it means the whole traversal from that point is async.\n- **A variant has no component property definitions of its own** (`Can only get component property definitions of a component set or non-variant component`). They belong to the COMPONENT_SET \u2014 read `node.parent` when a variant is what you have, and add new TEXT/BOOLEAN properties to the set too: adding them to a single variant either fails or leaves the other variants without them.\n- **`SPACE_BETWEEN` centres a lone child.** A header built as label + value with the value hidden by default looks correct in the variant you were staring at and lands centred everywhere else \u2014 because Figma spreads a single item to the middle rather than leaving it at the start. When one side is optional, align the row `MIN` and let the first child FILL: the optional one stays pinned to the far edge when it appears, and the other stays put when it does not.\n- **Applying a text style changes the node's font, so a `characters` write right after it can throw for a font nobody loaded.** `await node.setTextStyleIdAsync(id)` may swap the family or weight; load the *new* font (`await figma.loadFontAsync(node.fontName)`) before writing text. Applying the style is also the last word on properties it owns \u2014 a `textDecoration` written in the same breath is overwritten by the style landing, so set it in a later call.\n- **Text edits inside a component SET propagate to every variant.** Applying a text style to one variant's label retypes the matching layer in all of them \u2014 same for `textDecoration` and text fills, at node level and via `setRange*` alike. Renaming the layer does not break the link (matching is positional). So a set whose sizes need DIFFERENT text styles cannot be styled in place: apply the styles while the components are still standalone and combine afterwards \u2014 an atom does not save you either unless the atom's variants are standalone components rather than a set of their own. Layers bound to one TEXT component property are linked through the property as well: every layer whose `characters` references it shares a single `textDecoration` \u2014 write it on one variant and all of them change, re-binding a layer pulls it back to the shared value, and only a layer whose reference is removed keeps its own. The binding step writes too: pointing five variants' titles at a new TEXT property replaced their `characters` with the property's default and reset their underline to `NONE`, while the `combineAsVariants` just before it had kept every variant's underline. So a label whose decoration differs per variant cannot sit on a shared TEXT property. And a decoration written in the same execution as `setTextStyleIdAsync` is clobbered by the style landing \u2014 set it in a later call.\n- **Writing `leadingTrim` on a text node detaches its text style** and strands the style's typography variables as node-level bindings \u2014 the node keeps rendering but no longer follows the style. `textDecoration` is safe (the style survives); `leadingTrim` is not. When a cap-trimmed label needs to sit in a full line box (so an icon beside it doesn't hang below the baseline, and toggling the icon doesn't change the height), leave the trim alone and bind the PARENT frame's height to the same leading variable the style uses.\n- **A variable bound to a component property's VALUE hides from `boundVariables`.** `node.boundVariables.componentProperties` lists the VARIANT properties and nothing else \u2014 a token bound to a TEXT or INSTANCE_SWAP property (an icon glyph name, a swapped instance) lives only in `instance.componentProperties[key].boundVariables.value`. Read one location and every such token looks unreferenced. Bindings authored as a property's *default* sit in a third place, `componentPropertyDefinitions[key].boundVariables`, on the SET.\n- **\"Plugin runtime aborted\" is out of memory, not a timeout**, and reading `instance.componentProperties` in bulk is the fastest way there: the getter deep-wraps every property object, so 1,000 instances costs about 4 seconds and 5,700 kills the sandbox. Memory is only reclaimed when the command returns, so the fix is slices \u2014 walk a fixed number of nodes per command and have the caller ask again \u2014 not a tidier traversal inside one script.\n- **`component.clone()` loses every `componentPropertyReferences` until it is back inside a SET** \u2014 and it does not get them back when you append it. Cloning a variant to make a new one therefore yields a variant whose label, `show*` toggles and swaps are all dead: the layers are there, the wiring is not. The clone renders convincingly because each layer keeps the *value* it had. After `set.appendChild(clone)`, walk the source variant and the clone in `findAll` order and copy `componentPropertyReferences` node for node.\n- **An instance whose variant you deleted becomes a ghost, and `setProperties` cannot reach it.** It still reports a `mainComponent` \u2014 named after the *deleted* variant, parented to the live set \u2014 but its `componentProperties` no longer carry the set's variant axes, so writes are dropped without an error. Figma does not fall back to the default variant. The repair is `instance.swapComponent(targetVariant)` and then `setProperties`; a variant-count audit will not find these, because the set itself looks healthy.\n- **SLOT properties are per-node and giving two of them the same name does not merge them.** A set whose variants each carry their own slot ends up with two entries \u2014 `Items Slot#1938:15` and `Items Slot#1938:18` \u2014 and renaming them alike only makes the panel show two rows nobody can tell apart, which is worse than the honest `Sm`/`Md` names. The consequence worth knowing before designing around it: content placed in one variant's slot does **not** survive switching to a variant that owns the other slot, and there is no way to express \"one slot, shared across variants\" today.\n- **`componentPropertyReferences` cannot be set on a layer inside a nested instance \u2014 but almost everything else on that layer can.** The references belong to that instance's own main component, so writing them throws `Cannot set component property references on instance sublayer`. The trap is over-correcting: excluding instance sublayers from the *paint* loop as well leaves half the job done and no error, because fills, characters and visibility on those same sublayers are perfectly writable \u2014 and in a component whose label or icon lives inside a nested atom, that is exactly where the colour you meant to change lives. Skip sublayers when copying references; include them when painting.\n- **Deleting a layer from a component can silently orphan instance-side overrides on layers you did not touch.** Figma re-creates part of the instance's subtree with fresh node ids, and any override keyed to the old ids is dropped \u2014 on the instance only. The component still renders correctly, which is exactly why nobody notices: the master is where you look. After removing layers from a set, diff each instance against its main property by property; a screenshot of the component proves nothing about its instances.\n- **Setting `layoutSizingHorizontal/Vertical = \"FIXED\"` right after `insertChild` freezes the node at the size the row just stretched it to**, not the size it wants. An icon instance that is 12\xD712 on its own lands in a 16-tall row, gets stretched, and the FIXED you meant as *don't grow* records 16\xD716 permanently. Insert, let the layout settle, and set `layoutAlign`/`layoutGrow` instead \u2014 or copy the posture of a healthy sibling in the same row rather than asserting sizing modes from scratch.\n- **A component that sizes itself from the inside must not be re-sized from the outside.** Where a wrapper hugs a child whose own width/height are bound to size tokens, the wrapper carries no bindings at all \u2014 that absence *is* the mechanism. Binding the outer instance's width/height to look tidy overrides the child and pins one size across every variant, and the mistake is invisible because the number is right in whichever variant you were looking at. Before binding a dimension, check whether the node already derives it.\n- **A long editing session leaves `findAll` enumerating instance sublayers that no longer exist.** After a few hundred `swapComponent` calls and nested component-property writes, `page.findAll` still returns proxies whose ids are gone: touching one \u2014 `getMainComponentAsync`, or merely `.name` \u2014 throws *The node (instance sublayer or table cell) with id \u2026 does not exist*, while `getNodeByIdAsync` on the same id answers `null`. The document is fine (it renders and validates); it is the enumeration that is stale, and a plugin reload clears it. Two consequences for doc-wide sweeps: wrap each node in its own try/catch, and never read the failing node inside the catch to report it.\n- **A plain write clears a variable-bound component property only on the node you write to \u2014 read back to confirm which node still holds the alias.** `instance.setProperties({ key: \"diamond\" })` does drop the `VARIABLE_ALIAS` on that instance (measured both with a different value and with the token's own resolved string). What surprises people is where the binding *else* lives: set it on one instance and the main component keeps its own alias, so re-reading the master still reports bound; and an instance nested two levels down (an icon instance inside a `Body Cell` inside a `Column`) carries its own override that the parent write never touches. Always read the property back on the exact node you meant to change, and sweep nested instances separately.\n- **Component property keys are `name#id`, and the name may itself contain `#`** \u2014 an icon component can ship `Icon Name#4866:0`, `Icon Name w/##4866:28` and `Icon Name w/###4866:56`. Splitting on the *first* `#` maps the last two to the same base name, so a lookup silently binds only one of them and the duotone layers keep their placeholder glyph. Split on `lastIndexOf(\"#\")`. The mirror trick is useful: matching on the `#id` tail alone follows a property across a rename, since `editComponentProperty` keeps the id.\n- **An instance under an explicit variable mode can get stuck on the placeholder \u2014 when a plugin creates it there, or switches its variant there \u2014 and waiting does not fix it.** Create an instance, drop it under a node whose Theme mode differs from the page's, and read it back: the nested variant *does* swap (`iconFamily=Phosphor` \u2192 `FA7`), but a component property bound to a variable resolves to the new variant's **default** rather than to the token, so every icon reads as the placeholder glyph. Measured: still wrong after three minutes untouched \u2014 in `characters` and in the rendered screenshot alike \u2014 and setting the *same* mode again changes nothing. What unsticks it is `clearExplicitVariableModeForCollection` and then `setExplicitVariableModeForCollection` **in two separate `execute_figma` calls**: clearing and re-setting inside one script leaves it stuck (measured both when read 1.5 seconds later in the same script and when read in the next call). The binding was never destroyed, only unresolved. The order does not matter: appending to a parent with no explicit mode and setting the parent's mode in a later call leaves the same placeholder. Nor does it take a new instance: switching an existing instance's variant with `setProperties` under a pinned parent drops the same way \u2014 glyph properties fall back to the unbound placeholder, icon fills to the default variable, a bound size to none \u2014 but only in modes that swap a nested instance to a different variant than its main uses; where the mode resolves to the main's own icon family, nothing drops. Clear-then-set in separate calls restored every binding in both cases. It is not a sure cure: in another probe (five button instances created in one call, their parent's mode set in the next), clearing and setting the mode in two further calls, then setting it on each instance too, left the two loading buttons' variable-bound glyph names on the placeholder. Writing the resolved values with `setProperties` did stick there, as long as only properties whose value differs are written: writes that repeated the variant properties' current values flipped the nested icon to the main's own variant in 2 of 7 cases (both on the instances with bound glyph names), and writing the differing value back fixed it. That write costs the binding \u2014 a plain write drops the alias on that node (see the plain-write entry) \u2014 so it suits a throwaway probe, not a file's instances. (Distinct from the few-second repaint lag a designer sees after switching a theme by hand on content that already exists.) A brand-switch probe built and screenshotted in one pass will therefore report that a whole file's icon tokens are broken when nothing is wrong: clear in one call, set in the next, read after that \u2014 and confirm against a node that was already in the file.\n- **`variable.remove()` succeeds even when instances still bind it, and the variable keeps working \u2014 so a retirement can look clean and change nothing.** After removing it, `getLocalVariablesAsync()` no longer lists it (the designer sees it gone from the panel) but `getVariableByIdAsync(id)` still resolves it, the bindings still render its last value, and no error is raised anywhere. The trap is upstream, in the consumer scan that told you it was safe: a walker that does `if (node.type === 'INSTANCE') { check its componentProperties; return }` never descends into nested instances, and component-property bindings mostly live there \u2014 an icon instance inside a `Body Cell` inside a `Column`. One retirement measured 0 consumers by that walk and 160 by a walk that recursed. Before removing a variable, recurse through instance children; afterwards the id keeps resolving forever (it is a tombstone, not proof of failure), so verify instead that it has left `getLocalVariablesAsync()` and that a recursive scan finds zero bindings.\n- **`clone()` on a variant downgrades a SLOT child to a plain FRAME and drops its `slotContentId` reference** \u2014 the copy looks right and quietly loses the slot. Re-assigning `componentPropertyReferences = { slotContentId: '<Slot#id>' }` to the cloned frame restores the behaviour: the master still reports `FRAME`, but instances of it materialise a real `SLOT` child, expose the SLOT property, and accept appended content. There is no `figma.createSlot()`, so this reassignment is the only repair from code; duplicating the variant in the Figma UI keeps the SLOT type intact. Relai's post-run lint now names the slotless variants, but only for component sets it can reach \u2014 return the ids of what you cloned.\n- **`setBoundVariable` takes a Variable object, not an id string** \u2014 the string form throws `Cannot call setBoundVariable with a variable id. Please pass a variable object`. Fetch the variable first (`getVariableByIdAsync`, or a map built from `getLocalVariablesAsync`) and pass the object itself.\n- **Giving a node INSIDE an instance a new width/height from code does nothing \u2014 rebinding width/height and `resize()` are silent no-ops, and `rescale()` throws.** `setBoundVariable` returns without error and `boundVariables` still shows the old id afterwards. Only `setBoundVariable(field, null)` (unbind) persists there. On a nested instance (a logo inside a footer instance), `resize()`, `resizeWithoutConstraints()` and `resize()` after `unlockAspectRatio()` all returned normally and read back the old 105.3\xD748, and `rescale(0.75)` threw `This property cannot be overridden in an instance: size` \u2014 change the size in the main (a variant with that size) instead. To back out, `removeOverrides()` on the nested instance clears only the overrides from that layer down; the outer instance keeps its own. Stuck overrides \u2014 bindings an instance kept from a main that has since been cleaned \u2014 can therefore never be re-pointed from code, only cleared; and if the main is already clean, clearing IS the correct repair (the node falls back to the main's shape, pixels stay put). Measured on 301 soft-deleted `Size/height/12` bindings (2026-08-12): rebind reported success \xD7301 and changed nothing; unbind cleared every one. Always read the field back after writing \u2014 success without verification is how this one hides. One sizing write does persist inside an instance: setting `layoutSizingHorizontal` / `layoutSizingVertical` to `HUG` on the inner node is kept, and it also clears stale overrides that point at nodes which no longer exist.\n- **One broken component set crashes a whole-file property scan**: `instance.componentProperties` (and a set's `componentPropertyDefinitions`) throw `Component set has existing errors` when the backing set has variant conflicts \u2014 and the set may not even be on any page (a soft-deleted draft still referenced by one forgotten instance). A file-wide audit that reads properties uncaught dies on the first such instance. Guard each read with try/catch, report skips, and find the culprit cheaply: collect unique `mainComponent.parent` sets across instances and probe `componentPropertyDefinitions` on each (a few hundred sets) instead of touching every instance.\n- **Dot-prefixed components are never published**, so their keys 404 on every REST endpoint. Figma hides `.`-prefixed assets from the publish dialog; they exist only nested inside published parents. When resolving a key \u2192 file via `/v1/components/{key}`, skip names starting with `.` \u2014 and note a component SET's key resolves only at `/v1/component_sets/{key}`, never at `/v1/components/{key}` (and vice versa). Relatedly, `/v1/files/{key}/components` returns the individual VARIANTS (`size=md, state=default`); the components a designer names live in `/component_sets`.\n- **Freshly created frames and components count their strokes into hug sizing** \u2014 `strokesIncludedInLayout` defaults to `true`, so a hairline bottom divider makes an auto-layout row report 40.5 instead of 40, and an outlined container reports two extra pixels. The half-pixel survives into every instance and into any math you do on the height. Components that should keep integer dimensions need `strokesIncludedInLayout = false` set explicitly; existing hand-built components in a file often already have it off, which is why cloning them behaves differently from building from scratch.\n- **Converting a frame to a Slot in the Figma UI freezes its sizing at FIXED and strips the property references of everything inside it.** The converted node keeps the width/height it had at conversion time as a hard FIXED size (a later `layoutSizingHorizontal = \"HUG\"` is rejected; `\"FILL\"` is accepted), and a TEXT child that carried a `characters` reference (`label#\u2026`) comes out with the reference gone while the component property itself survives on the set \u2014 the panel shows the control, typing into it does nothing. After any frame\u2192Slot conversion: re-assert the slot's sizing explicitly and re-wire `componentPropertyReferences` on its former children. (`figma.createSlot` does not exist \u2014 conversion is UI-only, so this cleanup is always manual-plus-script.)\n- **`combineAsVariants` re-keys every component property and can drop a TEXT property's `characters` reference on the way.** The ids in property keys (`title#1033:0`) belong to the standalone components; combining mints new set-level keys (`title#7527:4`) \u2014 so any key strings you saved before combining are stale afterwards. Worse, the re-wiring is lossy: in one measured combine the `title` reference survived on all variants while the `description` reference vanished from all of them, with no error. Existing instances do ride through the re-keying: when `combineAsVariants` turned a lone component into a set of one variant (the second was added afterwards), its TEXT key changed (`text#N:61` \u2192 `text#N:62`), the reference survived, and all 176 instances kept their text, size and position. After every combine, audit `componentPropertyReferences` across all variants against the set's `componentPropertyDefinitions` and re-wire the orphans (survival is luck, not a rule \u2014 this is the same disease as duplicated variants keeping no references, in a different coat).\n- **Writing `textAutoResize` resets a text node's truncation.** Assigning `NONE`, `HEIGHT` or `WIDTH_AND_HEIGHT` \u2014 including the value it already holds \u2014 sets `textTruncation` back to `DISABLED` and `maxLines` to `null`, with no error (24/24 cases, auto-layout children and free nodes alike). The deprecated `TRUNCATE` keeps `ENDING` but still drops `maxLines`; don't lean on it. `resize()` and `layoutSizingHorizontal` leave `textTruncation` alone, but they can flip `textAutoResize` between `HEIGHT` and `WIDTH_AND_HEIGHT` on the way. Order the writes: `textAutoResize` first, then `textTruncation` and `maxLines`, and re-read all three after any sizing change. On a node whose `textAutoResize` is `NONE`, writing `ENDING` reads back as `TRUNCATE`, and `maxLines = 1` is accepted but reads back `null`.\n- **Removing an instance that has slots leaves undeletable orphans behind \u2014 whether it came from `createInstance()` or `clone()`.** An instance with SLOT content keeps each slot's content in a `FRAME` with `parent === null`, holding the slotted instances; while the instance exists that frame is its live storage, not litter. Delete the instance and the frame stays. It sits on no page and nothing shows it, but `remove()` on it (or on the instances inside it) throws `Removing this node is not allowed`, and `component.getInstancesAsync()` keeps returning those instances: one probe that cloned slot-bearing instances to check variable modes took a component set's instance count (summed over its variants) from 545 to 959, and two probes built with `createInstance()` and deleted again left 24 such frames. Whether reopening the file clears them has not been measured. Don't build probes out of slot-bearing instances \u2014 to see one under another mode, resolve its variables against an existing node in that mode (`variable.resolveForConsumer(node)`) \u2014 and in instance audits drop any result whose ancestor chain never reaches a PAGE.\n- **Overriding an instance's text freezes the layer's styling along with it.** Writing `characters` on a text layer inside an instance stores the layer's `styledTextSegments` in the override, so the layer stops following its main per variant: an instance made from a variant whose title has a 1px underline, switched to a variant whose title has a 2px one, kept its own text *and* the 1px \u2014 and it will not follow a later change to the main's text styling either. Binding the layer to a TEXT property instead has its own edge: switch to a variant whose layer is not bound to that property and the text reverts to that variant's own. Create the instance from the variant it should show before overriding its text, and read the decoration back after any variant switch.\n- **An instance reads its main component's pluginData as its own, and emptying a key on the instance leaves an override behind.** `instance.getPluginData(key)` returns the main's value when the instance never set one \u2014 clear the key on the main and it disappears from every instance, and a sweep that counts keys node by node counts each inherited copy again (one cleanup found 188 keys, 94 of them inherited). Writing `''` on an instance does empty its value, but `pluginData` stays in that instance's `overriddenFields`, and nothing removes that entry alone: `removeOverrides()` also discards every rename and resize beside it. Clear a marker on the node you set it on, and don't read a key found on an instance as proof that the instance itself was marked.\n- **`getNodeByIdAsync` throws on the bare id of a variable or collection.** Variables and collections draw from the same `N:M` counter as nodes, so a scan over an id range lands on them: `getNodeByIdAsync('N:M')` for a variable's number throws `unsupported type in createVMObject: VARIABLE` (`VARIABLE_SET` for a collection) instead of returning `null`, while the prefixed `VariableID:N:M` just returns `null`. Catch per id when sweeping ranges, and resolve variables through `figma.variables.getVariableByIdAsync`.\n- **Range reads throw on an empty range, so an empty text node breaks the usual font-loading loop.** `getRangeAllFontNames(0, node.characters.length)` on a node whose `characters` is `\"\"` throws `Empty range selected. 'end' must be greater than 'start'`, and so does any `getRange*` read with `start === end` (`getRangeFontName(0, 0)`, `getRangeAllFontNames(1, 1)` on `\"ab\"`). `node.fontName` answers normally there and `getStyledTextSegments(['fontName'])` returns `[]`. Load `fontName` when the text is empty, and call range APIs only when `end > start`.\n- **`exportAsync` renders only what a clipping ancestor leaves visible, so pixel diffs of clipped content compare nothing.** By default a node is exported at its `absoluteRenderBounds`, which a `clipsContent` ancestor cuts: a 100\xD7300 child of a clipping 100\xD7100 frame exports as 100\xD7100, and a child clipped away entirely (its `absoluteRenderBounds` is `null`) exports as a 1\xD71 image \u2014 so the hidden rows of a clipped table \"match\" before and after whatever you changed in them. `exportAsync({ format: \"PNG\", useAbsoluteBounds: true })` renders the whole node, byte-identical to exporting it with the ancestor unclipped; use it for before/after comparisons, or compare `absoluteRenderBounds` with `absoluteBoundingBox` before trusting a diff.\n- **Switching a nested instance's variant can discard the overrides its container put inside it.** Inside a component, `setProperties({ state: \"default\" })` on a nested icon button moved it to the new variant but dropped what the component had set on the button's icon: the three glyph properties bound to variables came back as the new variant's unbound default, and the icon's bound fill as the default variable \u2014 only the binding on the icon's variant property (`style`) survived. No error, and instances of the component show the placeholder from then on. After switching a nested instance's variant, re-read its content node by node and rebind from a sibling variant that is already in the target state.\n- **Slot content that is already on another variant when it enters the slot can come back with ids that resolve to nothing.** If a nested instance in the content is already on a different variant than the component defines for it when the content lands in the slot \u2014 an explicit mode swapped an icon to another family, or an override set a menu item to `state=hovered` \u2014 its descendants get ids missing the slot's segment: `getNodeByIdAsync` answers `null` for them, and two items can report the same id. Switching the variant after the content is in the slot keeps the ids whole. The repair measured on a 311-node menu: clear the mode in one call, set the swapped item to its default variant in place in the next and back in the one after, then set the mode \u2014 14 unreadable nodes went to 0; clearing and re-setting the mode alone fixed 13 of them, not a hidden layer. Walk slot content with a per-node try/catch that records the path instead of stopping.\n- **When a variable mode swaps a nested instance to another variant, the swap is stored as overrides, and later binding changes on the main stop reaching it.** Eighteen instances were created in one call and their parents' Theme modes set in the next: in the mode that resolves the nested icon to the main's own variant, the nested instance carried no overrides; in each of the five modes that swapped it, it carried `boundVariables`, `componentProperties`, `fills`, `name`, both axis sizing modes and `targetAspectRatio` as overrides. Rebinding that nested instance's `style` property to a new variable in the main then reached only the unswapped instance \u2014 the other five kept reading the old variable \u2014 while fresh instances with the modes set again all showed the new binding. Instances that were already in the file carried the same overrides. After changing a binding in a main, re-create probe instances rather than re-reading old ones, and read `overrides` on the nested layer before trusting that the change reached instances under other modes.\n- **A VECTOR whose corners are rounded per vertex ignores `cornerRadius` writes on an instance, and only `rescale` scales those corners.** When the radius lives in `vectorNetwork.vertices[i].cornerRadius` (a glyph outline with 12 and 9 rounded vertices), writing the node's `cornerRadius` inside an instance adds a `cornerRadius` entry to `overrides` and nothing else: it reads back the main's 0.449, the vertices keep it, and the render is pixel-identical. Writing the main's value back leaves the entry behind; only `removeOverrides()` clears it, along with every other override on that instance. `resize()` scales the outline but not the vertex radii, so an instance resized 2.4\xD7 draws sharper corners than the main scaled 2.4\xD7 (alpha differs by up to 175/255 at the corners). `rescale(2.4)` scales the vertex radii too (0.449 \u2192 1.079, within 16/255 of the scaled main), but it does not coexist with a variable-bound height: binding `height` after `rescale` resets `scaleFactor` to 1 and the radii to 0.449 (with the aspect ratio locked as well), and `rescale` after binding multiplies the bound height (72 \u2192 259.2). Pick either the bound height or proportional corners.\n- **A bound paint's `color` is only a fallback \u2014 and one component set kept the fallback on screen.** `figma.variables.setBoundVariableForPaint(paint, 'color', v)` returns the paint with your `color` untouched (a `{0,0,0}` placeholder stays `{0,0,0}`). Assigned through `fills` or `setRangeFills`, the stored color is normally replaced by the variable's resolved value: in eight probes (node fills on text and on a rectangle; `setRangeFills` over a whole text, part of one, a text in a set variant, in a standalone component and on a TEXT-property layer) it read back as the resolved gray, and the exported ones rendered it. But four ranges written with `setRangeFills` in two variants of a working component set kept `{0,0,0}` while still bound, and rendered black; a scan of the 168 bound paints around them found no other mismatch, and the trigger was not isolated. Build bound paints with the resolved color (take `{ r, g, b }` from `v.resolveForConsumer(node).value` \u2014 it carries `a`, and a paint's `color` has no alpha) instead of a dummy, and after writing compare each bound paint's stored `color` with the resolved value. The same showed up on an instance whose fill override was bound to the very variable its main uses but stored `{0,0,0}`: it exported black where the main exported light.\n- **A blank symbol in a text node is not proof the font lacks it \u2014 the node can be holding a stale layout.** Key labels drew `\u2318` as empty space on some nodes and fine on others, all in the same monospace font, which has no `\u2318`, `\u21E7` or `\u23CE`: Figma draws those with its own fallback glyph, and the blank nodes held an old layout \u2014 `width` 7, the missing-glyph advance, where drawn ones measured 9 for `\u2318`/`\u23CE` and 8 for `\u21E7`. Switching the font does not help: a font whose file does contain `\u2318` and `\u23CE` still drew the same fallback glyph, and writing that font on the key text inside the masters' nested instances spread wrongly \u2014 145 of the file's 301 keys came out wrong, including keys with no symbol at all. What brought the glyph back was a fresh layout, reached the long way: on the five keys that had been given that font, `removeOverrides()` (which also returned the TEXT property to the main's default), then restoring the layer name and writing the property back to its symbol \u2014 the glyph appeared and the five keys grew 1\u20132 px. Writing the same value again without clearing first was not tried. Before blaming the font, compare the node's `width` with a node that renders; to repair, clear the overrides, write the text property back, and re-read the width.\n- **`remove()` on a COMPONENT leaves its id resolving, so a `null` lookup cannot confirm the cleanup.** After removing a component \u2014 standalone, or a variant removed along with its set \u2014 `getNodeByIdAsync(id)` still returns it, children included, with `parent === null` and `removed === false`; calling `remove()` on it again returns normally and changes nothing. Measured on four components removed in the call that created them; the COMPONENT_SET itself, and plain frames, text and rectangles removed the same way, did resolve to `null`. The component sits on no page and no page's children list it, but id sweeps find it; whether it goes away later has not been measured. Confirm component cleanup by ancestry (the chain must no longer reach a PAGE) rather than `null`, and build temporary probes from frames or instances of existing components rather than new components.\n- **Renaming a variant axis with `editComponentProperty` unbinds the instances, and can reset their plain overrides too.** Instances bound to that property keep their value but lose the `boundVariables.componentProperties` alias, nested explicit overrides included. In one measured rename, 71 instances whose value for the axis was a plain override \u2014 not bound to anything \u2014 fell back to the set's default variant. Right after rebinding, some instances kept an old value that disagreed with what the mode resolves to; writing the value first and then rebinding fixed them. Before renaming an axis, record every instance's value and binding for it; afterwards write the values back, rebind, and read both back.\n- **`figma.skipInvisibleInstanceChildren` is plugin-wide, not per script.** Setting it to `true` \u2014 a common speed-up for `findAll` \u2014 hides invisible sublayers of instances from every search that runs while it is set, not only the script that set it. Relai puts the previous value back after each `execute_figma` script, so it does not leak into later commands; set it again in every script that wants it, and while it is on, do not read an instance sublayer missing from a search as deleted.\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";
4482
4953
 
4483
4954
  // ../../docs/skills/design-tokens-strategy.md
4484
4955
  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";
@@ -4592,16 +5063,24 @@ function registerPrompts(server) {
4592
5063
  }
4593
5064
 
4594
5065
  export {
4595
- VERSION,
4596
- createServer,
4597
5066
  FIGMA_COMMANDS,
5067
+ isErrorCode,
5068
+ RelaiError,
5069
+ PROTOCOL_VERSION,
5070
+ inferLegacyCode,
5071
+ isAllowedOrigin,
4598
5072
  RelayCore,
4599
5073
  PITFALLS,
5074
+ currentCommandSlot,
5075
+ noteSettled,
5076
+ currentCallTarget,
5077
+ VERSION,
4600
5078
  resolveFileIdentity,
4601
5079
  registerRoomTool,
4602
5080
  registerEventsTool,
4603
5081
  registerAllTools,
4604
5082
  listToolCatalog,
5083
+ createServer,
4605
5084
  registerPrompts
4606
5085
  };
4607
- //# sourceMappingURL=chunk-5IKROMKE.js.map
5086
+ //# sourceMappingURL=chunk-62ZA4AUN.js.map