figma-relai 0.6.5 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-VZQTWVQC.js → chunk-DPDMRY72.js} +198 -19
- package/dist/chunk-DPDMRY72.js.map +1 -0
- package/dist/{chunk-6ABTHOFJ.js → chunk-VS2FNFYC.js} +30 -2
- package/dist/chunk-VS2FNFYC.js.map +1 -0
- package/dist/{doctor-INFHE5OE.js → doctor-MZDRG46R.js} +2 -2
- package/dist/index.js +40 -11
- package/dist/index.js.map +1 -1
- package/dist/{login-3TGCNCQB.js → login-2B44IDGS.js} +2 -2
- package/dist/{manifest-DIKV52AN.js → manifest-VBIEN5R4.js} +3 -3
- package/package.json +1 -1
- package/dist/chunk-6ABTHOFJ.js.map +0 -1
- package/dist/chunk-VZQTWVQC.js.map +0 -1
- /package/dist/{doctor-INFHE5OE.js.map → doctor-MZDRG46R.js.map} +0 -0
- /package/dist/{login-3TGCNCQB.js.map → login-2B44IDGS.js.map} +0 -0
- /package/dist/{manifest-DIKV52AN.js.map → manifest-VBIEN5R4.js.map} +0 -0
|
@@ -3,8 +3,9 @@ import {
|
|
|
3
3
|
loadUserSkills
|
|
4
4
|
} from "./chunk-SDINVROO.js";
|
|
5
5
|
import {
|
|
6
|
-
loadToken
|
|
7
|
-
|
|
6
|
+
loadToken,
|
|
7
|
+
noteAuthFailure
|
|
8
|
+
} from "./chunk-VS2FNFYC.js";
|
|
8
9
|
import {
|
|
9
10
|
__export
|
|
10
11
|
} from "./chunk-2H7UOFLK.js";
|
|
@@ -15,7 +16,7 @@ function createServer() {
|
|
|
15
16
|
return new McpServer(
|
|
16
17
|
{
|
|
17
18
|
name: "Relai",
|
|
18
|
-
version: "0.
|
|
19
|
+
version: "0.7.1"
|
|
19
20
|
},
|
|
20
21
|
{
|
|
21
22
|
instructions: `
|
|
@@ -194,6 +195,10 @@ var FIGMA_COMMANDS = [
|
|
|
194
195
|
"reset_instance",
|
|
195
196
|
"audit_colors",
|
|
196
197
|
"find_orphan_instances",
|
|
198
|
+
"audit_component_properties",
|
|
199
|
+
"get_file_identity",
|
|
200
|
+
"get_comment_anchors",
|
|
201
|
+
"set_file_identity",
|
|
197
202
|
// Variables
|
|
198
203
|
"get_variable_collections",
|
|
199
204
|
"get_variables",
|
|
@@ -297,8 +302,8 @@ var PITFALLS = [
|
|
|
297
302
|
},
|
|
298
303
|
{
|
|
299
304
|
pattern: "explicitly loaded",
|
|
300
|
-
hint: "Non-current pages must be loaded before traversal: await page.loadAsync() before reading page.children or calling findAll.",
|
|
301
|
-
doc: "**Traversing a non-current page throws until you `await page.loadAsync()`** \u2014 `figma.currentPage` is always loaded, other pages are not."
|
|
305
|
+
hint: "Non-current pages must be loaded before traversal: await page.loadAsync() before reading page.children or calling findAll. A getNodeByIdAsync that succeeded is NOT proof its page is loaded \u2014 resolve the node's page and load that, rather than relying on whatever ran before you.",
|
|
306
|
+
doc: "**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."
|
|
302
307
|
},
|
|
303
308
|
{
|
|
304
309
|
pattern: "layoutSizing",
|
|
@@ -539,13 +544,28 @@ var PITFALLS = [
|
|
|
539
544
|
{
|
|
540
545
|
pattern: null,
|
|
541
546
|
hint: "",
|
|
542
|
-
doc: '**
|
|
547
|
+
doc: '**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 (`.Icon Content` 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.'
|
|
543
548
|
},
|
|
544
549
|
{
|
|
545
550
|
pattern: null,
|
|
546
551
|
hint: "",
|
|
547
552
|
doc: '**Component property keys are `name#id`, and the name may itself contain `#`** \u2014 `.Icon Content` ships `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.'
|
|
548
553
|
},
|
|
554
|
+
{
|
|
555
|
+
pattern: null,
|
|
556
|
+
hint: "",
|
|
557
|
+
doc: "**A plugin-created instance placed under an explicit variable mode stays stuck on the placeholder, 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` followed by `setExplicitVariableModeForCollection`; the binding was never destroyed, only unresolved. (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-and-re-set before reading, and confirm against a node that was already in the file."
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
pattern: null,
|
|
561
|
+
hint: "",
|
|
562
|
+
doc: "**`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 Content` 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."
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
pattern: null,
|
|
566
|
+
hint: "",
|
|
567
|
+
doc: "**`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."
|
|
568
|
+
},
|
|
549
569
|
{
|
|
550
570
|
pattern: null,
|
|
551
571
|
hint: "",
|
|
@@ -827,6 +847,82 @@ var RelayCore = class {
|
|
|
827
847
|
}
|
|
828
848
|
};
|
|
829
849
|
|
|
850
|
+
// src/tools/core/file-identity.ts
|
|
851
|
+
function addressableKey(identity) {
|
|
852
|
+
return identity.branchKey ?? identity.fileKey;
|
|
853
|
+
}
|
|
854
|
+
function describeIdentity(identity) {
|
|
855
|
+
return identity.branchName ? `${identity.fileName} (branch "${identity.branchName}")` : identity.fileName;
|
|
856
|
+
}
|
|
857
|
+
async function figmaApi(token, path) {
|
|
858
|
+
const res = await fetch(`https://api.figma.com/v1${path}`, {
|
|
859
|
+
headers: { "X-Figma-Token": token }
|
|
860
|
+
});
|
|
861
|
+
noteAuthFailure(res.status);
|
|
862
|
+
const json = await res.json().catch(() => ({}));
|
|
863
|
+
return { ok: res.ok, status: res.status, json };
|
|
864
|
+
}
|
|
865
|
+
var FileIdentityError = class extends Error {
|
|
866
|
+
};
|
|
867
|
+
async function resolveFileIdentity(sendCommand) {
|
|
868
|
+
const probe = await sendCommand("get_file_identity", {});
|
|
869
|
+
if (probe?.identity?.fileKey) return probe.identity;
|
|
870
|
+
const rootName = probe?.rootName ?? "";
|
|
871
|
+
const candidates = probe?.candidates ?? [];
|
|
872
|
+
if (candidates.length === 0) {
|
|
873
|
+
throw new FileIdentityError(
|
|
874
|
+
"This file publishes no components, so its key cannot be resolved from the plugin (figma.fileKey is private-plugin-only)."
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
const token = loadToken();
|
|
878
|
+
if (!token) {
|
|
879
|
+
throw new FileIdentityError(
|
|
880
|
+
"Resolving the file key needs a Figma token \u2014 store one with `npx -y figma-relai@latest login`."
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
let main = null;
|
|
884
|
+
const statuses = [];
|
|
885
|
+
for (const c of candidates) {
|
|
886
|
+
const paths = c.kind === "set" ? ["/component_sets/", "/components/"] : ["/components/", "/component_sets/"];
|
|
887
|
+
for (const p of paths) {
|
|
888
|
+
const res = await figmaApi(token, `${p}${c.key}`);
|
|
889
|
+
statuses.push(res.status);
|
|
890
|
+
const meta = res.json.meta ?? {};
|
|
891
|
+
if (res.ok && typeof meta.file_key === "string") {
|
|
892
|
+
main = {
|
|
893
|
+
fileKey: meta.file_key,
|
|
894
|
+
fileName: meta.file_name ?? rootName,
|
|
895
|
+
via: c.key
|
|
896
|
+
};
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (main) break;
|
|
901
|
+
}
|
|
902
|
+
if (!main) {
|
|
903
|
+
const denied = statuses.length > 0 && statuses.every((s) => s === 401 || s === 403);
|
|
904
|
+
throw new FileIdentityError(
|
|
905
|
+
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."
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
const identity = { fileKey: main.fileKey, fileName: main.fileName };
|
|
909
|
+
if (rootName && rootName !== main.fileName) {
|
|
910
|
+
const res = await figmaApi(token, `/files/${main.fileKey}?branch_data=true&depth=1`);
|
|
911
|
+
const branches = res.json.branches ?? [];
|
|
912
|
+
const hit = branches.find((b) => b.name === rootName);
|
|
913
|
+
if (hit?.key) {
|
|
914
|
+
identity.branchKey = hit.key;
|
|
915
|
+
identity.branchName = hit.name;
|
|
916
|
+
}
|
|
917
|
+
if (typeof res.json.name === "string") identity.fileName = res.json.name;
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
await sendCommand("set_file_identity", { identity, componentKey: main.via });
|
|
921
|
+
} catch {
|
|
922
|
+
}
|
|
923
|
+
return identity;
|
|
924
|
+
}
|
|
925
|
+
|
|
830
926
|
// src/tools/index.ts
|
|
831
927
|
import { z as z23 } from "zod";
|
|
832
928
|
|
|
@@ -1945,6 +2041,8 @@ function parseFileKey(input) {
|
|
|
1945
2041
|
return null;
|
|
1946
2042
|
}
|
|
1947
2043
|
function compactComment(c) {
|
|
2044
|
+
const meta = c.client_meta;
|
|
2045
|
+
const isRegion = typeof meta?.region_width === "number" && typeof meta?.region_height === "number";
|
|
1948
2046
|
return {
|
|
1949
2047
|
id: c.id,
|
|
1950
2048
|
message: c.message,
|
|
@@ -1952,13 +2050,41 @@ function compactComment(c) {
|
|
|
1952
2050
|
created_at: c.created_at,
|
|
1953
2051
|
...c.resolved_at ? { resolved: true } : {},
|
|
1954
2052
|
...c.parent_id ? { replyTo: c.parent_id } : {},
|
|
1955
|
-
...
|
|
2053
|
+
...meta?.node_id ? { nodeId: meta.node_id } : {},
|
|
2054
|
+
...isRegion ? { region: { width: meta.region_width, height: meta.region_height } } : {},
|
|
2055
|
+
...meta ? { pin: meta } : {}
|
|
1956
2056
|
};
|
|
1957
2057
|
}
|
|
2058
|
+
async function anchorComments(sendCommand, comments) {
|
|
2059
|
+
const ids = [...new Set(comments.map((c) => c.nodeId).filter((v) => !!v))];
|
|
2060
|
+
if (ids.length === 0) return;
|
|
2061
|
+
let info;
|
|
2062
|
+
try {
|
|
2063
|
+
info = await sendCommand("get_comment_anchors", { nodeIds: ids.slice(0, 100) });
|
|
2064
|
+
} catch {
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
for (const c of comments) {
|
|
2068
|
+
const hit = c.nodeId ? info?.[c.nodeId] : void 0;
|
|
2069
|
+
if (!hit) continue;
|
|
2070
|
+
const offset = c.pin?.node_offset;
|
|
2071
|
+
Object.assign(c, {
|
|
2072
|
+
anchor: {
|
|
2073
|
+
// A pin whose node IS a page is a pin on that page's empty canvas
|
|
2074
|
+
on: hit.type === "PAGE" ? "canvas" : "node",
|
|
2075
|
+
name: hit.name,
|
|
2076
|
+
page: hit.page,
|
|
2077
|
+
// A pin on a node is offset from that node; a pin on empty canvas is
|
|
2078
|
+
// offset from the page origin, which is the canvas coordinate itself.
|
|
2079
|
+
...offset ? hit.type === "PAGE" ? { canvas: { x: Math.round(offset.x), y: Math.round(offset.y) } } : hit.x !== void 0 ? { canvas: { x: Math.round(hit.x + offset.x), y: Math.round((hit.y ?? 0) + offset.y) } } : {} : {}
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
1958
2084
|
function register15(server, sendCommand) {
|
|
1959
2085
|
server.tool(
|
|
1960
2086
|
"manage_comments",
|
|
1961
|
-
"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
|
|
2087
|
+
"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.",
|
|
1962
2088
|
{
|
|
1963
2089
|
action: z16.enum(["list", "add", "reply", "delete"]),
|
|
1964
2090
|
fileUrl: z16.string().optional().describe("Figma file URL or key (auto-detected when omitted)"),
|
|
@@ -1982,13 +2108,20 @@ function register15(server, sendCommand) {
|
|
|
1982
2108
|
if (fileUrl && !fileKey) {
|
|
1983
2109
|
return textResult(`Could not extract a file key from "${fileUrl}" \u2014 pass a figma.com/design/... URL.`);
|
|
1984
2110
|
}
|
|
2111
|
+
let identity = null;
|
|
1985
2112
|
if (!fileKey) {
|
|
1986
|
-
|
|
1987
|
-
|
|
2113
|
+
try {
|
|
2114
|
+
identity = await resolveFileIdentity(sendCommand);
|
|
2115
|
+
fileKey = addressableKey(identity);
|
|
2116
|
+
} catch (err) {
|
|
2117
|
+
return textResult(
|
|
2118
|
+
`${err.message} Pass fileUrl with the figma.com URL of the file \u2014 or of the branch, since comments live per branch.`
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
1988
2121
|
}
|
|
1989
2122
|
if (!fileKey) {
|
|
1990
2123
|
return textResult(
|
|
1991
|
-
"
|
|
2124
|
+
"Could not determine which file to read. Pass fileUrl with the file's figma.com URL (copy it from the browser or Share dialog)."
|
|
1992
2125
|
);
|
|
1993
2126
|
}
|
|
1994
2127
|
const request = async (method, path, body) => {
|
|
@@ -2000,11 +2133,12 @@ function register15(server, sendCommand) {
|
|
|
2000
2133
|
},
|
|
2001
2134
|
...body ? { body: JSON.stringify(body) } : {}
|
|
2002
2135
|
});
|
|
2136
|
+
noteAuthFailure(res.status);
|
|
2003
2137
|
const json = await res.json().catch(() => ({}));
|
|
2004
2138
|
if (!res.ok) {
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
);
|
|
2139
|
+
const reason = json.err ?? json.message ?? "request failed";
|
|
2140
|
+
const advice = /expired/i.test(reason) ? " \u2014 re-run `npx -y figma-relai@latest login`" : res.status === 403 ? " \u2014 check the token's comment scopes and file access" : "";
|
|
2141
|
+
throw new Error(`Figma API ${res.status}: ${reason}${advice}`);
|
|
2008
2142
|
}
|
|
2009
2143
|
return json;
|
|
2010
2144
|
};
|
|
@@ -2021,7 +2155,13 @@ function register15(server, sendCommand) {
|
|
|
2021
2155
|
raw = raw.filter((c) => c.created_at && Date.parse(c.created_at) > cutoff);
|
|
2022
2156
|
}
|
|
2023
2157
|
const comments = raw.slice(0, 100).map(compactComment);
|
|
2024
|
-
|
|
2158
|
+
await anchorComments(sendCommand, comments);
|
|
2159
|
+
return jsonResult({
|
|
2160
|
+
count: comments.length,
|
|
2161
|
+
...identity ? { file: describeIdentity(identity) } : {},
|
|
2162
|
+
comments,
|
|
2163
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2164
|
+
});
|
|
2025
2165
|
}
|
|
2026
2166
|
case "add": {
|
|
2027
2167
|
if (!message) return textResult("add requires message.");
|
|
@@ -2165,10 +2305,15 @@ function apiFor(token) {
|
|
|
2165
2305
|
const res = await fetch(`https://api.figma.com/v1${path}`, {
|
|
2166
2306
|
headers: { "X-Figma-Token": token }
|
|
2167
2307
|
});
|
|
2308
|
+
noteAuthFailure(res.status);
|
|
2168
2309
|
const json = await res.json().catch(() => ({}));
|
|
2169
2310
|
return { ok: res.ok, status: res.status, json };
|
|
2170
2311
|
};
|
|
2171
2312
|
}
|
|
2313
|
+
function authFailure(statuses) {
|
|
2314
|
+
if (statuses.length === 0 || !statuses.every((s) => s === 401 || s === 403)) return null;
|
|
2315
|
+
return "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`, then retry.";
|
|
2316
|
+
}
|
|
2172
2317
|
var metaOf = (r) => r.json.meta ?? {};
|
|
2173
2318
|
async function discoverLibraryFiles(api, scan) {
|
|
2174
2319
|
const comps = scan.components?.remoteUsed?.items ?? [];
|
|
@@ -2178,9 +2323,11 @@ async function discoverLibraryFiles(api, scan) {
|
|
|
2178
2323
|
...styles.map((s) => ({ ...s, paths: ["/styles/"] }))
|
|
2179
2324
|
].filter((c) => c.key && c.name && !c.name.startsWith(".")).slice(0, PROBE_LIMIT);
|
|
2180
2325
|
const found = /* @__PURE__ */ new Map();
|
|
2326
|
+
const statuses = [];
|
|
2181
2327
|
for (const c of candidates) {
|
|
2182
2328
|
for (const p of c.paths) {
|
|
2183
2329
|
const res = await api(`${p}${c.key}`);
|
|
2330
|
+
statuses.push(res.status);
|
|
2184
2331
|
const fileKey = metaOf(res).file_key;
|
|
2185
2332
|
if (res.ok && fileKey) {
|
|
2186
2333
|
if (!found.has(fileKey)) found.set(fileKey, c.name);
|
|
@@ -2188,6 +2335,10 @@ async function discoverLibraryFiles(api, scan) {
|
|
|
2188
2335
|
}
|
|
2189
2336
|
}
|
|
2190
2337
|
}
|
|
2338
|
+
if (found.size === 0) {
|
|
2339
|
+
const denied = authFailure(statuses);
|
|
2340
|
+
if (denied) throw new Error(denied);
|
|
2341
|
+
}
|
|
2191
2342
|
return [...found].map(([fileKey, via]) => ({ fileKey, via }));
|
|
2192
2343
|
}
|
|
2193
2344
|
async function catalogOne(api, fileKey) {
|
|
@@ -2241,10 +2392,14 @@ async function fetchLibraryCatalogs(libraryFileUrl, scan) {
|
|
|
2241
2392
|
if (!fileKey) return { note: `Could not extract a file key from "${libraryFileUrl}".` };
|
|
2242
2393
|
targets = [{ fileKey, via: "libraryFileUrl" }];
|
|
2243
2394
|
} else {
|
|
2244
|
-
|
|
2395
|
+
try {
|
|
2396
|
+
targets = await discoverLibraryFiles(api, scan);
|
|
2397
|
+
} catch (err) {
|
|
2398
|
+
return { note: err.message };
|
|
2399
|
+
}
|
|
2245
2400
|
if (!targets.length) {
|
|
2246
2401
|
return {
|
|
2247
|
-
note: "No library file could be resolved from the keys this file holds \u2014 every candidate 404'd. Pass libraryFileUrl explicitly."
|
|
2402
|
+
note: "No library file could be resolved from the keys this file holds \u2014 every candidate 404'd (unpublished, or from a library this token cannot read). Pass libraryFileUrl explicitly."
|
|
2248
2403
|
};
|
|
2249
2404
|
}
|
|
2250
2405
|
}
|
|
@@ -3879,6 +4034,29 @@ function register21(server, sendCommand) {
|
|
|
3879
4034
|
}
|
|
3880
4035
|
} catch {
|
|
3881
4036
|
}
|
|
4037
|
+
try {
|
|
4038
|
+
const wiring = await sendCommand(
|
|
4039
|
+
"audit_component_properties",
|
|
4040
|
+
{ nodeId: targetId },
|
|
4041
|
+
6e4
|
|
4042
|
+
);
|
|
4043
|
+
if (wiring && typeof wiring.scanned === "number" && wiring.scanned > 0) {
|
|
4044
|
+
const findings = wiring.findings ?? [];
|
|
4045
|
+
results.push({
|
|
4046
|
+
rule: "property_wiring",
|
|
4047
|
+
passed: findings.length === 0,
|
|
4048
|
+
severity: "warning",
|
|
4049
|
+
message: findings.length ? `${findings.length} component-property fault(s) across ${wiring.scanned} set(s): ${findings.slice(0, 2).join(" | ")}${findings.length > 2 ? " \u2026" : ""}` : `Every declared property is referenced by a layer (${wiring.scanned} set(s) checked)`,
|
|
4050
|
+
nodeId: targetId,
|
|
4051
|
+
fix: findings.length ? {
|
|
4052
|
+
tool: "manage_components",
|
|
4053
|
+
reason: "Wire the property to a layer, or delete it",
|
|
4054
|
+
args: { action: "props" }
|
|
4055
|
+
} : void 0
|
|
4056
|
+
});
|
|
4057
|
+
}
|
|
4058
|
+
} catch {
|
|
4059
|
+
}
|
|
3882
4060
|
try {
|
|
3883
4061
|
const tok = await sendCommand("audit_node_tokens", { nodeId: targetId }, 6e4);
|
|
3884
4062
|
if (tok && typeof tok.tokensBound === "number") {
|
|
@@ -4143,7 +4321,7 @@ function listToolCatalog() {
|
|
|
4143
4321
|
}
|
|
4144
4322
|
|
|
4145
4323
|
// ../../docs/skills/figma-plugin-api.md
|
|
4146
|
-
var figma_plugin_api_default = '# Figma Plugin API Cheat Sheet \u2014 for `execute_figma`\n\nRules and patterns for writing code that runs in the plugin sandbox. Work in small incremental scripts and `screenshot` between steps.\n\n## Non-negotiable rules (dynamic-page mode)\n\n- **Node lookup is async**: `await figma.getNodeByIdAsync(id)` \u2014 `getNodeById` throws in this plugin.\n- **Load pages before traversal**: `await page.loadAsync()` before `page.findAll(...)` on a non-current page; `figma.currentPage` is always loaded.\n- **Load fonts before ANY text edit**: `await figma.loadFontAsync(textNode.fontName)` \u2014 if `fontName === figma.mixed`, load every range: `for (const f of textNode.getRangeAllFontNames(0, textNode.characters.length)) await figma.loadFontAsync(f)`.\n- **`figma.mixed` is a Symbol**: properties like `fontSize`, `cornerRadius`, `fills` return it when values differ across ranges/children. Check `=== figma.mixed` before using; never JSON-serialize it.\n\n## Common patterns\n\n```js\n// Selection\nconst sel = figma.currentPage.selection; // read\nfigma.currentPage.selection = [node]; // write\n\n// Create + place\nconst frame = figma.createFrame();\nframe.resize(320, 200); // width/height are read-only; use resize()\nparent.appendChild(frame); // default parent is currentPage\n\n// Fills/strokes are ARRAYS and must be reassigned wholesale\nnode.fills = [{ type: "SOLID", color: { r: 1, g: 0.5, b: 0 } }]; // rgb 0-1, no alpha key \u2014 use opacity\nconst fills = JSON.parse(JSON.stringify(node.fills)); // clone before mutating\nfills[0].color.r = 0; node.fills = fills;\n\n// Auto-layout: set layoutMode FIRST, then padding/spacing/align\nframe.layoutMode = "VERTICAL";\nframe.itemSpacing = 8; frame.paddingTop = 16;\nchild.layoutSizingHorizontal = "FILL"; // requires PARENT with auto-layout\n\n// Components\nconst comp = await figma.importComponentByKeyAsync(key); // team library\nconst inst = comp.createInstance();\ninst.setProperties({ Variant: "Primary" }); // throws on unknown property names\n\n// Variables\nconst collections = await figma.variables.getLocalVariableCollectionsAsync();\nconst v = figma.variables.createVariable("name", collection, "COLOR");\nv.setValueForMode(modeId, { r: 0, g: 0, b: 0 });\nnode.setBoundVariable("fills", v); // binding paint needs figma.variables.setBoundVariableForPaint on the paint object for fills in older APIs \u2014 prefer setBoundVariable where available\n\n// Export (returns Uint8Array)\nconst bytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 2 } });\n```\n\n## `relai.*` helpers \u2014 the shortest path is the correct one\n\nAlongside `figma`, scripts get a `relai` object whose helpers are immune to the pitfalls below. Prefer them:\n\n```js\nawait relai.text(parent, "Hello", { font: {family:"IBM Plex Mono", style:"Regular"}, size: 14, color: {r:0,g:0,b:0} });\n // loads the font BEFORE writing characters\nconst card = relai.autoLayout("VERTICAL", { name: "Card", itemSpacing: 8 });\n // auto-layout frame, both axes hugging\nrelai.set(node, { layoutMode: "HORIZONTAL", width: 320, opacity: 0.9 });\n // layoutMode applied first; width/height via resize()\nrelai.hug(node); // HUG that sticks \u2014 call after appending children\nrelai.focusRing(button); // clipsContent + double spread shadows that render\nconst page = await relai.page(p => p.children.some(c => c.name === "Button"));\n // find pages by CONTENT \u2014 names get renamed\nconst titles = relai.query(\'FRAME[name^=Card] > TEXT\');\n // CSS-like search on the current page; relai.query(node, sel) scopes to a subtree.\n // Supported: TYPE, *, [name=] [name*=] [name^=] [name$=], descendant, >, comma.\n // NOT supported: pseudo-classes, dot-paths, sibling combinators \u2014 use findAll.\nrelai.placeholder(section); // construction veil so the designer sees work-in-progress\nrelai.placeholder(section, false); // ALWAYS remove when the section is done\n```\n\nTwo important facts: **scripts are NOT atomic** \u2014 on error, changes made before the throw persist, so keep scripts small and clean up after failures; and results may carry a `warnings` array for silent mistakes the lint catches on relai-created nodes and any node ids you return (e.g. spread shadows on a non-clipping frame). Convention: **return every created/mutated node id** (`return { createdNodeIds: [...] }`) \u2014 it powers both follow-up calls and the lint.\n\n## Pitfalls that throw (or silently do the wrong thing)\n\nWhen one of these throws inside `execute_figma`, the error already carries the remedy as a `Hint:` \u2014 this list is generated from the same registry (`packages/shared/src/pitfalls.ts`).\n\n<!-- PITFALLS:START -->\n- **`not a function` errors may name no symbol** \u2014 Figma\'s VM drops the subject. Usual cause: APIs from other agent tools that don\'t exist here (`node.query()`, `figma.notify()`). Use `findAll`/`findAllWithCriteria` for search; bisect when the message names nothing.\n- **Editing text without loading its font throws** (`unloaded font`). `await figma.loadFontAsync(textNode.fontName)` first; new TextNodes default to Inter Regular; when `fontName === figma.mixed`, load every font from `getRangeAllFontNames()`.\n- **`getNodeById` throws in dynamic-page mode** \u2014 always `await figma.getNodeByIdAsync(id)`.\n- **Traversing a non-current page throws until you `await page.loadAsync()`** \u2014 `figma.currentPage` is always loaded, other pages are not.\n- **`layoutSizingHorizontal/Vertical` throw without auto-layout** \u2014 FILL requires the parent to have a `layoutMode`, HUG requires it on the node itself.\n- **`createPage()` throws on the free plan once a file has 3 pages** \u2014 reuse an existing page instead.\n- **`width`/`height` are read-only** \u2014 use `node.resize(w, h)`.\n- **`figma.mixed` is a Symbol** returned by `fontSize`, `cornerRadius`, `fills` etc. when values differ across ranges/children \u2014 check `=== figma.mixed` before use; never JSON-serialize it.\n- **Exact-name lookups are fragile** \u2014 designers rename pages and layers freely; locate nodes by type/content instead of `name ===`.\n- **Stale node ids throw `does not exist`** \u2014 nodes get deleted while you work; re-read before editing and check `node.removed`.\n- **Nodes are non-extensible** \u2014 `node.myCustomProp = x` throws `object is not extensible`; use `setPluginData` or return the data instead.\n- **Shadow `spread` renders only on shapes or frames with `clipsContent: true`** \u2014 a focus ring built from spread shadows is invisible on a non-clipping frame/component.\n- **`resize()` on an auto-layout frame silently pins that axis to FIXED**, overriding `primaryAxisSizingMode: "AUTO"`. Append children first, then set `layoutSizingHorizontal = "HUG"`; use `resize` only for the fixed cross-axis.\n- **Per-corner radius** (`topLeftRadius` \u2026) only exists on RectangleCornerMixin nodes (rectangles, frames, components) \u2014 polygons, stars and lines throw.\n- **Instance children can\'t be added or removed** \u2014 detach first, or edit the main component.\n- **Figma\'s slot feature (Convert to slot, \u21E7\u2318S) has no Plugin API** \u2014 no createSlot/convertToSlot exists (verified against typings 1.123 and at runtime, 2026-07). Scaffold a frame named "Slot", select it for the designer (navigate select), and let them press the shortcut.\n- **A converted SLOT node stops hugging** \u2014 after the designer converts a frame to a slot it carries its own fixed size. Re-assert `layoutSizingVertical = "HUG"` (and the intended horizontal mode) on the SLOT node afterwards; the API can still edit its layout props.\n- **Variantizing can silently pin the hug axis to FIXED** \u2014 observed after clone \u2192 counterAxisSizingMode/width-variable changes \u2192 combineAsVariants: the variants\' `primaryAxisSizingMode` ended up FIXED though the source hugged. Always verify sizing modes on every variant after combineAsVariants and re-assert `"AUTO"`.\n- **A backgrounded Figma window suspends the plugin iframe** \u2014 timers freeze and the socket can\'t pong, so the relay\'s staleness sweep kicks the plugin and the frozen 2s redial never fires until the window regains attention. The panel redials on visibilitychange/focus (ui.html \u22650.2.4); when a paired plugin goes missing mid-session, ask the designer to click the Figma window before assuming anything is broken.\n- **A freshly-converted slot can still report `type: "FRAME"` to the Plugin API** \u2014 the layer panel shows the slot badge while getNodeByIdAsync returns FRAME (a reload boundary was observed to fix it). Never gate automation on `type === "SLOT"`; match the frame by name and trust the designer\'s visual confirmation.\n- **Deleted variables are soft-deleted ghosts** \u2014 `getVariableByIdAsync` still resolves them with name AND working values (`Variable` has no `removed` property), so bindings to them keep rendering while being invisible in pickers and dead on publish. To test aliveness, check membership in `getLocalVariablesAsync()` (remote variables are legitimately absent \u2014 check `variable.remote`).\n- **Variables cannot be reordered via the API** (`variableIds` is read-only; the `move\u2026After` methods exist only for styles) \u2014 new variables always append last; panel order is manual-drag only. NEVER delete-and-recreate variables to reorder: bindings reference variable IDs, so every binding in the file silently dies (observed in production; the ghosts keep rendering, hiding the damage).\n- **A remote (library) text style dominates variable bindings** \u2014 on text with a remote style applied, `setBoundVariable` writes appear to succeed but snap back to the style\'s own bindings. Clear the style first (`await setTextStyleIdAsync("")`), then bind.\n- **Binding writes inside instances silently revert \u2014 with two escape hatches.** `setBoundVariable` on a node nested inside an INSTANCE succeeds without error, then reverts (instance roots placed directly in a main ARE writable; anything deeper is not). What does persist: reassigning the whole `fills`/`strokes` array (variable-bound paints ride inside), and range-level `setRangeBoundVariable` on text. Verify after writing; never trust the call alone.\n- **A bound `strokeWeight` renders nothing while `strokes` is empty, and wrappers don\'t hug stroke thickness.** A LINE\'s visible weight lives entirely in its stroke paint; the frame around it keeps its own raw height/width, so the panel shows an unbound number with empty Fill/Stroke \u2014 looking broken when it isn\'t. Inspect the LINE child\'s stroke bindings, not the wrapper; and bind the wrapper\'s thickness-axis dimension to the same variable so nothing stays raw.\n- **`setBoundVariable("strokeWeight", \u2026)` is a silent no-op on FRAME-family nodes.** Frames and components bind stroke weight per side \u2014 `strokeTopWeight` / `strokeRightWeight` / `strokeBottomWeight` / `strokeLeftWeight`; the uniform `strokeWeight` field only binds on stroke-geometry nodes like LINE. No error is thrown, the binding just never appears \u2014 read back `boundVariables` after writing.\n- **Cloning a variant escapes its component set \u2014 onto whatever page the user is viewing.** `variant.clone()` parents the copy to `figma.currentPage` as a standalone COMPONENT (not into the set) \u2014 plain nodes inside components clone to the current page too. Every `componentPropertyReferences` pointing at set-level properties is silently severed, and native SLOT children are demoted to plain FRAMEs (slot-ness needs set context). Recover by `componentSet.appendChild(clone)`, re-assigning ALL references (`visible`, `slotContentId`, \u2026) \u2014 a SLOT can be restored by cloning a surviving SLOT node from a sibling variant (SLOT clones keep their type) and relinking `componentPropertyReferences.slotContentId`. Audit every set-scoped feature after adoption; none of it comes back on its own.\n- **Uneven variant renames/deletes poison the whole component set.** If variants end up with mismatched axis segments (some carry `axis=value`, others don\'t) or duplicate names, the set enters an error state where `componentPropertyDefinitions` AND every instance\'s `variantProperties`/`setProperties` throw (`Component set has existing errors`). Escape: rename stragglers so all variants share the same axes with unique values (temporary `legacy-*` values work), which heals the set \u2014 then migrate and delete.\n- **`setProperties` DOES work on instances nested inside other instances** \u2014 it records as an override on the outer instance. Only variable-binding writes (`setBoundVariable`) silently revert inside instance internals. Don\'t skip nested instances when migrating variant or boolean properties; skip them only for binding writes.\n- **`Plugin runtime aborted` is an out-of-memory, not a timeout.** The stack shows `deepWrap` recursing into `newString` before `abort`. Reading deep-wrapped properties \u2014 `componentProperties`, `overrides`, `boundVariables`, `exportSettings` \u2014 copies a fresh object into the QuickJS sandbox on EVERY access, so a loop over tens of thousands of instances kills the runtime even though each read looks free. Chunk the scan and keep only the fields you need. (Real case: `componentProperties` over 29,861 instances aborted; the same scan over 7,139 was fine.)\n- **What a file USES is not what its library HAS.** `get_design_system`\'s remote-used list only reports components the file already placed \u2014 a library component nobody has dragged in yet is invisible to the plugin, and no amount of scanning the document will reveal it. Read the published catalog before concluding a component doesn\'t exist (one real case: a design system\'s `TextArea` was declared missing and hand-rebuilt from a stretched `Text Field`, because the file had never used the real one).\n- **A component property\'s identity is the `#id` suffix, not its display name.** Renaming `show label#4525:247` to `hasLabel#4525:247` in the library keeps every instance\'s override \u2014 text and boolean values survive a library update untouched. VARIANT axes are the opposite: they have no id and are matched by name, so renaming an axis or its values (`layout=horizontal` \u2192 `labelPosition=side`) is the only thing that actually drops instances back to a default. When auditing a library migration, snapshot the variant axes and ignore the renamed text/boolean props.\n- **`importComponentSetByKeyAsync` returns the file\'s LOCAL copy, not the latest published version.** If the file holds a stale copy of a library component, the import hands back the stale one and looks authoritative. To see what is actually published, read `componentPropertyDefinitions` over REST (`/v1/files/{key}/nodes?ids={node_id}&depth=1`) and compare. Importing is also expensive \u2014 each call materialises the whole set (a 72-variant set is thousands of nodes), and a handful in one script will abort the sandbox.\n- **GRID auto-layout has read-only positions and imperative setters.** `gridRowAnchorIndex` / `gridColumnAnchorIndex` are readonly \u2014 move a child with `node.setGridChildPosition(row, column)` (spans stay writable: `gridColumnSpan`). `gridColumnSizes` / `gridRowSizes` reject array assignment (`no setter for property`), but `gridColumnCount` / `gridRowCount` are writable and each returned `GridTrackSize` has `.type` / `.value` setters \u2014 so add a track by raising the count, then set the new track\'s type. Shrinking the row count is how you drop a leftover empty row.\n- **`figma.root.findAll` throws in dynamic-page mode until you `await figma.loadAllPagesAsync()`.** Page-scoped work never needs it: `figma.currentPage` is already loaded and `relai.query` searches from a node you pass. Load the whole document only when the search genuinely spans pages \u2014 on a 40-page file it is seconds of work and memory.\n- **`instance.mainComponent` throws in dynamic-page mode** \u2014 `await instance.getMainComponentAsync()`. The same rule catches every sync sibling of an async API (`getNodeById`, `textStyleId =`, `page.children` on an unloaded page): when the message says *use X instead*, it means the whole traversal from that point is async.\n- **A variant has no component property definitions of its own** (`Can only get component property definitions of a component set or non-variant component`). They belong to the COMPONENT_SET \u2014 read `node.parent` when a variant is what you have, and add new TEXT/BOOLEAN properties to the set too: adding them to a single variant either fails or leaves the other variants without them.\n- **`SPACE_BETWEEN` centres a lone child.** A header built as label + value with the value hidden by default looks correct in the variant you were staring at and lands centred everywhere else \u2014 because Figma spreads a single item to the middle rather than leaving it at the start. When one side is optional, align the row `MIN` and let the first child FILL: the optional one stays pinned to the far edge when it appears, and the other stays put when it does not.\n- **Applying a text style changes the node\'s font, so a `characters` write right after it can throw for a font nobody loaded.** `await node.setTextStyleIdAsync(id)` may swap the family or weight; load the *new* font (`await figma.loadFontAsync(node.fontName)`) before writing text. Applying the style is also the last word on properties it owns \u2014 a `textDecoration` written in the same breath is overwritten by the style landing, so set it in a later call.\n- **Text edits inside a component SET propagate to every variant.** Applying a text style to one variant\'s label retypes the matching layer in all of them \u2014 same for `textDecoration` and text fills, at node level and via `setRange*` alike. Renaming the layer does not break the link (matching is positional). So a set whose sizes need DIFFERENT text styles cannot be styled in place: apply the styles while the components are still standalone and combine afterwards \u2014 an atom does not save you either unless the atom\'s variants are standalone components rather than a set of their own. Two more from the same afternoon: `combineAsVariants` drops `textDecoration`, and a decoration written in the same execution as `setTextStyleIdAsync` is clobbered by the style landing \u2014 set it in a later call.\n- **Writing `leadingTrim` on a text node detaches its text style** and strands the style\'s typography variables as node-level bindings \u2014 the node keeps rendering but no longer follows the style. `textDecoration` is safe (the style survives); `leadingTrim` is not. When a cap-trimmed label needs to sit in a full line box (so an icon beside it doesn\'t hang below the baseline, and toggling the icon doesn\'t change the height), leave the trim alone and bind the PARENT frame\'s height to the same leading variable the style uses.\n- **A variable bound to a component property\'s VALUE hides from `boundVariables`.** `node.boundVariables.componentProperties` lists the VARIANT properties and nothing else \u2014 a token bound to a TEXT or INSTANCE_SWAP property (an icon glyph name, a swapped instance) lives only in `instance.componentProperties[key].boundVariables.value`. Read one location and every such token looks unreferenced. Bindings authored as a property\'s *default* sit in a third place, `componentPropertyDefinitions[key].boundVariables`, on the SET.\n- **"Plugin runtime aborted" is out of memory, not a timeout**, and reading `instance.componentProperties` in bulk is the fastest way there: the getter deep-wraps every property object, so 1,000 instances costs about 4 seconds and 5,700 kills the sandbox. Memory is only reclaimed when the command returns, so the fix is slices \u2014 walk a fixed number of nodes per command and have the caller ask again \u2014 not a tidier traversal inside one script.\n- **`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- **Writing a plain value over a variable-bound component property does not unbind it.** `instance.setProperties({ key: "diamond" })` on a property whose value is a `VARIABLE_ALIAS` returns without error and leaves the alias in place \u2014 the next read still shows the token. There is no `unbind` for property values: call `instance.resetOverrides()` and re-apply everything you wanted to keep (the sizes, the styles, and any fills bound inside that instance, which the reset also clears).\n- **Component property keys are `name#id`, and the name may itself contain `#`** \u2014 `.Icon Content` ships `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- **Dot-prefixed components are never published**, so their keys 404 on every REST endpoint. Figma hides `.`-prefixed assets from the publish dialog; they exist only nested inside published parents. When resolving a key \u2192 file via `/v1/components/{key}`, skip names starting with `.` \u2014 and note a component SET\'s key resolves only at `/v1/component_sets/{key}`, never at `/v1/components/{key}` (and vice versa). Relatedly, `/v1/files/{key}/components` returns the individual VARIANTS (`size=md, state=default`); the components a designer names live in `/component_sets`.\n<!-- PITFALLS:END -->\n\nAlso: `resize(w, h)` throws on `w <= 0 || h <= 0`; `layoutWrap = "WRAP"` is HORIZONTAL-only, `counterAxisAlignItems = "BASELINE"` too; writing to `locked` nodes is allowed by the API but surprises designers \u2014 check first.\n\n## Return values\n\nReturn JSON-serializable data (the bridge summarizes nodes automatically and truncates >50k chars). Prefer returning `{ id, name, type }` summaries over whole nodes. `console.log` is captured and returned in `logs`.\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
|
|
4324
|
+
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. Two more from the same afternoon: `combineAsVariants` drops `textDecoration`, and a decoration written in the same execution as `setTextStyleIdAsync` is clobbered by the style landing \u2014 set it in a later call.\n- **Writing `leadingTrim` on a text node detaches its text style** and strands the style\'s typography variables as node-level bindings \u2014 the node keeps rendering but no longer follows the style. `textDecoration` is safe (the style survives); `leadingTrim` is not. When a cap-trimmed label needs to sit in a full line box (so an icon beside it doesn\'t hang below the baseline, and toggling the icon doesn\'t change the height), leave the trim alone and bind the PARENT frame\'s height to the same leading variable the style uses.\n- **A variable bound to a component property\'s VALUE hides from `boundVariables`.** `node.boundVariables.componentProperties` lists the VARIANT properties and nothing else \u2014 a token bound to a TEXT or INSTANCE_SWAP property (an icon glyph name, a swapped instance) lives only in `instance.componentProperties[key].boundVariables.value`. Read one location and every such token looks unreferenced. Bindings authored as a property\'s *default* sit in a third place, `componentPropertyDefinitions[key].boundVariables`, on the SET.\n- **"Plugin runtime aborted" is out of memory, not a timeout**, and reading `instance.componentProperties` in bulk is the fastest way there: the getter deep-wraps every property object, so 1,000 instances costs about 4 seconds and 5,700 kills the sandbox. Memory is only reclaimed when the command returns, so the fix is slices \u2014 walk a fixed number of nodes per command and have the caller ask again \u2014 not a tidier traversal inside one script.\n- **`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 (`.Icon Content` 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 `.Icon Content` ships `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- **A plugin-created instance placed under an explicit variable mode stays stuck on the placeholder, 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` followed by `setExplicitVariableModeForCollection`; the binding was never destroyed, only unresolved. (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-and-re-set before reading, 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 Content` 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- **Dot-prefixed components are never published**, so their keys 404 on every REST endpoint. Figma hides `.`-prefixed assets from the publish dialog; they exist only nested inside published parents. When resolving a key \u2192 file via `/v1/components/{key}`, skip names starting with `.` \u2014 and note a component SET\'s key resolves only at `/v1/component_sets/{key}`, never at `/v1/components/{key}` (and vice versa). Relatedly, `/v1/files/{key}/components` returns the individual VARIANTS (`size=md, state=default`); the components a designer names live in `/component_sets`.\n<!-- PITFALLS:END -->\n\nAlso: `resize(w, h)` throws on `w <= 0 || h <= 0`; `layoutWrap = "WRAP"` is HORIZONTAL-only, `counterAxisAlignItems = "BASELINE"` too; writing to `locked` nodes is allowed by the API but surprises designers \u2014 check first.\n\n## Return values\n\nReturn JSON-serializable data (the bridge summarizes nodes automatically and truncates >50k chars). Prefer returning `{ id, name, type }` summaries over whole nodes. `console.log` is captured and returned in `logs`.\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
|
|
4147
4325
|
|
|
4148
4326
|
// ../../docs/skills/design-tokens-strategy.md
|
|
4149
4327
|
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";
|
|
@@ -4261,10 +4439,11 @@ export {
|
|
|
4261
4439
|
FIGMA_COMMANDS,
|
|
4262
4440
|
RelayCore,
|
|
4263
4441
|
PITFALLS,
|
|
4442
|
+
resolveFileIdentity,
|
|
4264
4443
|
registerRoomTool,
|
|
4265
4444
|
registerEventsTool,
|
|
4266
4445
|
registerAllTools,
|
|
4267
4446
|
listToolCatalog,
|
|
4268
4447
|
registerPrompts
|
|
4269
4448
|
};
|
|
4270
|
-
//# sourceMappingURL=chunk-
|
|
4449
|
+
//# sourceMappingURL=chunk-DPDMRY72.js.map
|