figma-relai 0.5.2 → 0.5.4
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/README.md +16 -5
- package/dist/{chunk-7KACLWNP.js → chunk-AJPSPZ64.js} +144 -34
- package/dist/chunk-AJPSPZ64.js.map +1 -0
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/{manifest-LYWTTJTX.js → manifest-A3NB3JW5.js} +2 -2
- package/package.json +1 -1
- package/dist/chunk-7KACLWNP.js.map +0 -1
- /package/dist/{manifest-LYWTTJTX.js.map → manifest-A3NB3JW5.js.map} +0 -0
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Bulk edits. "Translate every button label to English" or "recolor this for dark
|
|
|
61
61
|
|
|
62
62
|
Audits. `analyze_design` checks color-token coverage, auto-layout quality, component health, and accessibility (WCAG contrast, touch targets, text sizes) — or all four at once as a weighted 0–100 health score you can put in a review. It also scores agent-readiness (how prepared the file is for AI work, with top gaps), fingerprints the file's voice (its radius/spacing/type signature), and runs a ghost census for references to soft-deleted variables.
|
|
63
63
|
|
|
64
|
-
Design systems. Variable collections with modes, token binding, shared styles, components with proper variants, team-library imports. `get_design_system` inventories what the file — and the libraries it uses — already has, so the AI builds from your components instead of redrawing near-copies; `analyze_design`'s tokens aspect finds hardcoded values that visually match an existing variable, and one `tokenize` call binds them all. These run as declarative operations with precondition checks, so the same request behaves the same way every time, and a failure tells the AI what to do next ("call set_layout_mode first") instead of dumping a stack trace.
|
|
64
|
+
Design systems. Variable collections with modes, token binding, shared styles, components with proper variants, team-library imports. `get_design_system` inventories what the file — and the libraries it uses — already has (with a token, the library's whole catalog, including components the file has never placed), so the AI builds from your components instead of redrawing near-copies; `analyze_design`'s tokens aspect finds hardcoded values that visually match an existing variable, and one `tokenize` call binds them all. These run as declarative operations with precondition checks, so the same request behaves the same way every time, and a failure tells the AI what to do next ("call set_layout_mode first") instead of dumping a stack trace.
|
|
65
65
|
|
|
66
66
|
Everything else. `execute_figma` runs JavaScript against the Figma Plugin API directly — the same escape-hatch approach as Figma's official MCP — with a `relai.*` helper library that makes the correct pattern the shortest one, hints attached to known errors, and a lint that flags silent mistakes. If you'd rather the AI never ran code, turn it off with the plugin's "Allow code execution" toggle.
|
|
67
67
|
|
|
@@ -133,20 +133,31 @@ Each tool is self-describing, so the AI sees full parameter docs. The same contr
|
|
|
133
133
|
|
|
134
134
|
Figma's own AI has grown fast — the official MCP server now writes to the canvas, and the Figma Design Agent collaborates right inside the editor. Both are reserved for full seats on paid plans, with usage metered in AI credits and models chosen by Figma. Relai is the open-source counterpart on the other side of that line: every plan including free, whatever model and subscription you already use, everything running on your machine, and the designer holding the controls. If you have the seats, the two coexist happily — run both.
|
|
135
135
|
|
|
136
|
-
## Optional:
|
|
136
|
+
## Optional: a Figma token
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
Two things live behind Figma's REST API and need a personal access token: comments, and a library's full component catalog — the components published in a library your file has never placed. Which library that is, Relai works out on its own; there is no URL to paste.
|
|
139
|
+
|
|
140
|
+
Generate a token at figma.com → Settings → Security → Personal access tokens (file content read scope; add the comment scopes if you want `manage_comments`), then hand it over once:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npx figma-relai login # verifies it, names the account back, stores it in ~/.figma-relai/credentials.json (0600)
|
|
144
|
+
npx figma-relai logout # removes it
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The token never arrives as a command argument — argv lands in shell history and in every `ps` on the machine — so `login` takes it from a hidden prompt, or from a pipe (`pbpaste | npx figma-relai login`). Setting `FIGMA_TOKEN` in the MCP config still works and still wins, which keeps a per-project override possible:
|
|
139
148
|
|
|
140
149
|
```json
|
|
141
150
|
{ "mcpServers": { "Relai": { "command": "npx", "args": ["-y", "figma-relai"],
|
|
142
151
|
"env": { "FIGMA_TOKEN": "figd_..." } } } }
|
|
143
152
|
```
|
|
144
153
|
|
|
145
|
-
|
|
154
|
+
Either way, the token is read by the MCP server process only and is sent only to `api.figma.com`. It never crosses the relay to the plugin — the panel's ACCESS TOKEN pill reports presence, never the value. Every other tool works without it.
|
|
155
|
+
|
|
156
|
+
With it, "apply the feedback in the comments" becomes a thing the AI can actually do: read the threads, make the edits, reply. It also unlocks a quiet workflow: leave an @-comment on the canvas as a task, then tell your AI to "check the comments" — it claims the thread, does the work, and reports back on it.
|
|
146
157
|
|
|
147
158
|
## Troubleshooting
|
|
148
159
|
|
|
149
|
-
Start with `npx figma-relai doctor` — one command that checks Node, the relay ports (and whether something foreign is squatting on them), plugin presence, the saved room, and the
|
|
160
|
+
Start with `npx figma-relai doctor` — one command that checks Node, the relay ports (and whether something foreign is squatting on them), plugin presence, the saved room, and the Figma token — where it came from, and whether the stored file's mode is tight enough — each with its fix.
|
|
150
161
|
|
|
151
162
|
**The plugin shows NO SERVER.** No MCP server is listening on ports 9055–9057, which usually means your AI client isn't running or Relai isn't registered in it. The panel shows the exact registration command; the plugin keeps dialing and connects the moment a server appears.
|
|
152
163
|
|
|
@@ -15,7 +15,7 @@ function createServer() {
|
|
|
15
15
|
return new McpServer(
|
|
16
16
|
{
|
|
17
17
|
name: "Relai",
|
|
18
|
-
version: "0.5.
|
|
18
|
+
version: "0.5.4"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
instructions: `
|
|
@@ -419,6 +419,46 @@ var PITFALLS = [
|
|
|
419
419
|
pattern: null,
|
|
420
420
|
hint: "",
|
|
421
421
|
doc: "**`setProperties` DOES work on instances nested inside other instances** \u2014 it records as an override on the outer instance. Only variable-binding writes (`setBoundVariable`) silently revert inside instance internals. Don't skip nested instances when migrating variant or boolean properties; skip them only for binding writes."
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
pattern: "Plugin runtime aborted",
|
|
425
|
+
hint: "The sandbox VM ran out of room marshalling values, not time. Usual cause: reading a deep-wrapped property (componentProperties, overrides, boundVariables, exportSettings) across thousands of nodes \u2014 each access copies a fresh object into the VM. Chunk the scan (a few hundred nodes per execute_figma), collect only the fields you need, and never build one array holding every node's full property object.",
|
|
426
|
+
doc: "**`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.)"
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
pattern: null,
|
|
430
|
+
hint: "",
|
|
431
|
+
doc: "**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)."
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
pattern: null,
|
|
435
|
+
hint: "",
|
|
436
|
+
doc: "**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."
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
pattern: null,
|
|
440
|
+
hint: "",
|
|
441
|
+
doc: "**`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."
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
pattern: "no setter for property",
|
|
445
|
+
hint: "Some layout properties are read-only and have an imperative setter instead. Grid children: gridRowAnchorIndex/gridColumnAnchorIndex are readonly \u2014 call node.setGridChildPosition(row, column). Grid tracks: gridColumnSizes/gridRowSizes cannot be assigned as arrays, but gridColumnCount/gridRowCount are writable and the returned GridTrackSize objects have their own .type/.value setters.",
|
|
446
|
+
doc: "**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."
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
pattern: null,
|
|
450
|
+
hint: "",
|
|
451
|
+
doc: "**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, or do what the house pattern does and put the per-size typography in its own atom that the parent instantiates. 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."
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
pattern: null,
|
|
455
|
+
hint: "",
|
|
456
|
+
doc: "**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."
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
pattern: null,
|
|
460
|
+
hint: "",
|
|
461
|
+
doc: "**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`."
|
|
422
462
|
}
|
|
423
463
|
];
|
|
424
464
|
|
|
@@ -1849,17 +1889,18 @@ function register15(server, sendCommand) {
|
|
|
1849
1889
|
// src/tools/core/design-system.ts
|
|
1850
1890
|
var design_system_exports = {};
|
|
1851
1891
|
__export(design_system_exports, {
|
|
1892
|
+
discoverLibraryFiles: () => discoverLibraryFiles,
|
|
1852
1893
|
register: () => register16
|
|
1853
1894
|
});
|
|
1854
1895
|
import { z as z17 } from "zod";
|
|
1855
1896
|
function register16(server, sendCommand) {
|
|
1856
1897
|
server.tool(
|
|
1857
1898
|
"get_design_system",
|
|
1858
|
-
"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,
|
|
1899
|
+
"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. Results are cached per session \u2014 pass refresh:true after big library changes.",
|
|
1859
1900
|
{
|
|
1860
1901
|
refresh: z17.boolean().optional().describe("Rescan instead of using the session cache"),
|
|
1861
1902
|
libraryFileUrl: z17.string().optional().describe(
|
|
1862
|
-
"
|
|
1903
|
+
"Optional. Catalog THIS library file instead of the auto-resolved ones (figma.com URL or key)"
|
|
1863
1904
|
)
|
|
1864
1905
|
},
|
|
1865
1906
|
{ readOnlyHint: true },
|
|
@@ -1870,9 +1911,7 @@ function register16(server, sendCommand) {
|
|
|
1870
1911
|
{ refresh: refresh ?? false },
|
|
1871
1912
|
12e4
|
|
1872
1913
|
);
|
|
1873
|
-
|
|
1874
|
-
data.libraryCatalog = await fetchLibraryCatalog(libraryFileUrl);
|
|
1875
|
-
}
|
|
1914
|
+
data.libraryCatalog = await fetchLibraryCatalogs(libraryFileUrl, data);
|
|
1876
1915
|
const truncNotes = [];
|
|
1877
1916
|
const components = data.components;
|
|
1878
1917
|
for (const [group, entry] of Object.entries(components ?? {})) {
|
|
@@ -1917,8 +1956,23 @@ function register16(server, sendCommand) {
|
|
|
1917
1956
|
async ({ action, content, kind, text, id, refs, limit }) => {
|
|
1918
1957
|
try {
|
|
1919
1958
|
switch (action) {
|
|
1920
|
-
case "set":
|
|
1921
|
-
|
|
1959
|
+
case "set": {
|
|
1960
|
+
const saved = await sendCommand("set_conventions", {
|
|
1961
|
+
content: content ?? ""
|
|
1962
|
+
});
|
|
1963
|
+
try {
|
|
1964
|
+
const status = await sendCommand("ruleset_status", {});
|
|
1965
|
+
if (status?.state === "drifted" && status.linked) {
|
|
1966
|
+
return jsonResult({
|
|
1967
|
+
...saved,
|
|
1968
|
+
kit: { linked: status.linked, state: status.state },
|
|
1969
|
+
recommended_next: `These rules now differ from the kit "${status.linked}". If they belong to every file that uses it, manage_rulesets action:push sends them up (file \u2192 kit); if they are this file's own, leave the difference standing. Ask the designer \u2014 the panel's "update" button runs the other way (kit \u2192 file) and would overwrite what was just written.`
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
} catch {
|
|
1973
|
+
}
|
|
1974
|
+
return jsonResult(saved);
|
|
1975
|
+
}
|
|
1922
1976
|
case "record_precedent":
|
|
1923
1977
|
return jsonResult(
|
|
1924
1978
|
await sendCommand("record_precedent", { kind, text, refs, source: "chat" })
|
|
@@ -1938,45 +1992,101 @@ function register16(server, sendCommand) {
|
|
|
1938
1992
|
}
|
|
1939
1993
|
);
|
|
1940
1994
|
}
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
return {
|
|
1945
|
-
note: "Full library catalogs need a personal access token (file content read scope): store it once with `npx figma-relai login`, or set FIGMA_TOKEN in the MCP config env. Without it, components the file already uses are still listed above \u2014 or keep a 'DS palette' page with one instance of each key component so they show up there."
|
|
1946
|
-
};
|
|
1947
|
-
}
|
|
1948
|
-
const fileKey = parseFileKey(libraryFileUrl);
|
|
1949
|
-
if (!fileKey) {
|
|
1950
|
-
return { note: `Could not extract a file key from "${libraryFileUrl}".` };
|
|
1951
|
-
}
|
|
1952
|
-
const get = async (path) => {
|
|
1995
|
+
var PROBE_LIMIT = 12;
|
|
1996
|
+
function apiFor(token) {
|
|
1997
|
+
return async (path) => {
|
|
1953
1998
|
const res = await fetch(`https://api.figma.com/v1${path}`, {
|
|
1954
1999
|
headers: { "X-Figma-Token": token }
|
|
1955
2000
|
});
|
|
1956
2001
|
const json = await res.json().catch(() => ({}));
|
|
1957
|
-
|
|
1958
|
-
throw new Error(
|
|
1959
|
-
`Figma API ${res.status} for ${path}: ${json.err ?? "request failed"}`
|
|
1960
|
-
);
|
|
1961
|
-
}
|
|
1962
|
-
return json;
|
|
2002
|
+
return { ok: res.ok, status: res.status, json };
|
|
1963
2003
|
};
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
2004
|
+
}
|
|
2005
|
+
var metaOf = (r) => r.json.meta ?? {};
|
|
2006
|
+
async function discoverLibraryFiles(api, scan) {
|
|
2007
|
+
const comps = scan.components?.remoteUsed?.items ?? [];
|
|
2008
|
+
const styles = scan.styles?.remoteUsed?.items ?? [];
|
|
2009
|
+
const candidates = [
|
|
2010
|
+
...comps.map((c) => ({ ...c, paths: ["/component_sets/", "/components/"] })),
|
|
2011
|
+
...styles.map((s) => ({ ...s, paths: ["/styles/"] }))
|
|
2012
|
+
].filter((c) => c.key && c.name && !c.name.startsWith(".")).slice(0, PROBE_LIMIT);
|
|
2013
|
+
const found = /* @__PURE__ */ new Map();
|
|
2014
|
+
for (const c of candidates) {
|
|
2015
|
+
for (const p of c.paths) {
|
|
2016
|
+
const res = await api(`${p}${c.key}`);
|
|
2017
|
+
const fileKey = metaOf(res).file_key;
|
|
2018
|
+
if (res.ok && fileKey) {
|
|
2019
|
+
if (!found.has(fileKey)) found.set(fileKey, c.name);
|
|
2020
|
+
break;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
return [...found].map(([fileKey, via]) => ({ fileKey, via }));
|
|
2025
|
+
}
|
|
2026
|
+
async function catalogOne(api, fileKey) {
|
|
2027
|
+
const [sets, components, styles, meta] = await Promise.all([
|
|
2028
|
+
api(`/files/${fileKey}/component_sets`),
|
|
2029
|
+
api(`/files/${fileKey}/components`),
|
|
2030
|
+
api(`/files/${fileKey}/styles`),
|
|
2031
|
+
api(`/files/${fileKey}/meta`)
|
|
1967
2032
|
]);
|
|
1968
|
-
const
|
|
2033
|
+
const failed = [sets, components, styles].find((r) => !r.ok);
|
|
2034
|
+
if (failed) {
|
|
2035
|
+
return {
|
|
2036
|
+
fileKey,
|
|
2037
|
+
error: `Figma API ${failed.status}: ${failed.json.err ?? failed.json.message ?? "request failed"}`
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
const rawComponents = metaOf(components).components ?? [];
|
|
2041
|
+
const standalone = rawComponents.filter(
|
|
2042
|
+
(c) => !c.containing_frame?.containingComponentSet
|
|
2043
|
+
);
|
|
1969
2044
|
return {
|
|
1970
|
-
|
|
2045
|
+
fileKey,
|
|
2046
|
+
name: meta.json.file?.name ?? void 0,
|
|
2047
|
+
componentSets: (metaOf(sets).component_sets ?? []).map(
|
|
1971
2048
|
(c) => ({ key: c.key, name: c.name, description: c.description || void 0 })
|
|
1972
2049
|
),
|
|
1973
|
-
|
|
2050
|
+
components: standalone.map((c) => ({
|
|
2051
|
+
key: c.key,
|
|
2052
|
+
name: c.name,
|
|
2053
|
+
description: c.description || void 0
|
|
2054
|
+
})),
|
|
2055
|
+
variantsFolded: rawComponents.length - standalone.length,
|
|
2056
|
+
styles: (metaOf(styles).styles ?? []).map((s) => ({
|
|
1974
2057
|
key: s.key,
|
|
1975
2058
|
name: s.name,
|
|
1976
2059
|
type: s.style_type
|
|
1977
2060
|
}))
|
|
1978
2061
|
};
|
|
1979
2062
|
}
|
|
2063
|
+
async function fetchLibraryCatalogs(libraryFileUrl, scan) {
|
|
2064
|
+
const token = loadToken();
|
|
2065
|
+
if (!token) {
|
|
2066
|
+
return {
|
|
2067
|
+
note: "Full library catalogs need a personal access token (file content read scope): store it once with `npx figma-relai login`, or set FIGMA_TOKEN in the MCP config env. Without it, components the file already uses are still listed above \u2014 or keep a 'DS palette' page with one instance of each key component so they show up there."
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
const api = apiFor(token);
|
|
2071
|
+
let targets;
|
|
2072
|
+
if (libraryFileUrl) {
|
|
2073
|
+
const fileKey = parseFileKey(libraryFileUrl);
|
|
2074
|
+
if (!fileKey) return { note: `Could not extract a file key from "${libraryFileUrl}".` };
|
|
2075
|
+
targets = [{ fileKey, via: "libraryFileUrl" }];
|
|
2076
|
+
} else {
|
|
2077
|
+
targets = await discoverLibraryFiles(api, scan);
|
|
2078
|
+
if (!targets.length) {
|
|
2079
|
+
return {
|
|
2080
|
+
note: "No library file could be resolved from the keys this file holds \u2014 every candidate 404'd. Pass libraryFileUrl explicitly."
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
const libraries = [];
|
|
2085
|
+
for (const t of targets) {
|
|
2086
|
+
libraries.push({ resolvedFrom: t.via, ...await catalogOne(api, t.fileKey) });
|
|
2087
|
+
}
|
|
2088
|
+
return { libraries };
|
|
2089
|
+
}
|
|
1980
2090
|
|
|
1981
2091
|
// src/tools/core/rulesets.ts
|
|
1982
2092
|
var rulesets_exports = {};
|
|
@@ -3763,7 +3873,7 @@ function listToolCatalog() {
|
|
|
3763
3873
|
}
|
|
3764
3874
|
|
|
3765
3875
|
// ../../docs/skills/figma-plugin-api.md
|
|
3766
|
-
var figma_plugin_api_default = '# Figma Plugin API Cheat Sheet \u2014 for `execute_figma`\n\nRules and patterns for writing code that runs in the plugin sandbox. Work in small incremental scripts and `screenshot` between steps.\n\n## Non-negotiable rules (dynamic-page mode)\n\n- **Node lookup is async**: `await figma.getNodeByIdAsync(id)` \u2014 `getNodeById` throws in this plugin.\n- **Load pages before traversal**: `await page.loadAsync()` before `page.findAll(...)` on a non-current page; `figma.currentPage` is always loaded.\n- **Load fonts before ANY text edit**: `await figma.loadFontAsync(textNode.fontName)` \u2014 if `fontName === figma.mixed`, load every range: `for (const f of textNode.getRangeAllFontNames(0, textNode.characters.length)) await figma.loadFontAsync(f)`.\n- **`figma.mixed` is a Symbol**: properties like `fontSize`, `cornerRadius`, `fills` return it when values differ across ranges/children. Check `=== figma.mixed` before using; never JSON-serialize it.\n\n## Common patterns\n\n```js\n// Selection\nconst sel = figma.currentPage.selection; // read\nfigma.currentPage.selection = [node]; // write\n\n// Create + place\nconst frame = figma.createFrame();\nframe.resize(320, 200); // width/height are read-only; use resize()\nparent.appendChild(frame); // default parent is currentPage\n\n// Fills/strokes are ARRAYS and must be reassigned wholesale\nnode.fills = [{ type: "SOLID", color: { r: 1, g: 0.5, b: 0 } }]; // rgb 0-1, no alpha key \u2014 use opacity\nconst fills = JSON.parse(JSON.stringify(node.fills)); // clone before mutating\nfills[0].color.r = 0; node.fills = fills;\n\n// Auto-layout: set layoutMode FIRST, then padding/spacing/align\nframe.layoutMode = "VERTICAL";\nframe.itemSpacing = 8; frame.paddingTop = 16;\nchild.layoutSizingHorizontal = "FILL"; // requires PARENT with auto-layout\n\n// Components\nconst comp = await figma.importComponentByKeyAsync(key); // team library\nconst inst = comp.createInstance();\ninst.setProperties({ Variant: "Primary" }); // throws on unknown property names\n\n// Variables\nconst collections = await figma.variables.getLocalVariableCollectionsAsync();\nconst v = figma.variables.createVariable("name", collection, "COLOR");\nv.setValueForMode(modeId, { r: 0, g: 0, b: 0 });\nnode.setBoundVariable("fills", v); // binding paint needs figma.variables.setBoundVariableForPaint on the paint object for fills in older APIs \u2014 prefer setBoundVariable where available\n\n// Export (returns Uint8Array)\nconst bytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 2 } });\n```\n\n## `relai.*` helpers \u2014 the shortest path is the correct one\n\nAlongside `figma`, scripts get a `relai` object whose helpers are immune to the pitfalls below. Prefer them:\n\n```js\nawait relai.text(parent, "Hello", { font: {family:"IBM Plex Mono", style:"Regular"}, size: 14, color: {r:0,g:0,b:0} });\n // loads the font BEFORE writing characters\nconst card = relai.autoLayout("VERTICAL", { name: "Card", itemSpacing: 8 });\n // auto-layout frame, both axes hugging\nrelai.set(node, { layoutMode: "HORIZONTAL", width: 320, opacity: 0.9 });\n // layoutMode applied first; width/height via resize()\nrelai.hug(node); // HUG that sticks \u2014 call after appending children\nrelai.focusRing(button); // clipsContent + double spread shadows that render\nconst page = await relai.page(p => p.children.some(c => c.name === "Button"));\n // find pages by CONTENT \u2014 names get renamed\nconst titles = relai.query(\'FRAME[name^=Card] > TEXT\');\n // CSS-like search on the current page; relai.query(node, sel) scopes to a subtree.\n // Supported: TYPE, *, [name=] [name*=] [name^=] [name$=], descendant, >, comma.\n // NOT supported: pseudo-classes, dot-paths, sibling combinators \u2014 use findAll.\nrelai.placeholder(section); // construction veil so the designer sees work-in-progress\nrelai.placeholder(section, false); // ALWAYS remove when the section is done\n```\n\nTwo important facts: **scripts are NOT atomic** \u2014 on error, changes made before the throw persist, so keep scripts small and clean up after failures; and results may carry a `warnings` array for silent mistakes the lint catches on relai-created nodes and any node ids you return (e.g. spread shadows on a non-clipping frame). Convention: **return every created/mutated node id** (`return { createdNodeIds: [...] }`) \u2014 it powers both follow-up calls and the lint.\n\n## Pitfalls that throw (or silently do the wrong thing)\n\nWhen one of these throws inside `execute_figma`, the error already carries the remedy as a `Hint:` \u2014 this list is generated from the same registry (`packages/shared/src/pitfalls.ts`).\n\n<!-- PITFALLS:START -->\n- **`not a function` errors may name no symbol** \u2014 Figma\'s VM drops the subject. Usual cause: APIs from other agent tools that don\'t exist here (`node.query()`, `figma.notify()`). Use `findAll`/`findAllWithCriteria` for search; bisect when the message names nothing.\n- **Editing text without loading its font throws** (`unloaded font`). `await figma.loadFontAsync(textNode.fontName)` first; new TextNodes default to Inter Regular; when `fontName === figma.mixed`, load every font from `getRangeAllFontNames()`.\n- **`getNodeById` throws in dynamic-page mode** \u2014 always `await figma.getNodeByIdAsync(id)`.\n- **Traversing a non-current page throws until you `await page.loadAsync()`** \u2014 `figma.currentPage` is always loaded, other pages are not.\n- **`layoutSizingHorizontal/Vertical` throw without auto-layout** \u2014 FILL requires the parent to have a `layoutMode`, HUG requires it on the node itself.\n- **`createPage()` throws on the free plan once a file has 3 pages** \u2014 reuse an existing page instead.\n- **`width`/`height` are read-only** \u2014 use `node.resize(w, h)`.\n- **`figma.mixed` is a Symbol** returned by `fontSize`, `cornerRadius`, `fills` etc. when values differ across ranges/children \u2014 check `=== figma.mixed` before use; never JSON-serialize it.\n- **Exact-name lookups are fragile** \u2014 designers rename pages and layers freely; locate nodes by type/content instead of `name ===`.\n- **Stale node ids throw `does not exist`** \u2014 nodes get deleted while you work; re-read before editing and check `node.removed`.\n- **Nodes are non-extensible** \u2014 `node.myCustomProp = x` throws `object is not extensible`; use `setPluginData` or return the data instead.\n- **Shadow `spread` renders only on shapes or frames with `clipsContent: true`** \u2014 a focus ring built from spread shadows is invisible on a non-clipping frame/component.\n- **`resize()` on an auto-layout frame silently pins that axis to FIXED**, overriding `primaryAxisSizingMode: "AUTO"`. Append children first, then set `layoutSizingHorizontal = "HUG"`; use `resize` only for the fixed cross-axis.\n- **Per-corner radius** (`topLeftRadius` \u2026) only exists on RectangleCornerMixin nodes (rectangles, frames, components) \u2014 polygons, stars and lines throw.\n- **Instance children can\'t be added or removed** \u2014 detach first, or edit the main component.\n- **Figma\'s slot feature (Convert to slot, \u21E7\u2318S) has no Plugin API** \u2014 no createSlot/convertToSlot exists (verified against typings 1.123 and at runtime, 2026-07). Scaffold a frame named "Slot", select it for the designer (navigate select), and let them press the shortcut.\n- **A converted SLOT node stops hugging** \u2014 after the designer converts a frame to a slot it carries its own fixed size. Re-assert `layoutSizingVertical = "HUG"` (and the intended horizontal mode) on the SLOT node afterwards; the API can still edit its layout props.\n- **Variantizing can silently pin the hug axis to FIXED** \u2014 observed after clone \u2192 counterAxisSizingMode/width-variable changes \u2192 combineAsVariants: the variants\' `primaryAxisSizingMode` ended up FIXED though the source hugged. Always verify sizing modes on every variant after combineAsVariants and re-assert `"AUTO"`.\n- **A backgrounded Figma window suspends the plugin iframe** \u2014 timers freeze and the socket can\'t pong, so the relay\'s staleness sweep kicks the plugin and the frozen 2s redial never fires until the window regains attention. The panel redials on visibilitychange/focus (ui.html \u22650.2.4); when a paired plugin goes missing mid-session, ask the designer to click the Figma window before assuming anything is broken.\n- **A freshly-converted slot can still report `type: "FRAME"` to the Plugin API** \u2014 the layer panel shows the slot badge while getNodeByIdAsync returns FRAME (a reload boundary was observed to fix it). Never gate automation on `type === "SLOT"`; match the frame by name and trust the designer\'s visual confirmation.\n- **Deleted variables are soft-deleted ghosts** \u2014 `getVariableByIdAsync` still resolves them with name AND working values (`Variable` has no `removed` property), so bindings to them keep rendering while being invisible in pickers and dead on publish. To test aliveness, check membership in `getLocalVariablesAsync()` (remote variables are legitimately absent \u2014 check `variable.remote`).\n- **Variables cannot be reordered via the API** (`variableIds` is read-only; the `move\u2026After` methods exist only for styles) \u2014 new variables always append last; panel order is manual-drag only. NEVER delete-and-recreate variables to reorder: bindings reference variable IDs, so every binding in the file silently dies (observed in production; the ghosts keep rendering, hiding the damage).\n- **A remote (library) text style dominates variable bindings** \u2014 on text with a remote style applied, `setBoundVariable` writes appear to succeed but snap back to the style\'s own bindings. Clear the style first (`await setTextStyleIdAsync("")`), then bind.\n- **Binding writes inside instances silently revert \u2014 with two escape hatches.** `setBoundVariable` on a node nested inside an INSTANCE succeeds without error, then reverts (instance roots placed directly in a main ARE writable; anything deeper is not). What does persist: reassigning the whole `fills`/`strokes` array (variable-bound paints ride inside), and range-level `setRangeBoundVariable` on text. Verify after writing; never trust the call alone.\n- **A bound `strokeWeight` renders nothing while `strokes` is empty, and wrappers don\'t hug stroke thickness.** A LINE\'s visible weight lives entirely in its stroke paint; the frame around it keeps its own raw height/width, so the panel shows an unbound number with empty Fill/Stroke \u2014 looking broken when it isn\'t. Inspect the LINE child\'s stroke bindings, not the wrapper; and bind the wrapper\'s thickness-axis dimension to the same variable so nothing stays raw.\n- **`setBoundVariable("strokeWeight", \u2026)` is a silent no-op on FRAME-family nodes.** Frames and components bind stroke weight per side \u2014 `strokeTopWeight` / `strokeRightWeight` / `strokeBottomWeight` / `strokeLeftWeight`; the uniform `strokeWeight` field only binds on stroke-geometry nodes like LINE. No error is thrown, the binding just never appears \u2014 read back `boundVariables` after writing.\n- **Cloning a variant escapes its component set \u2014 onto whatever page the user is viewing.** `variant.clone()` parents the copy to `figma.currentPage` as a standalone COMPONENT (not into the set) \u2014 plain nodes inside components clone to the current page too. Every `componentPropertyReferences` pointing at set-level properties is silently severed, and native SLOT children are demoted to plain FRAMEs (slot-ness needs set context). Recover by `componentSet.appendChild(clone)`, re-assigning ALL references (`visible`, `slotContentId`, \u2026) \u2014 a SLOT can be restored by cloning a surviving SLOT node from a sibling variant (SLOT clones keep their type) and relinking `componentPropertyReferences.slotContentId`. Audit every set-scoped feature after adoption; none of it comes back on its own.\n- **Uneven variant renames/deletes poison the whole component set.** If variants end up with mismatched axis segments (some carry `axis=value`, others don\'t) or duplicate names, the set enters an error state where `componentPropertyDefinitions` AND every instance\'s `variantProperties`/`setProperties` throw (`Component set has existing errors`). Escape: rename stragglers so all variants share the same axes with unique values (temporary `legacy-*` values work), which heals the set \u2014 then migrate and delete.\n- **`setProperties` DOES work on instances nested inside other instances** \u2014 it records as an override on the outer instance. Only variable-binding writes (`setBoundVariable`) silently revert inside instance internals. Don\'t skip nested instances when migrating variant or boolean properties; skip them only for binding writes.\n<!-- PITFALLS:END -->\n\nAlso: `resize(w, h)` throws on `w <= 0 || h <= 0`; `layoutWrap = "WRAP"` is HORIZONTAL-only, `counterAxisAlignItems = "BASELINE"` too; writing to `locked` nodes is allowed by the API but surprises designers \u2014 check first.\n\n## Return values\n\nReturn JSON-serializable data (the bridge summarizes nodes automatically and truncates >50k chars). Prefer returning `{ id, name, type }` summaries over whole nodes. `console.log` is captured and returned in `logs`.\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
|
|
3876
|
+
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- **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, or do what the house pattern does and put the per-size typography in its own atom that the parent instantiates. 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- **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';
|
|
3767
3877
|
|
|
3768
3878
|
// ../../docs/skills/design-tokens-strategy.md
|
|
3769
3879
|
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";
|
|
@@ -3887,4 +3997,4 @@ export {
|
|
|
3887
3997
|
listToolCatalog,
|
|
3888
3998
|
registerPrompts
|
|
3889
3999
|
};
|
|
3890
|
-
//# sourceMappingURL=chunk-
|
|
4000
|
+
//# sourceMappingURL=chunk-AJPSPZ64.js.map
|