figma-relai 0.7.10 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,81 +4,13 @@ import {
4
4
  } from "./chunk-SDINVROO.js";
5
5
  import {
6
6
  loadToken,
7
- noteAuthFailure
8
- } from "./chunk-VS2FNFYC.js";
7
+ noteAuthFailure,
8
+ stateDir
9
+ } from "./chunk-INWZM5QK.js";
9
10
  import {
10
11
  __export
11
12
  } from "./chunk-2H7UOFLK.js";
12
13
 
13
- // src/version.ts
14
- var VERSION = "0.7.10";
15
-
16
- // src/server.ts
17
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
- function createServer() {
19
- return new McpServer(
20
- {
21
- name: "Relai",
22
- version: VERSION
23
- },
24
- {
25
- instructions: `
26
- You control Figma through ~30 consolidated tools. Pairing with the Figma plugin is automatic \u2014 just call any tool; join_room is only needed if an error reports multiple plugins.
27
-
28
- \u{1F4D6} UNDERSTAND (start here):
29
- get_document_overview \u2014 structure, page/component/style/variable counts
30
- get_selection_context \u2014 full context for what the designer selected
31
- get_node_details \u2014 deep single-node inspection (CSS, token bindings)
32
- search_nodes \u2014 find nodes by name/type \xB7 get_node_data \u2014 raw data/tree/css
33
- get_design_tokens \u2014 variable collections, modes, styles
34
- screenshot \u2014 see the canvas (use this to verify your work visually)
35
-
36
- \u{1F50D} DIAGNOSE:
37
- analyze_design \u2014 aspect: color/layout/components/accessibility, or
38
- "overall" for a weighted 0-100 health score across all four
39
- diff_nodes \u2014 compare two nodes, or checkpoint save/compare to audit
40
- what changed on a node over an editing session
41
-
42
- \u270F\uFE0F EDIT:
43
- create_node \u2014 any node type (rectangle/frame/text/svg/image/\u2026)
44
- set_properties \u2014 one call for geometry, fills, strokes, effects, text,
45
- auto-layout, constraints, style/variable bindings, on one or many nodes
46
- set_text \u2014 single, bulk, or character-range text edits
47
- edit_structure \u2014 group/reparent/reorder/clone/flatten/boolean/delete
48
- navigate \u2014 focus/select/viewport/switch_page \xB7 manage_pages
49
-
50
- \u{1F9F1} DESIGN SYSTEM:
51
- manage_components \u2014 create/variants/instantiate/overrides/props/detach
52
- manage_variables \u2014 collections, modes, tokens, bind/unbind
53
- manage_styles \u2014 paint/text/effect/grid styles
54
- import_from_library \u2014 bring in library components/styles/variables
55
-
56
- \u{1F4E6} ASSETS: export_asset (PNG/JPG/SVG/PDF) \xB7 add_image (URL or fill)
57
- \u{1F4DD} ANNOTATIONS: annotate (Dev Mode annotations)
58
- \u{1F4AC} COMMENTS: manage_comments \u2014 read/apply/reply to file comments
59
- (needs FIGMA_TOKEN env; the tool explains setup when missing)
60
-
61
- \u2705 VERIFY: verify_changes \xB7 validate_design_rules
62
-
63
- \u26A1 ADVANCED:
64
- batch_execute \u2014 run many plugin-level commands in one round-trip
65
- execute_figma \u2014 run JavaScript against the Figma Plugin API directly
66
- (the escape hatch when no tool fits; small incremental scripts,
67
- screenshot between steps; the designer can disable it)
68
-
69
- OPERATING PRINCIPLES:
70
- - Inspect context BEFORE changing things; don't assume selection state
71
- - After visual edits, verify: screenshot or verify_changes
72
- - Colors are RGBA in 0-1 range (Figma format), not 0-255
73
- - layoutSizing FILL requires the node's PARENT to have auto-layout;
74
- set layoutMode on a frame before padding/alignment/spacing
75
- - Responses include recommended_next tools \u2014 follow them
76
- - Errors name the node, its type, and the fix \u2014 read them, don't retry blindly
77
- `
78
- }
79
- );
80
- }
81
-
82
14
  // ../shared/src/types/commands.ts
83
15
  var FIGMA_COMMANDS = [
84
16
  // Document & Selection
@@ -487,7 +419,7 @@ var PITFALLS = [
487
419
  {
488
420
  pattern: null,
489
421
  hint: "",
490
- 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 \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."
422
+ 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 \u2014 an atom does not save you either unless the atom's variants are standalone components rather than a set of their own. Layers bound to one TEXT component property are linked through the property as well: every layer whose `characters` references it shares a single `textDecoration` \u2014 write it on one variant and all of them change, re-binding a layer pulls it back to the shared value, and only a layer whose reference is removed keeps its own. The binding step writes too: pointing five variants' titles at a new TEXT property replaced their `characters` with the property's default and reset their underline to `NONE`, while the `combineAsVariants` just before it had kept every variant's underline. So a label whose decoration differs per variant cannot sit on a shared TEXT property. And a decoration written in the same execution as `setTextStyleIdAsync` is clobbered by the style landing \u2014 set it in a later call."
491
423
  },
492
424
  {
493
425
  pattern: null,
@@ -547,22 +479,22 @@ var PITFALLS = [
547
479
  {
548
480
  pattern: null,
549
481
  hint: "",
550
- 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.'
482
+ 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 (an icon instance inside a `Body Cell` inside a `Column`) carries its own override that the parent write never touches. Always read the property back on the exact node you meant to change, and sweep nested instances separately.'
551
483
  },
552
484
  {
553
485
  pattern: null,
554
486
  hint: "",
555
- 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.'
487
+ doc: '**Component property keys are `name#id`, and the name may itself contain `#`** \u2014 an icon component can ship `Icon Name#4866:0`, `Icon Name w/##4866:28` and `Icon Name w/###4866:56`. Splitting on the *first* `#` maps the last two to the same base name, so a lookup silently binds only one of them and the duotone layers keep their placeholder glyph. Split on `lastIndexOf("#")`. The mirror trick is useful: matching on the `#id` tail alone follows a property across a rename, since `editComponentProperty` keeps the id.'
556
488
  },
557
489
  {
558
490
  pattern: null,
559
491
  hint: "",
560
- 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."
492
+ doc: "**An instance under an explicit variable mode can get stuck on the placeholder \u2014 when a plugin creates it there, or switches its variant there \u2014 and waiting does not fix it.** Create an instance, drop it under a node whose Theme mode differs from the page's, and read it back: the nested variant *does* swap (`iconFamily=Phosphor` \u2192 `FA7`), but a component property bound to a variable resolves to the new variant's **default** rather than to the token, so every icon reads as the placeholder glyph. Measured: still wrong after three minutes untouched \u2014 in `characters` and in the rendered screenshot alike \u2014 and setting the *same* mode again changes nothing. What unsticks it is `clearExplicitVariableModeForCollection` and then `setExplicitVariableModeForCollection` **in two separate `execute_figma` calls**: clearing and re-setting inside one script leaves it stuck (measured both when read 1.5 seconds later in the same script and when read in the next call). The binding was never destroyed, only unresolved. The order does not matter: appending to a parent with no explicit mode and setting the parent's mode in a later call leaves the same placeholder. Nor does it take a new instance: switching an existing instance's variant with `setProperties` under a pinned parent drops the same way \u2014 glyph properties fall back to the unbound placeholder, icon fills to the default variable, a bound size to none \u2014 but only in modes that swap a nested instance to a different variant than its main uses; where the mode resolves to the main's own icon family, nothing drops. Clear-then-set in separate calls restored every binding in both cases. It is not a sure cure: in another probe (five button instances created in one call, their parent's mode set in the next), clearing and setting the mode in two further calls, then setting it on each instance too, left the two loading buttons' variable-bound glyph names on the placeholder. Writing the resolved values with `setProperties` did stick there, as long as only properties whose value differs are written: writes that repeated the variant properties' current values flipped the nested icon to the main's own variant in 2 of 7 cases (both on the instances with bound glyph names), and writing the differing value back fixed it. That write costs the binding \u2014 a plain write drops the alias on that node (see the plain-write entry) \u2014 so it suits a throwaway probe, not a file's instances. (Distinct from the few-second repaint lag a designer sees after switching a theme by hand on content that already exists.) A brand-switch probe built and screenshotted in one pass will therefore report that a whole file's icon tokens are broken when nothing is wrong: clear in one call, set in the next, read after that \u2014 and confirm against a node that was already in the file."
561
493
  },
562
494
  {
563
495
  pattern: null,
564
496
  hint: "",
565
- 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."
497
+ 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 instance inside a `Body Cell` inside a `Column`. One retirement measured 0 consumers by that walk and 160 by a walk that recursed. Before removing a variable, recurse through instance children; afterwards the id keeps resolving forever (it is a tombstone, not proof of failure), so verify instead that it has left `getLocalVariablesAsync()` and that a recursive scan finds zero bindings."
566
498
  },
567
499
  {
568
500
  pattern: null,
@@ -575,9 +507,9 @@ var PITFALLS = [
575
507
  doc: "**`setBoundVariable` takes a Variable object, not an id string** \u2014 the string form throws `Cannot call setBoundVariable with a variable id. Please pass a variable object`. Fetch the variable first (`getVariableByIdAsync`, or a map built from `getLocalVariablesAsync`) and pass the object itself."
576
508
  },
577
509
  {
578
- pattern: null,
579
- hint: "",
580
- doc: "**Rebinding width/height on a node INSIDE an instance is a silent no-op** \u2014 `setBoundVariable` returns without error and `boundVariables` still shows the old id afterwards. Only `setBoundVariable(field, null)` (unbind) persists there. Stuck overrides \u2014 bindings an instance kept from a main that has since been cleaned \u2014 can therefore never be re-pointed from code, only cleared; and if the main is already clean, clearing IS the correct repair (the node falls back to the main's shape, pixels stay put). Measured on 301 soft-deleted `Size/height/12` bindings (2026-08-12): rebind reported success \xD7301 and changed nothing; unbind cleared every one. Always read the field back after writing \u2014 success without verification is how this one hides."
510
+ pattern: "cannot be overridden in an instance: size",
511
+ hint: "The size of a node inside an instance cannot be overridden from code: rescale() throws this, and resize(), resizeWithoutConstraints() and setBoundVariable('width'|'height', v) return without error and change nothing. Change the size in the main component (e.g. a variant with that size) or swap the nested instance. To back out of a half-done attempt, removeOverrides() on the nested instance clears only that layer and below \u2014 the outer instance keeps its own overrides.",
512
+ doc: "**Giving a node INSIDE an instance a new width/height from code does nothing \u2014 rebinding width/height and `resize()` are silent no-ops, and `rescale()` throws.** `setBoundVariable` returns without error and `boundVariables` still shows the old id afterwards. Only `setBoundVariable(field, null)` (unbind) persists there. On a nested instance (a logo inside a footer instance), `resize()`, `resizeWithoutConstraints()` and `resize()` after `unlockAspectRatio()` all returned normally and read back the old 105.3\xD748, and `rescale(0.75)` threw `This property cannot be overridden in an instance: size` \u2014 change the size in the main (a variant with that size) instead. To back out, `removeOverrides()` on the nested instance clears only the overrides from that layer down; the outer instance keeps its own. Stuck overrides \u2014 bindings an instance kept from a main that has since been cleaned \u2014 can therefore never be re-pointed from code, only cleared; and if the main is already clean, clearing IS the correct repair (the node falls back to the main's shape, pixels stay put). Measured on 301 soft-deleted `Size/height/12` bindings (2026-08-12): rebind reported success \xD7301 and changed nothing; unbind cleared every one. Always read the field back after writing \u2014 success without verification is how this one hides. One sizing write does persist inside an instance: setting `layoutSizingHorizontal` / `layoutSizingVertical` to `HUG` on the inner node is kept, and it also clears stale overrides that point at nodes which no longer exist."
581
513
  },
582
514
  {
583
515
  pattern: "Component set (for node )?has existing errors",
@@ -602,10 +534,233 @@ var PITFALLS = [
602
534
  {
603
535
  pattern: null,
604
536
  hint: "",
605
- doc: "**`combineAsVariants` re-keys every component property and can drop a TEXT property's `characters` reference on the way.** The ids in property keys (`title#1033:0`) belong to the standalone components; combining mints new set-level keys (`title#7527:4`) \u2014 so any key strings you saved before combining are stale afterwards. Worse, the re-wiring is lossy: in one measured combine the `title` reference survived on all variants while the `description` reference vanished from all of them, with no error. After every combine, audit `componentPropertyReferences` across all variants against the set's `componentPropertyDefinitions` and re-wire the orphans (survival is luck, not a rule \u2014 this is the same disease as duplicated variants keeping no references, in a different coat)."
537
+ doc: "**`combineAsVariants` re-keys every component property and can drop a TEXT property's `characters` reference on the way.** The ids in property keys (`title#1033:0`) belong to the standalone components; combining mints new set-level keys (`title#7527:4`) \u2014 so any key strings you saved before combining are stale afterwards. Worse, the re-wiring is lossy: in one measured combine the `title` reference survived on all variants while the `description` reference vanished from all of them, with no error. Existing instances do ride through the re-keying: when `combineAsVariants` turned a lone component into a set of one variant (the second was added afterwards), its TEXT key changed (`text#N:61` \u2192 `text#N:62`), the reference survived, and all 176 instances kept their text, size and position. After every combine, audit `componentPropertyReferences` across all variants against the set's `componentPropertyDefinitions` and re-wire the orphans (survival is luck, not a rule \u2014 this is the same disease as duplicated variants keeping no references, in a different coat)."
538
+ },
539
+ {
540
+ pattern: null,
541
+ hint: "",
542
+ doc: "**Writing `textAutoResize` resets a text node's truncation.** Assigning `NONE`, `HEIGHT` or `WIDTH_AND_HEIGHT` \u2014 including the value it already holds \u2014 sets `textTruncation` back to `DISABLED` and `maxLines` to `null`, with no error (24/24 cases, auto-layout children and free nodes alike). The deprecated `TRUNCATE` keeps `ENDING` but still drops `maxLines`; don't lean on it. `resize()` and `layoutSizingHorizontal` leave `textTruncation` alone, but they can flip `textAutoResize` between `HEIGHT` and `WIDTH_AND_HEIGHT` on the way. Order the writes: `textAutoResize` first, then `textTruncation` and `maxLines`, and re-read all three after any sizing change. On a node whose `textAutoResize` is `NONE`, writing `ENDING` reads back as `TRUNCATE`, and `maxLines = 1` is accepted but reads back `null`."
543
+ },
544
+ {
545
+ pattern: "Removing this node is not allowed",
546
+ hint: "Some nodes cannot be removed from the Plugin API. Known source: an INSTANCE with slots keeps each slot's content in a parentless FRAME (parent === null); while the instance lives that FRAME is its slot storage, and once the instance is removed \u2014 made by createInstance() or by clone() \u2014 the FRAME stays behind: remove() on it, or on the instances inside it, throws this, and getInstancesAsync() still returns those instances. Leave it, exclude nodes whose ancestor chain never reaches a PAGE from audits, and stop building probes out of slot-bearing instances.",
547
+ doc: "**Removing an instance that has slots leaves undeletable orphans behind \u2014 whether it came from `createInstance()` or `clone()`.** An instance with SLOT content keeps each slot's content in a `FRAME` with `parent === null`, holding the slotted instances; while the instance exists that frame is its live storage, not litter. Delete the instance and the frame stays. It sits on no page and nothing shows it, but `remove()` on it (or on the instances inside it) throws `Removing this node is not allowed`, and `component.getInstancesAsync()` keeps returning those instances: one probe that cloned slot-bearing instances to check variable modes took a component set's instance count (summed over its variants) from 545 to 959, and two probes built with `createInstance()` and deleted again left 24 such frames. Whether reopening the file clears them has not been measured. Don't build probes out of slot-bearing instances \u2014 to see one under another mode, resolve its variables against an existing node in that mode (`variable.resolveForConsumer(node)`) \u2014 and in instance audits drop any result whose ancestor chain never reaches a PAGE."
548
+ },
549
+ {
550
+ pattern: null,
551
+ hint: "",
552
+ doc: "**Overriding an instance's text freezes the layer's styling along with it.** Writing `characters` on a text layer inside an instance stores the layer's `styledTextSegments` in the override, so the layer stops following its main per variant: an instance made from a variant whose title has a 1px underline, switched to a variant whose title has a 2px one, kept its own text *and* the 1px \u2014 and it will not follow a later change to the main's text styling either. Binding the layer to a TEXT property instead has its own edge: switch to a variant whose layer is not bound to that property and the text reverts to that variant's own. Create the instance from the variant it should show before overriding its text, and read the decoration back after any variant switch."
553
+ },
554
+ {
555
+ pattern: null,
556
+ hint: "",
557
+ doc: "**An instance reads its main component's pluginData as its own, and emptying a key on the instance leaves an override behind.** `instance.getPluginData(key)` returns the main's value when the instance never set one \u2014 clear the key on the main and it disappears from every instance, and a sweep that counts keys node by node counts each inherited copy again (one cleanup found 188 keys, 94 of them inherited). Writing `''` on an instance does empty its value, but `pluginData` stays in that instance's `overriddenFields`, and nothing removes that entry alone: `removeOverrides()` also discards every rename and resize beside it. Clear a marker on the node you set it on, and don't read a key found on an instance as proof that the instance itself was marked."
558
+ },
559
+ {
560
+ pattern: "createVMObject",
561
+ hint: "getNodeByIdAsync was handed the bare number of a variable or variable collection \u2014 they share the N:M id counter with nodes, and instead of returning null the call throws. Read variables with figma.variables.getVariableByIdAsync('VariableID:N:M') and collections with getVariableCollectionByIdAsync('VariableCollectionId:N:M'); when scanning an id range, wrap each getNodeByIdAsync in its own try/catch.",
562
+ doc: "**`getNodeByIdAsync` throws on the bare id of a variable or collection.** Variables and collections draw from the same `N:M` counter as nodes, so a scan over an id range lands on them: `getNodeByIdAsync('N:M')` for a variable's number throws `unsupported type in createVMObject: VARIABLE` (`VARIABLE_SET` for a collection) instead of returning `null`, while the prefixed `VariableID:N:M` just returns `null`. Catch per id when sweeping ranges, and resolve variables through `figma.variables.getVariableByIdAsync`."
563
+ },
564
+ {
565
+ pattern: "Empty range selected",
566
+ hint: "Range APIs reject empty ranges \u2014 end must be greater than start. On an empty text node getRangeAllFontNames(0, node.characters.length) is (0, 0) and throws; read node.fontName there (it is never mixed on empty text), and skip range calls whenever start === end.",
567
+ doc: "**Range reads throw on an empty range, so an empty text node breaks the usual font-loading loop.** `getRangeAllFontNames(0, node.characters.length)` on a node whose `characters` is `\"\"` throws `Empty range selected. 'end' must be greater than 'start'`, and so does any `getRange*` read with `start === end` (`getRangeFontName(0, 0)`, `getRangeAllFontNames(1, 1)` on `\"ab\"`). `node.fontName` answers normally there and `getStyledTextSegments(['fontName'])` returns `[]`. Load `fontName` when the text is empty, and call range APIs only when `end > start`."
568
+ },
569
+ {
570
+ pattern: null,
571
+ hint: "",
572
+ doc: '**`exportAsync` renders only what a clipping ancestor leaves visible, so pixel diffs of clipped content compare nothing.** By default a node is exported at its `absoluteRenderBounds`, which a `clipsContent` ancestor cuts: a 100\xD7300 child of a clipping 100\xD7100 frame exports as 100\xD7100, and a child clipped away entirely (its `absoluteRenderBounds` is `null`) exports as a 1\xD71 image \u2014 so the hidden rows of a clipped table "match" before and after whatever you changed in them. `exportAsync({ format: "PNG", useAbsoluteBounds: true })` renders the whole node, byte-identical to exporting it with the ancestor unclipped; use it for before/after comparisons, or compare `absoluteRenderBounds` with `absoluteBoundingBox` before trusting a diff.'
573
+ },
574
+ {
575
+ pattern: null,
576
+ hint: "",
577
+ doc: "**Switching a nested instance's variant can discard the overrides its container put inside it.** Inside a component, `setProperties({ state: \"default\" })` on a nested icon button moved it to the new variant but dropped what the component had set on the button's icon: the three glyph properties bound to variables came back as the new variant's unbound default, and the icon's bound fill as the default variable \u2014 only the binding on the icon's variant property (`style`) survived. No error, and instances of the component show the placeholder from then on. After switching a nested instance's variant, re-read its content node by node and rebind from a sibling variant that is already in the target state."
578
+ },
579
+ {
580
+ pattern: null,
581
+ hint: "",
582
+ doc: "**Slot content that is already on another variant when it enters the slot can come back with ids that resolve to nothing.** If a nested instance in the content is already on a different variant than the component defines for it when the content lands in the slot \u2014 an explicit mode swapped an icon to another family, or an override set a menu item to `state=hovered` \u2014 its descendants get ids missing the slot's segment: `getNodeByIdAsync` answers `null` for them, and two items can report the same id. Switching the variant after the content is in the slot keeps the ids whole. The repair measured on a 311-node menu: clear the mode in one call, set the swapped item to its default variant in place in the next and back in the one after, then set the mode \u2014 14 unreadable nodes went to 0; clearing and re-setting the mode alone fixed 13 of them, not a hidden layer. Walk slot content with a per-node try/catch that records the path instead of stopping."
583
+ },
584
+ {
585
+ pattern: null,
586
+ hint: "",
587
+ doc: "**When a variable mode swaps a nested instance to another variant, the swap is stored as overrides, and later binding changes on the main stop reaching it.** Eighteen instances were created in one call and their parents' Theme modes set in the next: in the mode that resolves the nested icon to the main's own variant, the nested instance carried no overrides; in each of the five modes that swapped it, it carried `boundVariables`, `componentProperties`, `fills`, `name`, both axis sizing modes and `targetAspectRatio` as overrides. Rebinding that nested instance's `style` property to a new variable in the main then reached only the unswapped instance \u2014 the other five kept reading the old variable \u2014 while fresh instances with the modes set again all showed the new binding. Instances that were already in the file carried the same overrides. After changing a binding in a main, re-create probe instances rather than re-reading old ones, and read `overrides` on the nested layer before trusting that the change reached instances under other modes."
588
+ },
589
+ {
590
+ pattern: null,
591
+ hint: "",
592
+ doc: "**A VECTOR whose corners are rounded per vertex ignores `cornerRadius` writes on an instance, and only `rescale` scales those corners.** When the radius lives in `vectorNetwork.vertices[i].cornerRadius` (a glyph outline with 12 and 9 rounded vertices), writing the node's `cornerRadius` inside an instance adds a `cornerRadius` entry to `overrides` and nothing else: it reads back the main's 0.449, the vertices keep it, and the render is pixel-identical. Writing the main's value back leaves the entry behind; only `removeOverrides()` clears it, along with every other override on that instance. `resize()` scales the outline but not the vertex radii, so an instance resized 2.4\xD7 draws sharper corners than the main scaled 2.4\xD7 (alpha differs by up to 175/255 at the corners). `rescale(2.4)` scales the vertex radii too (0.449 \u2192 1.079, within 16/255 of the scaled main), but it does not coexist with a variable-bound height: binding `height` after `rescale` resets `scaleFactor` to 1 and the radii to 0.449 (with the aspect ratio locked as well), and `rescale` after binding multiplies the bound height (72 \u2192 259.2). Pick either the bound height or proportional corners."
593
+ },
594
+ {
595
+ pattern: null,
596
+ hint: "",
597
+ doc: "**A bound paint's `color` is only a fallback \u2014 and one component set kept the fallback on screen.** `figma.variables.setBoundVariableForPaint(paint, 'color', v)` returns the paint with your `color` untouched (a `{0,0,0}` placeholder stays `{0,0,0}`). Assigned through `fills` or `setRangeFills`, the stored color is normally replaced by the variable's resolved value: in eight probes (node fills on text and on a rectangle; `setRangeFills` over a whole text, part of one, a text in a set variant, in a standalone component and on a TEXT-property layer) it read back as the resolved gray, and the exported ones rendered it. But four ranges written with `setRangeFills` in two variants of a working component set kept `{0,0,0}` while still bound, and rendered black; a scan of the 168 bound paints around them found no other mismatch, and the trigger was not isolated. Build bound paints with the resolved color (take `{ r, g, b }` from `v.resolveForConsumer(node).value` \u2014 it carries `a`, and a paint's `color` has no alpha) instead of a dummy, and after writing compare each bound paint's stored `color` with the resolved value. The same showed up on an instance whose fill override was bound to the very variable its main uses but stored `{0,0,0}`: it exported black where the main exported light."
598
+ },
599
+ {
600
+ pattern: null,
601
+ hint: "",
602
+ doc: "**A blank symbol in a text node is not proof the font lacks it \u2014 the node can be holding a stale layout.** Key labels drew `\u2318` as empty space on some nodes and fine on others, all in the same monospace font, which has no `\u2318`, `\u21E7` or `\u23CE`: Figma draws those with its own fallback glyph, and the blank nodes held an old layout \u2014 `width` 7, the missing-glyph advance, where drawn ones measured 9 for `\u2318`/`\u23CE` and 8 for `\u21E7`. Switching the font does not help: a font whose file does contain `\u2318` and `\u23CE` still drew the same fallback glyph, and writing that font on the key text inside the masters' nested instances spread wrongly \u2014 145 of the file's 301 keys came out wrong, including keys with no symbol at all. What brought the glyph back was a fresh layout, reached the long way: on the five keys that had been given that font, `removeOverrides()` (which also returned the TEXT property to the main's default), then restoring the layer name and writing the property back to its symbol \u2014 the glyph appeared and the five keys grew 1\u20132 px. Writing the same value again without clearing first was not tried. Before blaming the font, compare the node's `width` with a node that renders; to repair, clear the overrides, write the text property back, and re-read the width."
603
+ },
604
+ {
605
+ pattern: null,
606
+ hint: "",
607
+ doc: "**`remove()` on a COMPONENT leaves its id resolving, so a `null` lookup cannot confirm the cleanup.** After removing a component \u2014 standalone, or a variant removed along with its set \u2014 `getNodeByIdAsync(id)` still returns it, children included, with `parent === null` and `removed === false`; calling `remove()` on it again returns normally and changes nothing. Measured on four components removed in the call that created them; the COMPONENT_SET itself, and plain frames, text and rectangles removed the same way, did resolve to `null`. The component sits on no page and no page's children list it, but id sweeps find it; whether it goes away later has not been measured. Confirm component cleanup by ancestry (the chain must no longer reach a PAGE) rather than `null`, and build temporary probes from frames or instances of existing components rather than new components."
608
+ },
609
+ {
610
+ pattern: null,
611
+ hint: "",
612
+ doc: "**Renaming a variant axis with `editComponentProperty` unbinds the instances, and can reset their plain overrides too.** Instances bound to that property keep their value but lose the `boundVariables.componentProperties` alias, nested explicit overrides included. In one measured rename, 71 instances whose value for the axis was a plain override \u2014 not bound to anything \u2014 fell back to the set's default variant. Right after rebinding, some instances kept an old value that disagreed with what the mode resolves to; writing the value first and then rebinding fixed them. Before renaming an axis, record every instance's value and binding for it; afterwards write the values back, rebind, and read both back."
613
+ },
614
+ {
615
+ pattern: null,
616
+ hint: "",
617
+ doc: "**`figma.skipInvisibleInstanceChildren` is plugin-wide, not per script.** Setting it to `true` \u2014 a common speed-up for `findAll` \u2014 hides invisible sublayers of instances from every search that runs while it is set, not only the script that set it. Relai puts the previous value back after each `execute_figma` script, so it does not leak into later commands; set it again in every script that wants it, and while it is on, do not read an instance sublayer missing from a search as deleted."
606
618
  }
607
619
  ];
608
620
 
621
+ // ../shared/src/types/call.ts
622
+ var ERROR_CODES = [
623
+ // Server: reaching the relay and the plugin
624
+ "not_connected",
625
+ // no live link to the relay when the call was made; nothing was sent
626
+ "relay_unresponsive",
627
+ // the relay did not answer a room listing or join in time; nothing changed
628
+ "plugin_absent",
629
+ // no Figma plugin is in the target room
630
+ "room_ambiguous",
631
+ // several plugins are open and the call did not say which room
632
+ "room_changed",
633
+ // the session was re-paired to a room on a different file; resend to confirm
634
+ "wrong_file",
635
+ // the target file's name is not the expected one (server precheck or plugin recheck)
636
+ "cannot_verify_file",
637
+ // an expected file was given but the plugin did not report its file name
638
+ // Plugin: refused before the command ran
639
+ "unknown_command",
640
+ // the plugin has no handler for the command (plugin older than the server)
641
+ "denied_by_designer",
642
+ // the designer declined the approval card
643
+ "approval_timeout",
644
+ // nobody answered the approval card before it timed out
645
+ "cancelled",
646
+ // Stop pressed before the command started or while its card waited; also skipped batch items
647
+ "guard_blocked",
648
+ // the write touches a page the designer marked as a no-go zone
649
+ "scope_locked",
650
+ // the write falls outside the designer's scope lock
651
+ "designer_only",
652
+ // the command is reserved for the designer's panel
653
+ "code_exec_disabled",
654
+ // code execution is switched off in the panel
655
+ "node_not_found",
656
+ // a referenced node does not exist (checked before any change)
657
+ "precondition_failed",
658
+ // a referenced node has the wrong type or is locked (checked before any change)
659
+ "capacity_exceeded",
660
+ // file memory (precedents or rulesets) is full
661
+ "invalid_params",
662
+ // the arguments are malformed (from the plugin or the server's own checks)
663
+ "script_compile_error",
664
+ // the script did not compile, so none of it ran
665
+ // Plugin: failed while running
666
+ "script_error",
667
+ // the script threw while running; scripts are not atomic, earlier changes stay
668
+ "handler_error",
669
+ // any other error thrown by a command handler
670
+ "figma_internal",
671
+ // Figma itself reported an internal error (it reads like a connection error but is not one)
672
+ // Server: lost track of a request in flight
673
+ "timeout",
674
+ // no result arrived in time; the command may have run
675
+ "link_dropped",
676
+ // the link to the plugin closed while the request was in flight
677
+ "cancel_unconfirmed",
678
+ // Stop was pressed but no real result followed in time
679
+ // Server: tools that do not go through the plugin
680
+ "tool_error"
681
+ // a tool that talks to Figma's REST API or local state failed
682
+ ];
683
+ function isErrorCode(value) {
684
+ return typeof value === "string" && ERROR_CODES.includes(value);
685
+ }
686
+ var ERROR_DEFAULTS = {
687
+ not_connected: { outcome: "not_applied", retry: "retryable" },
688
+ relay_unresponsive: { outcome: "not_applied", retry: "retryable" },
689
+ plugin_absent: { outcome: "not_applied", retry: "needs_human" },
690
+ room_ambiguous: { outcome: "not_applied", retry: "fix_input" },
691
+ room_changed: { outcome: "not_applied", retry: "retryable" },
692
+ wrong_file: { outcome: "not_applied", retry: "fix_input" },
693
+ cannot_verify_file: { outcome: "not_applied", retry: "fix_input" },
694
+ unknown_command: { outcome: "not_applied", retry: "needs_human" },
695
+ denied_by_designer: { outcome: "not_applied", retry: "needs_human" },
696
+ approval_timeout: { outcome: "not_applied", retry: "needs_human" },
697
+ cancelled: { outcome: "not_applied", retry: "needs_human" },
698
+ guard_blocked: { outcome: "not_applied", retry: "needs_human" },
699
+ scope_locked: { outcome: "not_applied", retry: "needs_human" },
700
+ designer_only: { outcome: "not_applied", retry: "needs_human" },
701
+ code_exec_disabled: { outcome: "not_applied", retry: "needs_human" },
702
+ node_not_found: { outcome: "not_applied", retry: "read_canvas_first" },
703
+ precondition_failed: { outcome: "not_applied", retry: "fix_input" },
704
+ capacity_exceeded: { outcome: "not_applied", retry: "fix_input" },
705
+ invalid_params: { outcome: "not_applied", retry: "fix_input" },
706
+ script_compile_error: { outcome: "not_applied", retry: "fix_input" },
707
+ script_error: { outcome: "unknown", retry: "read_canvas_first" },
708
+ handler_error: { outcome: "unknown", retry: "read_canvas_first" },
709
+ figma_internal: { outcome: "unknown", retry: "read_canvas_first" },
710
+ timeout: { outcome: "unknown", retry: "read_canvas_first" },
711
+ link_dropped: { outcome: "unknown", retry: "read_canvas_first" },
712
+ cancel_unconfirmed: { outcome: "unknown", retry: "read_canvas_first" },
713
+ tool_error: { outcome: "not_applied", retry: "fix_input" }
714
+ };
715
+ var CATEGORY_CODES = {
716
+ precondition_failed: { code: "precondition_failed" },
717
+ invalid_input: { code: "invalid_params" },
718
+ type_mismatch: { code: "precondition_failed" },
719
+ partial_failure: { code: "tool_error", outcome: "applied", retry: "read_canvas_first", partial: true },
720
+ state_changed: { code: "tool_error", outcome: "not_applied", retry: "read_canvas_first" },
721
+ capability_limit: { code: "capacity_exceeded" },
722
+ connection_error: { code: "not_connected" }
723
+ };
724
+ var RelaiError = class extends Error {
725
+ code;
726
+ outcome;
727
+ retry;
728
+ partial;
729
+ constructor(code, message, options = {}) {
730
+ super(message);
731
+ this.name = "RelaiError";
732
+ this.code = code;
733
+ this.outcome = options.outcome ?? ERROR_DEFAULTS[code].outcome;
734
+ this.retry = options.retry ?? ERROR_DEFAULTS[code].retry;
735
+ if (options.partial) this.partial = true;
736
+ }
737
+ };
738
+ var PROTOCOL_VERSION = 2;
739
+ var CALL_META_KEY = "io.github.syoooo.figma-relai/call";
740
+ function aggregateOutcome(outcomes) {
741
+ if (outcomes.includes("unknown")) return { outcome: "unknown" };
742
+ if (outcomes.includes("applied")) {
743
+ return outcomes.includes("not_applied") ? { outcome: "applied", partial: true } : { outcome: "applied" };
744
+ }
745
+ return { outcome: "not_applied" };
746
+ }
747
+ function inferLegacyCode(error) {
748
+ const message = typeof error === "string" ? error : error?.message ?? "";
749
+ const pick = (code) => ({ code, ...ERROR_DEFAULTS[code] });
750
+ if (message.includes("no-go zone")) return pick("guard_blocked");
751
+ if (message.includes("Scope lock:")) return pick("scope_locked");
752
+ if (message.includes("Code execution is disabled")) return pick("code_exec_disabled");
753
+ if (message.includes("Node not found") || / has been deleted\. /.test(message)) {
754
+ return pick("node_not_found");
755
+ }
756
+ if (message.includes("Denied by designer")) return pick("denied_by_designer");
757
+ if (typeof error !== "string" && error?.cancelled === true && message.includes("Cancelled by designer")) {
758
+ return pick("cancel_unconfirmed");
759
+ }
760
+ if (message.includes("Unknown command")) return pick("unknown_command");
761
+ return pick("handler_error");
762
+ }
763
+
609
764
  // ../shared/src/utils/color.ts
610
765
  function rgbaToHex(color) {
611
766
  const r = Math.round(color.r * 255);
@@ -696,6 +851,9 @@ var reorderDirectionSchema = z.enum([
696
851
  ]);
697
852
 
698
853
  // ../shared/src/relay-core.ts
854
+ function isAllowedOrigin(origin) {
855
+ return origin === void 0 || origin === null || origin === "" || origin === "null";
856
+ }
699
857
  var RelayCore = class {
700
858
  rooms = /* @__PURE__ */ new Map();
701
859
  lastActivity = /* @__PURE__ */ new Map();
@@ -880,105 +1038,298 @@ var RelayCore = class {
880
1038
  }
881
1039
  };
882
1040
 
883
- // src/tools/core/file-identity.ts
884
- function addressableKey(identity) {
885
- return identity.branchKey ?? identity.fileKey;
1041
+ // src/version.ts
1042
+ var VERSION = "0.8.0";
1043
+
1044
+ // src/tools/index.ts
1045
+ import { z as z24 } from "zod";
1046
+
1047
+ // src/tools/core/helpers.ts
1048
+ function textResult(text) {
1049
+ return { content: [{ type: "text", text }] };
886
1050
  }
887
- function describeIdentity(identity) {
888
- return identity.branchName ? `${identity.fileName} (branch "${identity.branchName}")` : identity.fileName;
1051
+ function jsonResult(value) {
1052
+ return textResult(typeof value === "string" ? value : JSON.stringify(value, null, 2));
889
1053
  }
890
- async function figmaApi(token, path) {
891
- const res = await fetch(`https://api.figma.com/v1${path}`, {
892
- headers: { "X-Figma-Token": token }
893
- });
894
- noteAuthFailure(res.status);
895
- const json = await res.json().catch(() => ({}));
896
- return { ok: res.ok, status: res.status, json };
1054
+ function errorResult(error) {
1055
+ const code = error instanceof RelaiError ? error.code : "tool_error";
1056
+ const retry = error instanceof RelaiError ? error.retry : ERROR_DEFAULTS.tool_error.retry;
1057
+ const meta = { v: 1, error: { code, retry }, calls: [] };
1058
+ return {
1059
+ ...textResult(`Error: ${error instanceof Error ? error.message : String(error)}`),
1060
+ isError: true,
1061
+ _meta: { [CALL_META_KEY]: meta }
1062
+ };
897
1063
  }
898
- var FileIdentityError = class extends Error {
899
- };
900
- async function resolveFileIdentity(sendCommand) {
901
- const probe = await sendCommand("get_file_identity", {});
902
- const cached = probe?.identity?.fileKey ? probe.identity : null;
903
- if (cached && cached.by === VERSION) return cached;
904
- const rootName = probe?.rootName ?? "";
905
- const candidates = probe?.candidates ?? [];
906
- if (candidates.length === 0) {
907
- if (cached) return cached;
908
- throw new FileIdentityError(
909
- "This file publishes no components, so its key cannot be resolved from the plugin (figma.fileKey is private-plugin-only)."
910
- );
1064
+
1065
+ // src/call-envelope.ts
1066
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
1067
+ import { z as z2 } from "zod";
1068
+
1069
+ // src/call-scope.ts
1070
+ import { AsyncLocalStorage } from "async_hooks";
1071
+ var slots = new AsyncLocalStorage();
1072
+ function runInCommandSlot(slot, fn) {
1073
+ return slots.run(slot, fn);
1074
+ }
1075
+ function currentCommandSlot() {
1076
+ return slots.getStore();
1077
+ }
1078
+ function noteSettled(slot, settled) {
1079
+ if (slot && !slot.closed) slot.settled = settled;
1080
+ }
1081
+
1082
+ // src/result-files.ts
1083
+ import { mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "fs";
1084
+ import { join } from "path";
1085
+ import { randomUUID } from "crypto";
1086
+ var KEEP_FILES = 50;
1087
+ var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
1088
+ function resultsDir() {
1089
+ return join(stateDir(), "results");
1090
+ }
1091
+ function writeResultFile(callId, text) {
1092
+ try {
1093
+ const dir = resultsDir();
1094
+ mkdirSync(dir, { recursive: true, mode: 448 });
1095
+ pruneResultFiles(dir, KEEP_FILES - 1);
1096
+ const id = (callId ?? "").replace(/[^A-Za-z0-9_-]/g, "") || randomUUID();
1097
+ const path = join(dir, `${Date.now()}-${id}.${looksLikeJson(text) ? "json" : "txt"}`);
1098
+ writeFileSync(path, text, { mode: 384 });
1099
+ return path;
1100
+ } catch {
1101
+ return void 0;
911
1102
  }
912
- const token = loadToken();
913
- if (!token) {
914
- throw new FileIdentityError(
915
- "Resolving the file key needs a Figma token \u2014 store one with `npx -y figma-relai@latest login`."
916
- );
1103
+ }
1104
+ function pruneResultFiles(dir = resultsDir(), keep = KEEP_FILES) {
1105
+ const now = Date.now();
1106
+ const files = readdirSync(dir).filter((name) => name.endsWith(".json") || name.endsWith(".txt")).map((name) => ({ path: join(dir, name), mtime: statSync(join(dir, name)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
1107
+ files.forEach((file, index) => {
1108
+ if (index >= keep || now - file.mtime > MAX_AGE_MS) {
1109
+ try {
1110
+ unlinkSync(file.path);
1111
+ } catch {
1112
+ }
1113
+ }
1114
+ });
1115
+ }
1116
+ function looksLikeJson(text) {
1117
+ try {
1118
+ JSON.parse(text);
1119
+ return true;
1120
+ } catch {
1121
+ return false;
917
1122
  }
918
- let main = null;
919
- const statuses = [];
920
- for (const c of candidates) {
921
- const paths = c.kind === "set" ? ["/component_sets/", "/components/"] : ["/components/", "/component_sets/"];
922
- for (const p of paths) {
923
- const res = await figmaApi(token, `${p}${c.key}`);
924
- statuses.push(res.status);
925
- const meta = res.json.meta ?? {};
926
- if (res.ok && typeof meta.file_key === "string") {
927
- main = { fileKey: meta.file_key, via: c.key };
928
- break;
1123
+ }
1124
+
1125
+ // src/call-envelope.ts
1126
+ var MAX_TEXT_CHARS = 5e4;
1127
+ var RECEIPT_LABEL = "[relai]";
1128
+ var scopes = new AsyncLocalStorage2();
1129
+ var WRAPPED = /* @__PURE__ */ Symbol("relai.callEnvelope");
1130
+ var ENVELOPED_SERVER = /* @__PURE__ */ Symbol("relai.envelopedServer");
1131
+ var TARGET_SHAPE = {
1132
+ room: z2.string().optional().describe("Target this plugin room for this call only (see join_room)."),
1133
+ expect_file: z2.string().optional().describe("Refuse unless the target file's name is exactly this.")
1134
+ };
1135
+ function currentCallTarget() {
1136
+ const scope = scopes.getStore();
1137
+ return {
1138
+ ...scope?.room !== void 0 ? { room: scope.room } : {},
1139
+ ...scope?.expectFile !== void 0 ? { expectFile: scope.expectFile } : {}
1140
+ };
1141
+ }
1142
+ function withCallEnvelope(server, sendCommand) {
1143
+ if (server[ENVELOPED_SERVER]) return { server, sendCommand };
1144
+ const wrappedServer = new Proxy(server, {
1145
+ get(target, prop, receiver) {
1146
+ if (prop === ENVELOPED_SERVER) return true;
1147
+ if (prop !== "tool") return Reflect.get(target, prop, receiver);
1148
+ return (name, ...rest) => {
1149
+ if (rest.length !== 3 && rest.length !== 4 || typeof rest[0] !== "string") {
1150
+ throw new Error(
1151
+ `Tool "${name}" must be registered as (name, description, shape, [annotations,] handler) to get a call envelope`
1152
+ );
1153
+ }
1154
+ const handler = rest[rest.length - 1];
1155
+ const annotations = rest.length === 4 ? rest[2] : void 0;
1156
+ const wrapped = envelopeHandler(name, handler, annotations?.readOnlyHint === true);
1157
+ const shape = { ...TARGET_SHAPE, ...rest[1] };
1158
+ return target.tool.call(target, name, rest[0], shape, ...rest.slice(2, -1), wrapped);
1159
+ };
1160
+ }
1161
+ });
1162
+ const wrappedSend = async (command, params, timeoutMs) => {
1163
+ const scope = scopes.getStore();
1164
+ const slot = { closed: false };
1165
+ try {
1166
+ const result = await runInCommandSlot(slot, () => sendCommand(command, params, timeoutMs));
1167
+ slot.closed = true;
1168
+ if (scope) recordSuccess(scope, command, slot, result);
1169
+ return result;
1170
+ } catch (error) {
1171
+ slot.closed = true;
1172
+ const coded = toRelaiError(error);
1173
+ if (scope) {
1174
+ scope.receipts.push({ id: slot.settled?.id ?? "", command, outcome: coded.outcome, code: coded.code });
1175
+ if (coded.partial) scope.partial = true;
1176
+ if (slot.settled?.cancelRequested) scope.cancelRequested = true;
1177
+ scope.lastError = coded;
929
1178
  }
1179
+ throw coded;
1180
+ }
1181
+ };
1182
+ return { server: wrappedServer, sendCommand: wrappedSend };
1183
+ }
1184
+ function envelopeHandler(name, handler, readOnly) {
1185
+ const wrapped = async (...args) => {
1186
+ const scope = { receipts: [], partial: false, cancelRequested: false };
1187
+ const { room, expect_file: expectFile, ...own } = args[0] ?? {};
1188
+ if (typeof room === "string") scope.room = room;
1189
+ if (typeof expectFile === "string") scope.expectFile = expectFile;
1190
+ const toolArgs = args[0] === void 0 ? args : [own, ...args.slice(1)];
1191
+ let result;
1192
+ try {
1193
+ result = await scopes.run(scope, () => Promise.resolve(handler(...toolArgs)));
1194
+ } catch (error) {
1195
+ result = errorResult(error);
1196
+ }
1197
+ return finalizeResult(name, result, scope, readOnly);
1198
+ };
1199
+ wrapped[WRAPPED] = true;
1200
+ return wrapped;
1201
+ }
1202
+ function toRelaiError(error) {
1203
+ if (error instanceof RelaiError) return error;
1204
+ const message = error instanceof Error ? error.message : String(error);
1205
+ const { code, outcome, retry } = inferLegacyCode(message);
1206
+ return new RelaiError(code, message, { outcome, retry });
1207
+ }
1208
+ function recordSuccess(scope, command, slot, result) {
1209
+ const meta = slot.settled?.meta;
1210
+ const receipt = { id: slot.settled?.id ?? "", command, outcome: meta?.outcome ?? "applied" };
1211
+ if (typeof meta?.write === "boolean") receipt.write = meta.write;
1212
+ if (meta?.partial) scope.partial = true;
1213
+ if (meta?.file) scope.file = meta.file;
1214
+ if (slot.settled?.cancelRequested) scope.cancelRequested = true;
1215
+ if (command === "batch_execute" && Array.isArray(result)) {
1216
+ receipt.items = result.map(batchItem);
1217
+ if (!meta) {
1218
+ const judged = aggregateOutcome(receipt.items.map((item) => item.outcome));
1219
+ receipt.outcome = judged.outcome;
1220
+ if (judged.partial) scope.partial = true;
930
1221
  }
931
- if (main) break;
932
1222
  }
933
- if (!main) {
934
- const denied = statuses.length > 0 && statuses.every((s) => s === 401 || s === 403);
935
- throw new FileIdentityError(
936
- 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."
937
- );
1223
+ scope.receipts.push(receipt);
1224
+ }
1225
+ function batchItem(raw) {
1226
+ const item = raw ?? {};
1227
+ const command = typeof item.command === "string" ? item.command : "";
1228
+ const code = isErrorCode(item.code) ? item.code : void 0;
1229
+ const stated = item.outcome === "applied" || item.outcome === "not_applied" || item.outcome === "unknown" ? item.outcome : void 0;
1230
+ if (item.success === true) return { command, outcome: stated ?? "applied" };
1231
+ if (code) return { command, outcome: stated ?? ERROR_DEFAULTS[code].outcome, code };
1232
+ const legacy = inferLegacyCode(typeof item.error === "string" ? item.error : "");
1233
+ return { command, outcome: stated ?? legacy.outcome, code: legacy.code };
1234
+ }
1235
+ function finalizeResult(name, result, scope, readOnly) {
1236
+ const declared = result._meta?.[CALL_META_KEY] ?? void 0;
1237
+ const content = [...result.content ?? []];
1238
+ const bodyIndex = content.findIndex((item) => item.type === "text" && typeof item.text === "string");
1239
+ const body = bodyIndex >= 0 ? content[bodyIndex].text : void 0;
1240
+ const receipts = scope.receipts;
1241
+ const last = receipts[receipts.length - 1];
1242
+ let isError = result.isError === true;
1243
+ if (!isError && last?.code && body?.startsWith("Error: ")) isError = true;
1244
+ let error = declared?.error;
1245
+ if (isError && last?.code && scope.lastError) {
1246
+ error = { code: scope.lastError.code, retry: scope.lastError.retry };
1247
+ } else if (isError && !error) {
1248
+ error = { code: "tool_error", retry: ERROR_DEFAULTS.tool_error.retry };
938
1249
  }
939
- const file = await figmaApi(token, `/files/${main.fileKey}?branch_data=true&depth=1`);
940
- const identity = {
941
- fileKey: main.fileKey,
942
- fileName: typeof file.json.name === "string" ? file.json.name : rootName,
943
- by: VERSION
944
- };
945
- if (rootName && rootName !== identity.fileName) {
946
- const branches = file.json.branches ?? [];
947
- const hit = branches.find((b) => b.name === rootName);
948
- if (hit?.key) {
949
- identity.branchKey = hit.key;
950
- identity.branchName = hit.name;
1250
+ const isRead = (r) => r.write === false || r.write === void 0 && readOnly;
1251
+ const calls = receipts.map(
1252
+ (r) => isRead(r) && r.outcome !== "applied" ? { ...r, outcome: "not_applied" } : r
1253
+ );
1254
+ let outcome;
1255
+ let partial = scope.partial;
1256
+ if (calls.length > 0) {
1257
+ const writes = calls.filter((r) => !isRead(r));
1258
+ if (writes.length > 0) {
1259
+ const judged = aggregateOutcome(writes.map((r) => r.outcome));
1260
+ outcome = judged.outcome;
1261
+ partial = partial || judged.partial === true;
1262
+ } else {
1263
+ outcome = !isError && calls.some((r) => r.outcome === "applied") ? "applied" : "not_applied";
951
1264
  }
952
1265
  }
953
- try {
954
- await sendCommand("set_file_identity", { identity, componentKey: main.via });
955
- } catch {
1266
+ if (declared?.outcome) {
1267
+ outcome = outcome === "unknown" ? "unknown" : declared.outcome;
1268
+ partial = partial || declared.partial === true;
956
1269
  }
957
- return identity;
1270
+ if (error && outcome === "unknown") error = { ...error, retry: "read_canvas_first" };
1271
+ let truncated = declared?.truncated;
1272
+ if (body !== void 0 && body.length > MAX_TEXT_CHARS) {
1273
+ const whole = !truncated;
1274
+ const file = truncated?.file ?? (whole ? writeResultFile(last?.id, body) : void 0);
1275
+ const cut = {
1276
+ total: whole ? body.length : truncated.total,
1277
+ returned: MAX_TEXT_CHARS,
1278
+ unit: "chars",
1279
+ ...file ? { file } : {}
1280
+ };
1281
+ truncated = cut;
1282
+ content[bodyIndex] = {
1283
+ ...content[bodyIndex],
1284
+ text: `${body.slice(0, MAX_TEXT_CHARS)}
1285
+ \u2026 [truncated: returned ${cut.returned} of ${cut.total} chars; ` + (file ? `the full result is in ${file}]` : "the full result could not be saved]")
1286
+ };
1287
+ }
1288
+ const meta = {
1289
+ v: 1,
1290
+ ...outcome ? { outcome } : {},
1291
+ ...outcome && partial ? { partial: true } : {},
1292
+ ...error ? { error } : {},
1293
+ calls,
1294
+ ...scope.file || scope.room ? { target: { ...scope.room ? { room: scope.room } : {}, ...scope.file ? { file: scope.file } : {} } } : {},
1295
+ ...truncated ? { truncated } : {}
1296
+ };
1297
+ const wrote = calls.some((r) => !isRead(r));
1298
+ const showReceipt = isError || outcome === "unknown" || partial || scope.room !== void 0 || scope.expectFile !== void 0 || wrote && outcome !== void 0;
1299
+ if (showReceipt) {
1300
+ content.push({ type: "text", text: receiptLine(meta, last?.id, scope.cancelRequested) });
1301
+ }
1302
+ const { _meta: existingMeta, ...rest } = result;
1303
+ return {
1304
+ ...rest,
1305
+ content,
1306
+ ...isError ? { isError: true } : {},
1307
+ _meta: { ...existingMeta ?? {}, [CALL_META_KEY]: meta }
1308
+ };
1309
+ }
1310
+ var OUTCOME_WORDS = {
1311
+ applied: "applied \u2014 the command ran",
1312
+ not_applied: "not_applied \u2014 nothing was changed",
1313
+ unknown: "unknown \u2014 it may have changed the file; read the canvas before retrying"
1314
+ };
1315
+ function receiptLine(meta, callId, cancelRequested) {
1316
+ const parts = [
1317
+ meta.outcome ? `${RECEIPT_LABEL} ${OUTCOME_WORDS[meta.outcome]}${meta.partial ? " (partly: some of it was written)" : ""}` : `${RECEIPT_LABEL} failed`
1318
+ ];
1319
+ if (meta.error) parts.push(meta.error.code);
1320
+ if (cancelRequested) parts.push("the designer pressed Stop");
1321
+ if (callId) parts.push(`call ${callId.slice(0, 8)}`);
1322
+ if (meta.target?.file) parts.push(`file "${meta.target.file}"`);
1323
+ if (meta.target?.room) parts.push(`room ${meta.target.room}`);
1324
+ return parts.join(" \xB7 ");
958
1325
  }
959
-
960
- // src/tools/index.ts
961
- import { z as z23 } from "zod";
962
1326
 
963
1327
  // src/tools/core/create.ts
964
1328
  var create_exports = {};
965
1329
  __export(create_exports, {
966
1330
  register: () => register
967
1331
  });
968
- import { z as z2 } from "zod";
969
-
970
- // src/tools/core/helpers.ts
971
- function textResult(text) {
972
- return { content: [{ type: "text", text }] };
973
- }
974
- function jsonResult(value) {
975
- return textResult(typeof value === "string" ? value : JSON.stringify(value, null, 2));
976
- }
977
- function errorResult(error) {
978
- return textResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
979
- }
980
-
981
- // src/tools/core/create.ts
1332
+ import { z as z3 } from "zod";
982
1333
  var NODE_KINDS = [
983
1334
  "rectangle",
984
1335
  "frame",
@@ -1018,33 +1369,33 @@ function register(server, sendCommand) {
1018
1369
  "create_node",
1019
1370
  "Create a node in Figma: rectangle, frame, text, ellipse, polygon, star, line, section, slice, svg (from markup), image (from URL), or FigJam connector/table/sticky/code_block. Returns the new node's id \u2014 style it further with set_properties. For component instances use manage_components.",
1020
1371
  {
1021
- type: z2.enum(NODE_KINDS).describe("What to create"),
1022
- x: z2.number().optional().describe("X position (default 0)"),
1023
- y: z2.number().optional().describe("Y position (default 0)"),
1372
+ type: z3.enum(NODE_KINDS).describe("What to create"),
1373
+ x: z3.number().optional().describe("X position (default 0)"),
1374
+ y: z3.number().optional().describe("Y position (default 0)"),
1024
1375
  width: dimensionSchema.optional(),
1025
1376
  height: dimensionSchema.optional(),
1026
- name: z2.string().optional(),
1027
- parentId: z2.string().optional().describe("Parent node id (default: current page)"),
1028
- text: z2.string().optional().describe("Text content (text/sticky)"),
1029
- fontSize: z2.number().positive().optional().describe("text only"),
1030
- fontWeight: z2.number().optional().describe("text only (e.g. 400, 700)"),
1377
+ name: z3.string().optional(),
1378
+ parentId: z3.string().optional().describe("Parent node id (default: current page)"),
1379
+ text: z3.string().optional().describe("Text content (text/sticky)"),
1380
+ fontSize: z3.number().positive().optional().describe("text only"),
1381
+ fontWeight: z3.number().optional().describe("text only (e.g. 400, 700)"),
1031
1382
  fontColor: colorSchema.optional().describe("text only"),
1032
1383
  fillColor: colorSchema.optional().describe("frame/rectangle/section fill"),
1033
1384
  strokeColor: colorSchema.optional().describe("frame/rectangle/section stroke"),
1034
- cornerRadius: z2.number().min(0).optional().describe("section corner radius \u2014 a new section defaults to radius 2 with a 10% black stroke, which matches no design system; pass what yours uses (for other node types, set radius with set_properties)"),
1035
- pointCount: z2.number().int().min(3).optional().describe("polygon/star points"),
1036
- innerRadius: z2.number().min(0).max(1).optional().describe("star inner radius ratio"),
1037
- length: z2.number().positive().optional().describe("line length"),
1038
- rotation: z2.number().optional().describe("line rotation in degrees"),
1039
- strokeWeight: z2.number().min(0).optional().describe("line stroke weight"),
1040
- svg: z2.string().optional().describe("SVG markup (type=svg)"),
1041
- url: z2.string().optional().describe("Image URL (type=image)"),
1042
- scaleMode: z2.enum(["FILL", "FIT", "CROP", "TILE"]).optional().describe("image only"),
1043
- rows: z2.number().int().positive().optional().describe("table only"),
1044
- cols: z2.number().int().positive().optional().describe("table only"),
1045
- startNodeId: z2.string().optional().describe("connector only"),
1046
- endNodeId: z2.string().optional().describe("connector only"),
1047
- lineType: z2.enum(["ELBOWED", "STRAIGHT"]).optional().describe("connector only")
1385
+ cornerRadius: z3.number().min(0).optional().describe("section corner radius \u2014 a new section defaults to radius 2 with a 10% black stroke, which matches no design system; pass what yours uses (for other node types, set radius with set_properties)"),
1386
+ pointCount: z3.number().int().min(3).optional().describe("polygon/star points"),
1387
+ innerRadius: z3.number().min(0).max(1).optional().describe("star inner radius ratio"),
1388
+ length: z3.number().positive().optional().describe("line length"),
1389
+ rotation: z3.number().optional().describe("line rotation in degrees"),
1390
+ strokeWeight: z3.number().min(0).optional().describe("line stroke weight"),
1391
+ svg: z3.string().optional().describe("SVG markup (type=svg)"),
1392
+ url: z3.string().optional().describe("Image URL (type=image)"),
1393
+ scaleMode: z3.enum(["FILL", "FIT", "CROP", "TILE"]).optional().describe("image only"),
1394
+ rows: z3.number().int().positive().optional().describe("table only"),
1395
+ cols: z3.number().int().positive().optional().describe("table only"),
1396
+ startNodeId: z3.string().optional().describe("connector only"),
1397
+ endNodeId: z3.string().optional().describe("connector only"),
1398
+ lineType: z3.enum(["ELBOWED", "STRAIGHT"]).optional().describe("connector only")
1048
1399
  },
1049
1400
  async ({ type, ...params }) => {
1050
1401
  try {
@@ -1067,69 +1418,69 @@ __export(properties_exports, {
1067
1418
  propertiesSchema: () => propertiesSchema,
1068
1419
  register: () => register2
1069
1420
  });
1070
- import { z as z3 } from "zod";
1071
- var effectSchema = z3.object({
1072
- type: z3.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]),
1421
+ import { z as z4 } from "zod";
1422
+ var effectSchema = z4.object({
1423
+ type: z4.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]),
1073
1424
  color: colorSchema.optional().describe("Shadow color (shadows only)"),
1074
- offsetX: z3.number().optional(),
1075
- offsetY: z3.number().optional(),
1076
- radius: z3.number().min(0).optional().describe("Blur radius"),
1077
- spread: z3.number().optional()
1425
+ offsetX: z4.number().optional(),
1426
+ offsetY: z4.number().optional(),
1427
+ radius: z4.number().min(0).optional().describe("Blur radius"),
1428
+ spread: z4.number().optional()
1078
1429
  });
1079
- var propertiesSchema = z3.object({
1430
+ var propertiesSchema = z4.object({
1080
1431
  // Geometry
1081
- x: z3.number().optional(),
1082
- y: z3.number().optional(),
1432
+ x: z4.number().optional(),
1433
+ y: z4.number().optional(),
1083
1434
  width: dimensionSchema.optional(),
1084
1435
  height: dimensionSchema.optional(),
1085
- rotation: z3.number().optional().describe("Degrees"),
1436
+ rotation: z4.number().optional().describe("Degrees"),
1086
1437
  // Identity / visibility
1087
- name: z3.string().optional(),
1088
- visible: z3.boolean().optional(),
1089
- locked: z3.boolean().optional(),
1090
- opacity: z3.number().min(0).max(1).optional(),
1438
+ name: z4.string().optional(),
1439
+ visible: z4.boolean().optional(),
1440
+ locked: z4.boolean().optional(),
1441
+ opacity: z4.number().min(0).max(1).optional(),
1091
1442
  blendMode: blendModeSchema.optional(),
1092
1443
  // Paint
1093
1444
  fillColor: colorSchema.optional(),
1094
- fills: z3.array(z3.record(z3.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole fill list; [] clears all fills"),
1095
- strokes: z3.array(z3.record(z3.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole stroke list; [] clears all strokes"),
1445
+ fills: z4.array(z4.record(z4.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole fill list; [] clears all fills"),
1446
+ strokes: z4.array(z4.record(z4.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole stroke list; [] clears all strokes"),
1096
1447
  strokeColor: colorSchema.optional(),
1097
- strokeWeight: z3.number().min(0).optional(),
1098
- cornerRadius: z3.number().min(0).optional(),
1099
- corners: z3.array(z3.boolean()).length(4).optional().describe("With cornerRadius: apply per corner [TL, TR, BR, BL]"),
1100
- gradient: z3.object({ gradientType: gradientTypeSchema, stops: z3.array(gradientStopSchema).min(2) }).optional(),
1448
+ strokeWeight: z4.number().min(0).optional(),
1449
+ cornerRadius: z4.number().min(0).optional(),
1450
+ corners: z4.array(z4.boolean()).length(4).optional().describe("With cornerRadius: apply per corner [TL, TR, BR, BL]"),
1451
+ gradient: z4.object({ gradientType: gradientTypeSchema, stops: z4.array(gradientStopSchema).min(2) }).optional(),
1101
1452
  // Effects (replaces existing effects list entry of same type)
1102
- effects: z3.array(effectSchema).optional(),
1103
- removeEffects: z3.boolean().optional(),
1453
+ effects: z4.array(effectSchema).optional(),
1454
+ removeEffects: z4.boolean().optional(),
1104
1455
  // Text
1105
- text: z3.string().optional().describe("Text content (TEXT nodes)"),
1456
+ text: z4.string().optional().describe("Text content (TEXT nodes)"),
1106
1457
  // Auto-layout (frame itself)
1107
1458
  layoutMode: layoutModeSchema.optional(),
1108
1459
  layoutWrap: layoutWrapSchema.optional().describe("WRAP requires layoutMode HORIZONTAL"),
1109
- paddingTop: z3.number().min(0).optional(),
1110
- paddingRight: z3.number().min(0).optional(),
1111
- paddingBottom: z3.number().min(0).optional(),
1112
- paddingLeft: z3.number().min(0).optional(),
1460
+ paddingTop: z4.number().min(0).optional(),
1461
+ paddingRight: z4.number().min(0).optional(),
1462
+ paddingBottom: z4.number().min(0).optional(),
1463
+ paddingLeft: z4.number().min(0).optional(),
1113
1464
  primaryAxisAlignItems: primaryAxisAlignSchema.optional(),
1114
1465
  counterAxisAlignItems: counterAxisAlignSchema.optional(),
1115
- itemSpacing: z3.number().optional(),
1116
- counterAxisSpacing: z3.number().optional(),
1466
+ itemSpacing: z4.number().optional(),
1467
+ counterAxisSpacing: z4.number().optional(),
1117
1468
  // Auto-layout (as a child; FILL requires the PARENT to have auto-layout)
1118
1469
  layoutSizingHorizontal: sizingModeSchema.optional(),
1119
1470
  layoutSizingVertical: sizingModeSchema.optional(),
1120
1471
  // Constraints & sizing limits
1121
1472
  constraintsHorizontal: constraintTypeSchema.optional(),
1122
1473
  constraintsVertical: constraintTypeSchema.optional(),
1123
- minWidth: z3.number().min(0).optional(),
1124
- maxWidth: z3.number().min(0).optional(),
1125
- minHeight: z3.number().min(0).optional(),
1126
- maxHeight: z3.number().min(0).optional(),
1127
- aspectRatioLocked: z3.boolean().optional(),
1128
- clipsContent: z3.boolean().optional(),
1474
+ minWidth: z4.number().min(0).optional(),
1475
+ maxWidth: z4.number().min(0).optional(),
1476
+ minHeight: z4.number().min(0).optional(),
1477
+ maxHeight: z4.number().min(0).optional(),
1478
+ aspectRatioLocked: z4.boolean().optional(),
1479
+ clipsContent: z4.boolean().optional(),
1129
1480
  // Design-system bindings
1130
- styleId: z3.string().optional().describe("Apply a paint/text/effect/grid style by id"),
1131
- styleType: z3.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional(),
1132
- boundVariables: z3.record(z3.string()).optional().describe('Bind variables: {"fills": "VariableID:...", "width": "..."}')
1481
+ styleId: z4.string().optional().describe("Apply a paint/text/effect/grid style by id"),
1482
+ styleType: z4.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional(),
1483
+ boundVariables: z4.record(z4.string()).optional().describe('Bind variables: {"fills": "VariableID:...", "width": "..."}')
1133
1484
  }).strict();
1134
1485
  function defined(obj) {
1135
1486
  return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
@@ -1226,9 +1577,9 @@ function register2(server, sendCommand) {
1226
1577
  "set_properties",
1227
1578
  "Set any visual/layout/text properties on one or more nodes in a single call: geometry (x/y/width/height/rotation), fills/strokes/gradients/cornerRadius/opacity/effects, text content, auto-layout (layoutMode, padding, alignment, spacing, layoutSizing \u2014 FILL requires the parent to have auto-layout), constraints, style application, and variable bindings. Groups map to focused operations with per-operation success/error reporting. dryRun:true previews the mapped command list without touching the canvas.",
1228
1579
  {
1229
- nodeIds: z3.array(z3.string()).min(1).describe("Node IDs to update"),
1580
+ nodeIds: z4.array(z4.string()).min(1).describe("Node IDs to update"),
1230
1581
  properties: propertiesSchema.describe("Properties to apply to every listed node"),
1231
- dryRun: z3.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
1582
+ dryRun: z4.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
1232
1583
  },
1233
1584
  { idempotentHint: true },
1234
1585
  async ({ nodeIds, properties, dryRun }) => {
@@ -1267,13 +1618,13 @@ var structure_exports = {};
1267
1618
  __export(structure_exports, {
1268
1619
  register: () => register3
1269
1620
  });
1270
- import { z as z4 } from "zod";
1621
+ import { z as z5 } from "zod";
1271
1622
  function register3(server, sendCommand) {
1272
1623
  server.tool(
1273
1624
  "edit_structure",
1274
1625
  "Structural operations on nodes: group/ungroup, reparent (move into another parent), reorder (z-order), clone, flatten to vector, boolean operations (union/subtract/intersect/exclude), delete. group/boolean/delete take nodeIds; the rest take nodeId.",
1275
1626
  {
1276
- operation: z4.enum([
1627
+ operation: z5.enum([
1277
1628
  "group",
1278
1629
  "ungroup",
1279
1630
  "reparent",
@@ -1283,14 +1634,14 @@ function register3(server, sendCommand) {
1283
1634
  "boolean",
1284
1635
  "delete"
1285
1636
  ]),
1286
- nodeId: z4.string().optional().describe("Target node (ungroup/reparent/reorder/clone/flatten)"),
1287
- nodeIds: z4.array(z4.string()).optional().describe("Target nodes (group/boolean/delete)"),
1288
- parentId: z4.string().optional().describe("reparent: new parent"),
1289
- index: z4.number().int().min(0).optional().describe("reparent: insertion index"),
1637
+ nodeId: z5.string().optional().describe("Target node (ungroup/reparent/reorder/clone/flatten)"),
1638
+ nodeIds: z5.array(z5.string()).optional().describe("Target nodes (group/boolean/delete)"),
1639
+ parentId: z5.string().optional().describe("reparent: new parent"),
1640
+ index: z5.number().int().min(0).optional().describe("reparent: insertion index"),
1290
1641
  direction: reorderDirectionSchema.optional().describe("reorder direction"),
1291
1642
  booleanOperation: booleanOperationSchema.optional().describe("boolean: which operation"),
1292
- x: z4.number().optional().describe("clone: position of the copy"),
1293
- y: z4.number().optional()
1643
+ x: z5.number().optional().describe("clone: position of the copy"),
1644
+ y: z5.number().optional()
1294
1645
  },
1295
1646
  async ({ operation, nodeId, nodeIds, parentId, index, direction, booleanOperation, x, y }) => {
1296
1647
  try {
@@ -1354,22 +1705,22 @@ var text_exports = {};
1354
1705
  __export(text_exports, {
1355
1706
  register: () => register4
1356
1707
  });
1357
- import { z as z5 } from "zod";
1708
+ import { z as z6 } from "zod";
1358
1709
  function register4(server, sendCommand) {
1359
1710
  server.tool(
1360
1711
  "set_text",
1361
1712
  "Edit text nodes. Modes: single (nodeId + text), bulk (items \u2014 efficient for many nodes, e.g. translations), or character-range styling (nodeId + range with fontSize/fontWeight/letterSpacing/lineHeight). Fonts load automatically with fallbacks. To find text nodes first, use search_nodes or get_selection_context.",
1362
1713
  {
1363
- nodeId: z5.string().optional().describe("Target text node (single / range mode)"),
1364
- text: z5.string().optional().describe("New content (single mode)"),
1365
- items: z5.array(z5.object({ nodeId: z5.string(), text: z5.string() })).optional().describe("Bulk replacements"),
1366
- range: z5.object({
1367
- start: z5.number().int().min(0),
1368
- end: z5.number().int().positive(),
1369
- fontSize: z5.number().positive().optional(),
1370
- fontWeight: z5.number().optional(),
1371
- letterSpacing: z5.number().optional(),
1372
- lineHeight: z5.number().optional()
1714
+ nodeId: z6.string().optional().describe("Target text node (single / range mode)"),
1715
+ text: z6.string().optional().describe("New content (single mode)"),
1716
+ items: z6.array(z6.object({ nodeId: z6.string(), text: z6.string() })).optional().describe("Bulk replacements"),
1717
+ range: z6.object({
1718
+ start: z6.number().int().min(0),
1719
+ end: z6.number().int().positive(),
1720
+ fontSize: z6.number().positive().optional(),
1721
+ fontWeight: z6.number().optional(),
1722
+ letterSpacing: z6.number().optional(),
1723
+ lineHeight: z6.number().optional()
1373
1724
  }).refine((r) => r.end > r.start, { message: "end must be greater than start" }).optional().describe("Style a character range [start, end)")
1374
1725
  },
1375
1726
  async ({ nodeId, text, items, range }) => {
@@ -1405,13 +1756,13 @@ var components_exports = {};
1405
1756
  __export(components_exports, {
1406
1757
  register: () => register5
1407
1758
  });
1408
- import { z as z6 } from "zod";
1759
+ import { z as z7 } from "zod";
1409
1760
  function register5(server, sendCommand) {
1410
1761
  server.tool(
1411
1762
  "manage_components",
1412
1763
  "Component workflow: list local components, create a component from a node, create_set (combine components as variants), instantiate (place an instance by componentKey OR by the node id of a local component \u2014 an unpublished component has no importable key, so the id is the way), add_property / bind_property (declare a TEXT/BOOLEAN/INSTANCE_SWAP property on a SET \u2014 never on a single variant \u2014 then point a layer's characters/visible at it, in every variant at once with layerName), get_props / set_props (component properties on an instance; get_props on a variant answers with its set's definitions), get_overrides / set_overrides (copy overrides from a source instance to targets), reset_instance (clear ALL overrides so the instance re-inherits its main component \u2014 returns property snapshots before/after so you can re-apply what mattered), detach.",
1413
1764
  {
1414
- action: z6.enum([
1765
+ action: z7.enum([
1415
1766
  "list",
1416
1767
  "create",
1417
1768
  "create_set",
@@ -1425,21 +1776,21 @@ function register5(server, sendCommand) {
1425
1776
  "add_property",
1426
1777
  "bind_property"
1427
1778
  ]),
1428
- nodeId: z6.string().optional().describe("Target node (create/get_props/set_props/reset_instance/detach)"),
1429
- componentIds: z6.array(z6.string()).optional().describe("create_set: components to combine"),
1430
- componentKey: z6.string().optional().describe("instantiate: component key (or node id)"),
1431
- x: z6.number().optional().describe("instantiate: position"),
1432
- y: z6.number().optional(),
1433
- properties: z6.record(z6.union([z6.string(), z6.boolean()])).optional().describe("set_props: property name \u2192 value (variant/text/boolean/swap)"),
1434
- sourceInstanceId: z6.string().optional().describe("set_overrides: copy from this instance"),
1435
- targetNodeIds: z6.array(z6.string()).optional().describe("set_overrides: apply to these"),
1436
- instanceNodeId: z6.string().optional().describe("get_overrides: instance to inspect"),
1437
- propertyName: z6.string().optional().describe("add_property: the name designers will see (e.g. hasLabel, text)"),
1438
- propertyType: z6.enum(["TEXT", "BOOLEAN", "INSTANCE_SWAP"]).optional().describe("add_property: VARIANT axes come from variant names, so they are not addable here"),
1439
- defaultValue: z6.union([z6.string(), z6.boolean()]).optional().describe("add_property: the value a fresh instance starts with"),
1440
- propertyKey: z6.string().optional().describe("bind_property: the key add_property returned (name#id)"),
1441
- field: z6.enum(["characters", "visible", "mainComponent"]).optional().describe("bind_property: which part of the layer the property drives \u2014 text, show/hide, or which component sits in a swap slot"),
1442
- layerName: z6.string().optional().describe("bind_property: bind this layer in EVERY variant of the set at once")
1779
+ nodeId: z7.string().optional().describe("Target node (create/get_props/set_props/reset_instance/detach)"),
1780
+ componentIds: z7.array(z7.string()).optional().describe("create_set: components to combine"),
1781
+ componentKey: z7.string().optional().describe("instantiate: component key (or node id)"),
1782
+ x: z7.number().optional().describe("instantiate: position"),
1783
+ y: z7.number().optional(),
1784
+ properties: z7.record(z7.union([z7.string(), z7.boolean()])).optional().describe("set_props: property name \u2192 value (variant/text/boolean/swap)"),
1785
+ sourceInstanceId: z7.string().optional().describe("set_overrides: copy from this instance"),
1786
+ targetNodeIds: z7.array(z7.string()).optional().describe("set_overrides: apply to these"),
1787
+ instanceNodeId: z7.string().optional().describe("get_overrides: instance to inspect"),
1788
+ propertyName: z7.string().optional().describe("add_property: the name designers will see (e.g. hasLabel, text)"),
1789
+ propertyType: z7.enum(["TEXT", "BOOLEAN", "INSTANCE_SWAP"]).optional().describe("add_property: VARIANT axes come from variant names, so they are not addable here"),
1790
+ defaultValue: z7.union([z7.string(), z7.boolean()]).optional().describe("add_property: the value a fresh instance starts with"),
1791
+ propertyKey: z7.string().optional().describe("bind_property: the key add_property returned (name#id)"),
1792
+ field: z7.enum(["characters", "visible", "mainComponent"]).optional().describe("bind_property: which part of the layer the property drives \u2014 text, show/hide, or which component sits in a swap slot"),
1793
+ layerName: z7.string().optional().describe("bind_property: bind this layer in EVERY variant of the set at once")
1443
1794
  },
1444
1795
  async (args) => {
1445
1796
  try {
@@ -1519,7 +1870,7 @@ __export(variables_exports, {
1519
1870
  scopeWarning: () => scopeWarning,
1520
1871
  suggestScopes: () => suggestScopes
1521
1872
  });
1522
- import { z as z7 } from "zod";
1873
+ import { z as z8 } from "zod";
1523
1874
  var SCOPE_BY_NAME = [
1524
1875
  [/radius|corner/i, ["CORNER_RADIUS"]],
1525
1876
  [/border-width|stroke-width|stroke\b/i, ["STROKE_FLOAT"]],
@@ -1542,9 +1893,9 @@ var SCOPE_MENU = {
1542
1893
  function suggestScopes(name, resolvedType) {
1543
1894
  const menu = SCOPE_MENU[resolvedType];
1544
1895
  if (!menu) return null;
1545
- for (const [pattern, scopes] of SCOPE_BY_NAME) {
1896
+ for (const [pattern, scopes2] of SCOPE_BY_NAME) {
1546
1897
  if (!pattern.test(name)) continue;
1547
- const usable = scopes.filter((s) => menu.indexOf(s) >= 0);
1898
+ const usable = scopes2.filter((s) => menu.indexOf(s) >= 0);
1548
1899
  if (usable.length) return usable;
1549
1900
  }
1550
1901
  return null;
@@ -1586,26 +1937,26 @@ function register6(server, sendCommand) {
1586
1937
  "manage_variables",
1587
1938
  "Design-token variables: list_collections / list (variables in a collection) / snapshot (compact whole-file inventory \u2014 every collection and variable name/type in one call; take one before branch merges or bulk edits, diff after) / create_collection / update_collection / delete_collection / create (pass valuesByMode to set every mode \u2014 and alias by variable NAME \u2014 in the same call) / update / delete / add_mode / remove_mode / rename_mode / set_scopes / set_code_syntax / remove_code_syntax / create_alias / bind (variable\u2192node property) / unbind / set_node_mode / get_node_modes / tokenize (find hardcoded colors & numbers that match existing variables and bind them \u2014 fix:false to preview, fix:true to apply; scope with nodeId, default current page). Pass only the fields the action needs.",
1588
1939
  {
1589
- action: z7.enum(Object.keys(ACTIONS)),
1590
- collectionId: z7.string().optional(),
1591
- variableId: z7.string().optional(),
1592
- targetVariableId: z7.string().optional().describe("create_alias: variable to alias to"),
1593
- nodeId: z7.string().optional().describe("bind/unbind/set_node_mode/get_node_modes"),
1594
- name: z7.string().optional(),
1595
- description: z7.string().optional(),
1596
- modes: z7.array(z7.string()).optional().describe("create_collection: mode names"),
1597
- modeId: z7.string().optional(),
1598
- resolvedType: z7.enum(["COLOR", "FLOAT", "STRING", "BOOLEAN"]).optional(),
1599
- value: z7.unknown().optional().describe("Variable value (color object, number, string, bool)"),
1600
- valuesByMode: z7.record(z7.unknown()).optional().describe(
1940
+ action: z8.enum(Object.keys(ACTIONS)),
1941
+ collectionId: z8.string().optional(),
1942
+ variableId: z8.string().optional(),
1943
+ targetVariableId: z8.string().optional().describe("create_alias: variable to alias to"),
1944
+ nodeId: z8.string().optional().describe("bind/unbind/set_node_mode/get_node_modes"),
1945
+ name: z8.string().optional(),
1946
+ description: z8.string().optional(),
1947
+ modes: z8.array(z8.string()).optional().describe("create_collection: mode names"),
1948
+ modeId: z8.string().optional(),
1949
+ resolvedType: z8.enum(["COLOR", "FLOAT", "STRING", "BOOLEAN"]).optional(),
1950
+ value: z8.unknown().optional().describe("Variable value (color object, number, string, bool)"),
1951
+ valuesByMode: z8.record(z8.unknown()).optional().describe(
1601
1952
  'create: every mode in one call, keyed by mode NAME or id \u2014 {"Light": {"r":1,"g":1,"b":1}, "Dark": {"aliasOf": "Color/gray/900"}}. `aliasOf` takes a variable id or its exact name, so a brand-mode token lands in one round-trip instead of create + one alias per mode.'
1602
1953
  ),
1603
- property: z7.string().optional().describe('bind/unbind: node property (e.g. "fills", "width")'),
1604
- scopes: z7.array(z7.string()).optional(),
1605
- platform: z7.enum(["WEB", "ANDROID", "iOS"]).optional(),
1606
- hiddenFromPublishing: z7.boolean().optional(),
1607
- fix: z7.boolean().optional().describe("tokenize: apply the bindings (false = report only)"),
1608
- tolerance: z7.number().optional().describe("tokenize: OKLab \u0394E for color matches (default 0.02 \u2248 visually identical)")
1954
+ property: z8.string().optional().describe('bind/unbind: node property (e.g. "fills", "width")'),
1955
+ scopes: z8.array(z8.string()).optional(),
1956
+ platform: z8.enum(["WEB", "ANDROID", "iOS"]).optional(),
1957
+ hiddenFromPublishing: z8.boolean().optional(),
1958
+ fix: z8.boolean().optional().describe("tokenize: apply the bindings (false = report only)"),
1959
+ tolerance: z8.number().optional().describe("tokenize: OKLab \u0394E for color matches (default 0.02 \u2248 visually identical)")
1609
1960
  },
1610
1961
  async (args) => {
1611
1962
  try {
@@ -1636,13 +1987,13 @@ var styles_exports = {};
1636
1987
  __export(styles_exports, {
1637
1988
  register: () => register7
1638
1989
  });
1639
- import { z as z8 } from "zod";
1990
+ import { z as z9 } from "zod";
1640
1991
  function register7(server, sendCommand) {
1641
1992
  server.tool(
1642
1993
  "manage_styles",
1643
1994
  "Shared styles: list all local styles; create_paint / create_text / create_effect / create_grid; update (name/properties); delete; apply (style to a node); reorder. To bind variables instead of styles, use manage_variables.",
1644
1995
  {
1645
- action: z8.enum([
1996
+ action: z9.enum([
1646
1997
  "list",
1647
1998
  "create_paint",
1648
1999
  "create_text",
@@ -1653,26 +2004,26 @@ function register7(server, sendCommand) {
1653
2004
  "apply",
1654
2005
  "reorder"
1655
2006
  ]),
1656
- name: z8.string().optional().describe("Style name (create_*/update)"),
1657
- styleId: z8.string().optional().describe("Target style (update/delete/apply/reorder)"),
1658
- styleType: z8.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional().describe("apply/reorder"),
1659
- nodeId: z8.string().optional().describe("apply: target node"),
2007
+ name: z9.string().optional().describe("Style name (create_*/update)"),
2008
+ styleId: z9.string().optional().describe("Target style (update/delete/apply/reorder)"),
2009
+ styleType: z9.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional().describe("apply/reorder"),
2010
+ nodeId: z9.string().optional().describe("apply: target node"),
1660
2011
  color: colorSchema.optional().describe("create_paint/create_effect"),
1661
2012
  gradientType: gradientTypeSchema.optional(),
1662
- gradientStops: z8.array(gradientStopSchema).optional(),
1663
- fontFamily: z8.string().optional().describe("create_text"),
1664
- fontSize: z8.number().positive().optional(),
1665
- fontWeight: z8.number().optional(),
1666
- lineHeight: z8.number().optional(),
1667
- letterSpacing: z8.number().optional(),
1668
- effectType: z8.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]).optional().describe("create_effect"),
1669
- offsetX: z8.number().optional(),
1670
- offsetY: z8.number().optional(),
1671
- radius: z8.number().min(0).optional(),
1672
- spread: z8.number().optional(),
1673
- grids: z8.array(z8.record(z8.unknown())).optional().describe("create_grid: layout grid objects"),
1674
- properties: z8.record(z8.unknown()).optional().describe("update: properties to change"),
1675
- afterStyleId: z8.string().optional().describe("reorder: place after this style")
2013
+ gradientStops: z9.array(gradientStopSchema).optional(),
2014
+ fontFamily: z9.string().optional().describe("create_text"),
2015
+ fontSize: z9.number().positive().optional(),
2016
+ fontWeight: z9.number().optional(),
2017
+ lineHeight: z9.number().optional(),
2018
+ letterSpacing: z9.number().optional(),
2019
+ effectType: z9.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]).optional().describe("create_effect"),
2020
+ offsetX: z9.number().optional(),
2021
+ offsetY: z9.number().optional(),
2022
+ radius: z9.number().min(0).optional(),
2023
+ spread: z9.number().optional(),
2024
+ grids: z9.array(z9.record(z9.unknown())).optional().describe("create_grid: layout grid objects"),
2025
+ properties: z9.record(z9.unknown()).optional().describe("update: properties to change"),
2026
+ afterStyleId: z9.string().optional().describe("reorder: place after this style")
1676
2027
  },
1677
2028
  async (args) => {
1678
2029
  try {
@@ -1751,18 +2102,18 @@ var pages_exports = {};
1751
2102
  __export(pages_exports, {
1752
2103
  register: () => register8
1753
2104
  });
1754
- import { z as z9 } from "zod";
2105
+ import { z as z10 } from "zod";
1755
2106
  function register8(server, sendCommand) {
1756
2107
  server.tool(
1757
2108
  "manage_pages",
1758
2109
  "Page operations: list all pages, create/rename/delete a page, or set a page's background color. To make a page current, use navigate with switch_page. Guards: list_guards shows the designer's AI no-go zones; guard/unguard change them \u2014 ONLY do that when the designer explicitly asks (guards are theirs; every change shows in their activity feed). Writes into a guarded page are rejected at dispatch.",
1759
2110
  {
1760
- action: z9.enum(["list", "create", "rename", "delete", "set_background", "list_guards", "guard", "unguard"]),
1761
- pageId: z9.string().optional().describe("Target page (rename/delete/set_background/guard/unguard)"),
1762
- name: z9.string().optional().describe("Page name (create/rename)"),
2111
+ action: z10.enum(["list", "create", "rename", "delete", "set_background", "list_guards", "guard", "unguard"]),
2112
+ pageId: z10.string().optional().describe("Target page (rename/delete/set_background/guard/unguard)"),
2113
+ name: z10.string().optional().describe("Page name (create/rename)"),
1763
2114
  color: colorSchema.optional().describe("Background color (set_background)"),
1764
- afterPageId: z9.string().optional().describe("create: put the new page right after this one \u2014 pages are ordered by meaning, and a new one lands at the bottom otherwise"),
1765
- index: z9.number().int().min(0).optional().describe("create: exact position instead of afterPageId")
2115
+ afterPageId: z10.string().optional().describe("create: put the new page right after this one \u2014 pages are ordered by meaning, and a new one lands at the bottom otherwise"),
2116
+ index: z10.number().int().min(0).optional().describe("create: exact position instead of afterPageId")
1766
2117
  },
1767
2118
  async ({ action, pageId, name, color, afterPageId, index }) => {
1768
2119
  try {
@@ -1809,19 +2160,19 @@ var navigate_exports = {};
1809
2160
  __export(navigate_exports, {
1810
2161
  register: () => register9
1811
2162
  });
1812
- import { z as z10 } from "zod";
2163
+ import { z as z11 } from "zod";
1813
2164
  function register9(server, sendCommand) {
1814
2165
  server.tool(
1815
2166
  "navigate",
1816
2167
  "Control the designer's view: focus (scroll+zoom to a node), select (set the selection), get_viewport / set_viewport (x/y/zoom), or switch_page.",
1817
2168
  {
1818
- action: z10.enum(["focus", "select", "get_viewport", "set_viewport", "switch_page"]),
1819
- nodeId: z10.string().optional().describe("focus target"),
1820
- nodeIds: z10.array(z10.string()).optional().describe("select targets"),
1821
- x: z10.number().optional().describe("set_viewport center x"),
1822
- y: z10.number().optional(),
1823
- zoom: z10.number().positive().optional(),
1824
- pageId: z10.string().optional().describe("switch_page target")
2169
+ action: z11.enum(["focus", "select", "get_viewport", "set_viewport", "switch_page"]),
2170
+ nodeId: z11.string().optional().describe("focus target"),
2171
+ nodeIds: z11.array(z11.string()).optional().describe("select targets"),
2172
+ x: z11.number().optional().describe("set_viewport center x"),
2173
+ y: z11.number().optional(),
2174
+ zoom: z11.number().positive().optional(),
2175
+ pageId: z11.string().optional().describe("switch_page target")
1825
2176
  },
1826
2177
  async ({ action, nodeId, nodeIds, x, y, zoom, pageId }) => {
1827
2178
  try {
@@ -1856,15 +2207,15 @@ var assets_exports = {};
1856
2207
  __export(assets_exports, {
1857
2208
  register: () => register10
1858
2209
  });
1859
- import { z as z11 } from "zod";
2210
+ import { z as z12 } from "zod";
1860
2211
  function register10(server, sendCommand) {
1861
2212
  server.tool(
1862
2213
  "export_asset",
1863
2214
  "Export a node as PNG/JPG/SVG/PDF. Returns base64 data for saving to disk. For a quick visual check of your work, prefer the screenshot tool (returns a viewable image).",
1864
2215
  {
1865
- nodeId: z11.string().describe("Node to export"),
2216
+ nodeId: z12.string().describe("Node to export"),
1866
2217
  format: exportFormatSchema.optional().describe("Default PNG"),
1867
- scale: z11.number().positive().optional().describe("Export scale (default 1)")
2218
+ scale: z12.number().positive().optional().describe("Export scale (default 1)")
1868
2219
  },
1869
2220
  { readOnlyHint: true },
1870
2221
  async ({ nodeId, format, scale }) => {
@@ -1880,15 +2231,15 @@ function register10(server, sendCommand) {
1880
2231
  "add_image",
1881
2232
  "Place an image: from a URL (creates a new image node) or as a fill on an existing node (base64 imageData). scaleMode controls how the image fits.",
1882
2233
  {
1883
- url: z11.string().optional().describe("Image URL \u2014 creates a new node"),
1884
- nodeId: z11.string().optional().describe("Existing node \u2014 sets its fill from imageData"),
1885
- imageData: z11.string().optional().describe("Base64 image data (with nodeId)"),
1886
- x: z11.number().optional(),
1887
- y: z11.number().optional(),
1888
- width: z11.number().positive().optional(),
1889
- height: z11.number().positive().optional(),
1890
- name: z11.string().optional(),
1891
- scaleMode: z11.enum(["FILL", "FIT", "CROP", "TILE"]).optional()
2234
+ url: z12.string().optional().describe("Image URL \u2014 creates a new node"),
2235
+ nodeId: z12.string().optional().describe("Existing node \u2014 sets its fill from imageData"),
2236
+ imageData: z12.string().optional().describe("Base64 image data (with nodeId)"),
2237
+ x: z12.number().optional(),
2238
+ y: z12.number().optional(),
2239
+ width: z12.number().positive().optional(),
2240
+ height: z12.number().positive().optional(),
2241
+ name: z12.string().optional(),
2242
+ scaleMode: z12.enum(["FILL", "FIT", "CROP", "TILE"]).optional()
1892
2243
  },
1893
2244
  async ({ url, nodeId, imageData, x, y, width, height, name, scaleMode }) => {
1894
2245
  try {
@@ -1917,14 +2268,14 @@ var library_exports = {};
1917
2268
  __export(library_exports, {
1918
2269
  register: () => register11
1919
2270
  });
1920
- import { z as z12 } from "zod";
2271
+ import { z as z13 } from "zod";
1921
2272
  function register11(server, sendCommand) {
1922
2273
  server.tool(
1923
2274
  "import_from_library",
1924
2275
  "Import a component, style, or variable from an enabled team library by its key. Keys come from get_design_system's libraryCatalog, which resolves each library this file draws from and lists everything it publishes \u2014 not just what the file already uses. If the library you want is not in that catalog, it is listed under librariesNotCatalogued with what to do about it. Imported components can then be instantiated with manage_components.",
1925
2276
  {
1926
- kind: z12.enum(["component", "style", "variable"]),
1927
- key: z12.string().describe("Library key")
2277
+ kind: z13.enum(["component", "style", "variable"]),
2278
+ key: z13.string().describe("Library key")
1928
2279
  },
1929
2280
  async ({ kind, key }) => {
1930
2281
  try {
@@ -1943,21 +2294,21 @@ var annotate_exports = {};
1943
2294
  __export(annotate_exports, {
1944
2295
  register: () => register12
1945
2296
  });
1946
- import { z as z13 } from "zod";
2297
+ import { z as z14 } from "zod";
1947
2298
  function register12(server, sendCommand) {
1948
2299
  server.tool(
1949
2300
  "annotate",
1950
- "Read or write Dev Mode annotations. get: read annotations on a node. set: add/update one annotation (labelMarkdown). set_multiple: batch annotations.",
2301
+ "Read or write Dev Mode annotations. get: read annotations on a node. set: add one annotation (labelMarkdown); existing annotations are kept, and a node type that takes none is an error. set_multiple: batch annotations.",
1951
2302
  {
1952
- action: z13.enum(["get", "set", "set_multiple"]),
1953
- nodeId: z13.string().optional().describe("Target node (get/set)"),
1954
- labelMarkdown: z13.string().optional().describe("Annotation text (set)"),
1955
- categoryId: z13.string().optional(),
1956
- annotations: z13.array(
1957
- z13.object({
1958
- nodeId: z13.string(),
1959
- labelMarkdown: z13.string(),
1960
- categoryId: z13.string().optional()
2303
+ action: z14.enum(["get", "set", "set_multiple"]),
2304
+ nodeId: z14.string().optional().describe("Target node (get/set)"),
2305
+ labelMarkdown: z14.string().optional().describe("Annotation text (set)"),
2306
+ categoryId: z14.string().optional(),
2307
+ annotations: z14.array(
2308
+ z14.object({
2309
+ nodeId: z14.string(),
2310
+ labelMarkdown: z14.string(),
2311
+ categoryId: z14.string().optional()
1961
2312
  })
1962
2313
  ).optional().describe("Batch annotations (set_multiple)")
1963
2314
  },
@@ -1988,16 +2339,16 @@ var read_exports = {};
1988
2339
  __export(read_exports, {
1989
2340
  register: () => register13
1990
2341
  });
1991
- import { z as z14 } from "zod";
2342
+ import { z as z15 } from "zod";
1992
2343
  function register13(server, sendCommand) {
1993
2344
  server.tool(
1994
2345
  "get_node_data",
1995
2346
  "Raw node data at a chosen detail level: summary (shallow), tree (sparse structure for orientation), full (deeper serialization), css (CSS representation), variables (bound variable ids). For human-oriented summaries prefer get_node_details / get_selection_context.",
1996
2347
  {
1997
- nodeIds: z14.array(z14.string()).min(1).describe("One or more node ids"),
1998
- detail: z14.enum(["summary", "tree", "full", "css", "variables"]).optional().describe("Default summary"),
1999
- depth: z14.number().int().min(0).optional().describe("Override serialization depth"),
2000
- maxNodes: z14.number().int().positive().optional().describe("Override node budget")
2348
+ nodeIds: z15.array(z15.string()).min(1).describe("One or more node ids"),
2349
+ detail: z15.enum(["summary", "tree", "full", "css", "variables"]).optional().describe("Default summary"),
2350
+ depth: z15.number().int().min(0).optional().describe("Override serialization depth"),
2351
+ maxNodes: z15.number().int().positive().optional().describe("Override node budget")
2001
2352
  },
2002
2353
  { readOnlyHint: true },
2003
2354
  async ({ nodeIds, detail = "summary", depth, maxNodes }) => {
@@ -2037,22 +2388,42 @@ function register13(server, sendCommand) {
2037
2388
  // src/tools/core/execute.ts
2038
2389
  var execute_exports = {};
2039
2390
  __export(execute_exports, {
2391
+ pluginTruncation: () => pluginTruncation,
2040
2392
  register: () => register14
2041
2393
  });
2042
- import { z as z15 } from "zod";
2394
+ import { z as z16 } from "zod";
2395
+ var LEGACY_PLUGIN_LIMIT = 5e4;
2396
+ function pluginTruncation(result) {
2397
+ const r = result;
2398
+ if (!r || typeof r !== "object") return void 0;
2399
+ const t = r.truncated;
2400
+ if (t && typeof t === "object" && typeof t.total === "number" && typeof t.returned === "number") {
2401
+ return { total: t.total, returned: t.returned, unit: "chars" };
2402
+ }
2403
+ if (t === true && typeof r.result === "string") {
2404
+ const cut = /… \[truncated (\d+) chars/.exec(r.result);
2405
+ if (cut) {
2406
+ return { total: Number(cut[1]) + LEGACY_PLUGIN_LIMIT, returned: LEGACY_PLUGIN_LIMIT, unit: "chars" };
2407
+ }
2408
+ }
2409
+ return void 0;
2410
+ }
2043
2411
  function register14(server, sendCommand) {
2044
2412
  server.tool(
2045
2413
  "execute_figma",
2046
2414
  "Run JavaScript against the Figma Plugin API inside the plugin sandbox \u2014 the escape hatch for anything the other tools don't cover. The code runs in an async function with `figma` AND `relai` in scope. relai helpers avoid the classic pitfalls: relai.text(parent, chars, {font,size,color}) loads fonts first; relai.autoLayout(direction, props) makes a hugging auto-layout frame; relai.set(node, props) applies layoutMode first and routes width/height through resize; relai.hug(node); relai.focusRing(node); await relai.page(p => ...) finds pages by content, not name; relai.query('FRAME[name^=Card] > TEXT') is a CSS-like search (types, name matchers, descendant/child, comma); relai.placeholder(node) shows a construction veil \u2014 remove with (node, false) when done. Errors carry a Hint with the fix; scripts are NOT atomic \u2014 on error, partial changes persist, so keep scripts small and idempotent. Nodes created via relai plus any node ids you RETURN are linted for silent mistakes (e.g. spread shadows without clipsContent) and come back as `warnings`. Return ALL created/mutated node ids. Work incrementally; verify with screenshot. When BUILDING UI: call get_design_system first and instantiate the file's own components / bind its variables instead of drawing raw shapes \u2014 hand-drawn near-copies of existing components read as drift to the design team. The designer can disable this tool via the plugin's 'Allow code execution' toggle.",
2047
2415
  {
2048
- code: z15.string().describe("JavaScript source. May use await. Return a JSON-serializable value."),
2049
- description: z15.string().optional().describe("One line shown in the plugin's activity feed describing what this does"),
2050
- timeoutMs: z15.number().int().min(1e3).max(3e5).optional().describe("Execution timeout in ms (default 60000) \u2014 raise for scripts creating hundreds of nodes")
2416
+ code: z16.string().describe("JavaScript source. May use await. Return a JSON-serializable value."),
2417
+ description: z16.string().optional().describe("One line shown in the plugin's activity feed describing what this does"),
2418
+ timeoutMs: z16.number().int().min(1e3).max(3e5).optional().describe("Execution timeout in ms (default 60000) \u2014 raise for scripts creating hundreds of nodes")
2051
2419
  },
2052
2420
  async ({ code, description, timeoutMs }) => {
2053
2421
  try {
2054
2422
  const result = await sendCommand("execute_code", { code, description }, timeoutMs ?? 6e4);
2055
- return jsonResult(result);
2423
+ const truncated = pluginTruncation(result);
2424
+ if (!truncated) return jsonResult(result);
2425
+ const meta = { v: 1, calls: [], truncated };
2426
+ return { ...jsonResult(result), _meta: { [CALL_META_KEY]: meta } };
2056
2427
  } catch (error) {
2057
2428
  return errorResult(error);
2058
2429
  }
@@ -2066,7 +2437,86 @@ __export(comments_exports, {
2066
2437
  parseFileKey: () => parseFileKey,
2067
2438
  register: () => register15
2068
2439
  });
2069
- import { z as z16 } from "zod";
2440
+ import { z as z17 } from "zod";
2441
+
2442
+ // src/tools/core/file-identity.ts
2443
+ function addressableKey(identity) {
2444
+ return identity.branchKey ?? identity.fileKey;
2445
+ }
2446
+ function describeIdentity(identity) {
2447
+ return identity.branchName ? `${identity.fileName} (branch "${identity.branchName}")` : identity.fileName;
2448
+ }
2449
+ async function figmaApi(token, path) {
2450
+ const res = await fetch(`https://api.figma.com/v1${path}`, {
2451
+ headers: { "X-Figma-Token": token }
2452
+ });
2453
+ noteAuthFailure(res.status);
2454
+ const json = await res.json().catch(() => ({}));
2455
+ return { ok: res.ok, status: res.status, json };
2456
+ }
2457
+ var FileIdentityError = class extends Error {
2458
+ };
2459
+ async function resolveFileIdentity(sendCommand) {
2460
+ const probe = await sendCommand("get_file_identity", {});
2461
+ const cached = probe?.identity?.fileKey ? probe.identity : null;
2462
+ if (cached && cached.by === VERSION) return cached;
2463
+ const rootName = probe?.rootName ?? "";
2464
+ const candidates = probe?.candidates ?? [];
2465
+ if (candidates.length === 0) {
2466
+ if (cached) return cached;
2467
+ throw new FileIdentityError(
2468
+ "This file publishes no components, so its key cannot be resolved from the plugin (figma.fileKey is private-plugin-only)."
2469
+ );
2470
+ }
2471
+ const token = loadToken();
2472
+ if (!token) {
2473
+ throw new FileIdentityError(
2474
+ "Resolving the file key needs a Figma token \u2014 store one with `npx -y figma-relai@latest login`."
2475
+ );
2476
+ }
2477
+ let main = null;
2478
+ const statuses = [];
2479
+ for (const c of candidates) {
2480
+ const paths = c.kind === "set" ? ["/component_sets/", "/components/"] : ["/components/", "/component_sets/"];
2481
+ for (const p of paths) {
2482
+ const res = await figmaApi(token, `${p}${c.key}`);
2483
+ statuses.push(res.status);
2484
+ const meta = res.json.meta ?? {};
2485
+ if (res.ok && typeof meta.file_key === "string") {
2486
+ main = { fileKey: meta.file_key, via: c.key };
2487
+ break;
2488
+ }
2489
+ }
2490
+ if (main) break;
2491
+ }
2492
+ if (!main) {
2493
+ const denied = statuses.length > 0 && statuses.every((s) => s === 401 || s === 403);
2494
+ throw new FileIdentityError(
2495
+ denied ? "Figma rejected the token on every probe (401/403) \u2014 it is expired or lacks the file content read scope. Re-run `npx -y figma-relai@latest login`." : "None of this file's published component keys resolved."
2496
+ );
2497
+ }
2498
+ const file = await figmaApi(token, `/files/${main.fileKey}?branch_data=true&depth=1`);
2499
+ const identity = {
2500
+ fileKey: main.fileKey,
2501
+ fileName: typeof file.json.name === "string" ? file.json.name : rootName,
2502
+ by: VERSION
2503
+ };
2504
+ if (rootName && rootName !== identity.fileName) {
2505
+ const branches = file.json.branches ?? [];
2506
+ const hit = branches.find((b) => b.name === rootName);
2507
+ if (hit?.key) {
2508
+ identity.branchKey = hit.key;
2509
+ identity.branchName = hit.name;
2510
+ }
2511
+ }
2512
+ try {
2513
+ await sendCommand("set_file_identity", { identity, componentKey: main.via });
2514
+ } catch {
2515
+ }
2516
+ return identity;
2517
+ }
2518
+
2519
+ // src/tools/core/comments.ts
2070
2520
  var API = "https://api.figma.com/v1";
2071
2521
  function parseFileKey(input) {
2072
2522
  const url = input.match(/figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/);
@@ -2120,15 +2570,15 @@ function register15(server, sendCommand) {
2120
2570
  "manage_comments",
2121
2571
  "Read and write comments on the Figma file: list (with node anchors \u2014 great for 'apply the feedback in the comments' or polling for designer requests; filter with unresolved:true and since:<ISO date>), add (optionally pinned to a node), reply, delete. Comment-driven workflow: designers leave asks as comments in Figma; poll list (unresolved + since last check), do the work, then reply on the thread with what was done. Requires a FIGMA_TOKEN env var (personal access token with comment scopes, generated at figma.com Settings \u2192 Security); the canvas tools work without it. The file is resolved from the plugin automatically (via a published component key, and the branch you are on \u2014 comments live per branch); pass fileUrl if this file publishes nothing.",
2122
2572
  {
2123
- action: z16.enum(["list", "add", "reply", "delete"]),
2124
- fileUrl: z16.string().optional().describe("Figma file URL or key (auto-detected when omitted)"),
2125
- message: z16.string().optional().describe("Comment text (add/reply)"),
2126
- commentId: z16.string().optional().describe("Target comment (reply/delete)"),
2127
- nodeId: z16.string().optional().describe("add: pin the comment to this node"),
2128
- x: z16.number().optional().describe("add: canvas position (with y, when not pinning to a node)"),
2129
- y: z16.number().optional(),
2130
- since: z16.string().optional().describe("list: only comments created after this ISO 8601 timestamp"),
2131
- unresolved: z16.boolean().optional().describe("list: only unresolved threads")
2573
+ action: z17.enum(["list", "add", "reply", "delete"]),
2574
+ fileUrl: z17.string().optional().describe("Figma file URL or key (auto-detected when omitted)"),
2575
+ message: z17.string().optional().describe("Comment text (add/reply)"),
2576
+ commentId: z17.string().optional().describe("Target comment (reply/delete)"),
2577
+ nodeId: z17.string().optional().describe("add: pin the comment to this node"),
2578
+ x: z17.number().optional().describe("add: canvas position (with y, when not pinning to a node)"),
2579
+ y: z17.number().optional(),
2580
+ since: z17.string().optional().describe("list: only comments created after this ISO 8601 timestamp"),
2581
+ unresolved: z17.boolean().optional().describe("list: only unresolved threads")
2132
2582
  },
2133
2583
  async ({ action, fileUrl, message, commentId, nodeId, x, y, since, unresolved }) => {
2134
2584
  const token = loadToken();
@@ -2235,14 +2685,14 @@ __export(design_system_exports, {
2235
2685
  probeFailureNote: () => probeFailureNote,
2236
2686
  register: () => register16
2237
2687
  });
2238
- import { z as z17 } from "zod";
2688
+ import { z as z18 } from "zod";
2239
2689
  function register16(server, sendCommand) {
2240
2690
  server.tool(
2241
2691
  "get_design_system",
2242
2692
  "Inventory the design system available to this file \u2014 call this BEFORE building UI, then prefer instantiating existing components (manage_components action:instantiate takes local and library keys) and binding existing variables over drawing raw shapes. Reports: local components/styles/variable collections with usage counts, remote components/styles the file already uses, enabled libraries' variable collections, and \u2014 with a token stored (`npx figma-relai login`) \u2014 the FULL published catalog of every library this file consumes, resolved automatically. What the file already uses is never the whole library: a component the file has not placed yet is invisible until this catalog is read. A library the file has drawn from NOTHING at all cannot be resolved either \u2014 those are named under librariesNotCatalogued, with the one step that fixes it. Results are cached per session \u2014 pass refresh:true after big library changes.",
2243
2693
  {
2244
- refresh: z17.boolean().optional().describe("Rescan instead of using the session cache"),
2245
- libraryFileUrl: z17.string().optional().describe(
2694
+ refresh: z18.boolean().optional().describe("Rescan instead of using the session cache"),
2695
+ libraryFileUrl: z18.string().optional().describe(
2246
2696
  "Optional. Catalog THIS library file instead of the auto-resolved ones (figma.com URL or key)"
2247
2697
  )
2248
2698
  },
@@ -2281,7 +2731,7 @@ function register16(server, sendCommand) {
2281
2731
  "manage_conventions",
2282
2732
  "The file's law: conventions (statutes) + precedents (case law), stored INSIDE this Figma file so they travel with it to every future session from any AI client. action:get returns both \u2014 read them BEFORE working and follow them like user instructions. Conventions are a markdown doc (action:set overwrites). Precedents are single adjudications the designer made \u2014 record one (action:record_precedent, one sentence, with refs to the tokens/nodes/pages it concerns) whenever the designer rules on something durable ('this deviation is intent, not drift', 'never restructure this table'), and SAY in your reply that you recorded it. Write results automatically surface precedents whose refs they touch.",
2283
2733
  {
2284
- action: z17.enum([
2734
+ action: z18.enum([
2285
2735
  "get",
2286
2736
  "set",
2287
2737
  "record_precedent",
@@ -2289,16 +2739,18 @@ function register16(server, sendCommand) {
2289
2739
  "update_precedent",
2290
2740
  "remove_precedent"
2291
2741
  ]),
2292
- content: z17.string().optional().describe("set: the full markdown doc (overwrites; max 20k chars)"),
2293
- kind: z17.enum(["decision", "intent", "correction"]).optional().describe("record/update_precedent: decision (an approval/rejection), intent (a deviation that is deliberate), correction (a do-it-differently ruling). Default intent"),
2294
- text: z17.string().optional().describe("record/update_precedent: the adjudication, one sentence, \u2264280 chars"),
2295
- id: z17.string().optional().describe("update/remove_precedent: precedent id (from list_precedents)"),
2296
- refs: z17.object({
2297
- tokens: z17.array(z17.string()).optional().describe("Variable IDs this precedent concerns"),
2298
- nodes: z17.array(z17.string()).optional().describe("Node IDs this precedent concerns"),
2299
- pages: z17.array(z17.string()).optional().describe("Page IDs this precedent concerns")
2742
+ content: z18.string().optional().describe("set: the full markdown doc (overwrites; max 20k chars)"),
2743
+ kind: z18.enum(["decision", "intent", "correction"]).optional().describe("record/update_precedent: decision (an approval/rejection), intent (a deviation that is deliberate), correction (a do-it-differently ruling). Default intent"),
2744
+ text: z18.string().optional().describe("record/update_precedent: the adjudication, one sentence, \u2264280 chars"),
2745
+ id: z18.string().optional().describe("update/remove_precedent: precedent id (from list_precedents)"),
2746
+ refs: z18.object({
2747
+ tokens: z18.array(z18.string()).optional().describe(
2748
+ 'Variable IDs this precedent concerns, with the "VariableID:" prefix (a bare N:M gets it added; an id that names no variable is an error and nothing is saved)'
2749
+ ),
2750
+ nodes: z18.array(z18.string()).optional().describe("Node IDs this precedent concerns"),
2751
+ pages: z18.array(z18.string()).optional().describe("Page IDs this precedent concerns")
2300
2752
  }).optional().describe("What the precedent is anchored to \u2014 enables in-band surfacing when writes touch these"),
2301
- limit: z17.number().optional().describe("list_precedents: max entries returned (default 50)")
2753
+ limit: z18.number().optional().describe("list_precedents: max entries returned (default 50)")
2302
2754
  },
2303
2755
  async ({ action, content, kind, text, id, refs, limit }) => {
2304
2756
  try {
@@ -2480,7 +2932,7 @@ var rulesets_exports = {};
2480
2932
  __export(rulesets_exports, {
2481
2933
  register: () => register17
2482
2934
  });
2483
- import { z as z18 } from "zod";
2935
+ import { z as z19 } from "zod";
2484
2936
  var ACTIONS2 = {
2485
2937
  list: ["list_rulesets", []],
2486
2938
  save: ["save_ruleset", ["name", "conventions", "fromFile", "autoRestore", "provenance"]],
@@ -2497,20 +2949,20 @@ var ACTIONS2 = {
2497
2949
  function register17(server, sendCommand) {
2498
2950
  server.tool(
2499
2951
  "manage_rulesets",
2500
- "Kits (rulesets) \u2014 named common-ancestor law the plugin keeps on the designer's machine, spanning every file it opens (one designer usually runs one product; its files share the same law). The panel shows this as the KIT row in the connection cluster \u2014 when speaking to the designer, call it a kit, not a ruleset. A file LINKS to one ruleset; the file's own carried conventions stay the working copy. Figma DISCARDS file-carried law on branch\u2192main merges \u2014 a linked ruleset is the recovery: status shows 'file-empty' after a merge, restore (or the set's autoRestore switch) re-seeds it. Actions: list / status (this file's state: in-sync, file-empty, drifted\u2026) / save (name + conventions, or fromFile:true to capture this file's law) / link (optionally restore:true) / unlink / restore (ruleset\u2192file, also seeds promoted precedents; REFUSED when the file's law is newer than the kit's \u2014 status.newer says which side is, and push is usually the fix \u2014 pass force:true to override) / push (file\u2192ruleset \u2014 the file's law becomes the ancestor) / promote (id \u2014 copy one precedent into the linked ruleset so every linked file inherits it) / delete / export (markdown package with provenance frontmatter) / import (markdown). Pass only the fields the action needs.",
2952
+ "Kits (rulesets) \u2014 named common-ancestor law the plugin keeps on the designer's machine, spanning every file it opens (one designer usually runs one product; its files share the same law). The panel shows this as the KIT row in the connection cluster \u2014 when speaking to the designer, call it a kit, not a ruleset. A file LINKS to one ruleset; the file's own carried conventions stay the working copy. Figma DISCARDS file-carried law on branch\u2192main merges \u2014 a linked ruleset is the recovery: status shows 'file-empty' after a merge, restore (or the set's autoRestore switch) re-seeds it. Actions: list / status (this file's state: in-sync, file-empty, drifted\u2026) / save (name + conventions, or fromFile:true to capture this file's law) / link (optionally restore:true) / unlink / restore (ruleset\u2192file, also seeds promoted precedents \u2014 all or none: when the file memory can't hold them the law is still restored and precedentsSkipped says so; consolidate, then restore again \u2014 seeds merge by id, so only the missing ones are added; REFUSED when the file's law is newer than the kit's \u2014 status.newer says which side is, and push is usually the fix \u2014 pass force:true to override) / push (file\u2192ruleset \u2014 the file's law becomes the ancestor) / promote (id \u2014 copy one precedent into the linked ruleset so every linked file inherits it) / delete / export (markdown package with provenance frontmatter) / import (markdown). Pass only the fields the action needs.",
2501
2953
  {
2502
- action: z18.enum(Object.keys(ACTIONS2)),
2503
- name: z18.string().optional().describe("Ruleset name (save/link/delete/export; import fallback)"),
2504
- conventions: z18.string().optional().describe("save: full conventions markdown (or use fromFile)"),
2505
- fromFile: z18.boolean().optional().describe("save: capture this file's current conventions"),
2506
- autoRestore: z18.boolean().optional().describe("save: restore without asking when a linked file's law is found wiped"),
2507
- provenance: z18.string().optional().describe("save: where this law comes from (author/repo)"),
2508
- restore: z18.boolean().optional().describe("link: immediately restore the ruleset into the file"),
2509
- force: z18.boolean().optional().describe(
2954
+ action: z19.enum(Object.keys(ACTIONS2)),
2955
+ name: z19.string().optional().describe("Ruleset name (save/link/delete/export; import fallback)"),
2956
+ conventions: z19.string().optional().describe("save: full conventions markdown (or use fromFile)"),
2957
+ fromFile: z19.boolean().optional().describe("save: capture this file's current conventions"),
2958
+ autoRestore: z19.boolean().optional().describe("save: restore without asking when a linked file's law is found wiped"),
2959
+ provenance: z19.string().optional().describe("save: where this law comes from (author/repo)"),
2960
+ restore: z19.boolean().optional().describe("link: immediately restore the ruleset into the file"),
2961
+ force: z19.boolean().optional().describe(
2510
2962
  "restore/link: replace the file's law even when it is NEWER than the kit's; push: overwrite a kit that has moved since this file last synced it. Without this, an overwrite that would discard someone else's version is refused \u2014 say which side you mean to keep."
2511
2963
  ),
2512
- id: z18.string().optional().describe("promote: precedent id (list_precedents shows them)"),
2513
- markdown: z18.string().optional().describe("import: a ruleset package exported earlier")
2964
+ id: z19.string().optional().describe("promote: precedent id (list_precedents shows them)"),
2965
+ markdown: z19.string().optional().describe("import: a ruleset package exported earlier")
2514
2966
  },
2515
2967
  async (args) => {
2516
2968
  try {
@@ -2532,7 +2984,7 @@ var batch_exports = {};
2532
2984
  __export(batch_exports, {
2533
2985
  register: () => register18
2534
2986
  });
2535
- import { z as z19 } from "zod";
2987
+ import { z as z20 } from "zod";
2536
2988
  function textResult2(text) {
2537
2989
  return { content: [{ type: "text", text }] };
2538
2990
  }
@@ -2541,13 +2993,13 @@ function register18(server, sendCommand) {
2541
2993
  "batch_execute",
2542
2994
  "Execute multiple commands in a single round-trip. Use when performing 3+ similar operations (e.g., styling multiple nodes, creating many elements). Commands run sequentially. Pass dryRun:true to preview the exact command list without touching the canvas \u2014 useful for showing the designer a plan before a large batch.",
2543
2995
  {
2544
- commands: z19.array(
2545
- z19.object({
2546
- command: z19.string().describe("Command name"),
2547
- params: z19.record(z19.string(), z19.unknown()).optional().describe("Command parameters")
2996
+ commands: z20.array(
2997
+ z20.object({
2998
+ command: z20.string().describe("Command name"),
2999
+ params: z20.record(z20.string(), z20.unknown()).optional().describe("Command parameters")
2548
3000
  })
2549
3001
  ).describe("Array of commands to execute sequentially"),
2550
- dryRun: z19.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
3002
+ dryRun: z20.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
2551
3003
  },
2552
3004
  async ({ commands, dryRun }) => {
2553
3005
  try {
@@ -2581,7 +3033,7 @@ __export(context_exports, {
2581
3033
  register: () => register19,
2582
3034
  styleCountsFrom: () => styleCountsFrom
2583
3035
  });
2584
- import { z as z20 } from "zod";
3036
+ import { z as z21 } from "zod";
2585
3037
 
2586
3038
  // src/semantic/response.ts
2587
3039
  function standardResult(opts) {
@@ -2606,8 +3058,18 @@ function errorResult2(category, message, recovery, extra) {
2606
3058
  };
2607
3059
  if (extra?.preconditions) error.preconditions = extra.preconditions;
2608
3060
  if (extra?.partial_result !== void 0) error.partial_result = extra.partial_result;
3061
+ const { code, outcome, retry, partial } = CATEGORY_CODES[category];
3062
+ const meta = {
3063
+ v: 1,
3064
+ ...outcome ? { outcome } : {},
3065
+ ...partial ? { partial } : {},
3066
+ error: { code, retry: retry ?? ERROR_DEFAULTS[code].retry },
3067
+ calls: []
3068
+ };
2609
3069
  return {
2610
- content: [{ type: "text", text: JSON.stringify({ error }, null, 2) }]
3070
+ content: [{ type: "text", text: JSON.stringify({ error }, null, 2) }],
3071
+ isError: true,
3072
+ _meta: { [CALL_META_KEY]: meta }
2611
3073
  };
2612
3074
  }
2613
3075
  function noSelectionError() {
@@ -2824,7 +3286,7 @@ function register19(server, sendCommand) {
2824
3286
  );
2825
3287
  server.tool(
2826
3288
  "get_selection_context",
2827
- "Get full context for the current Figma selection: node properties, fills, layout, token bindings, and children summary. Use as the starting point for any inspection or modification workflow. Follow with analyze_* tools or update_node.",
3289
+ "Get full context for the current Figma selection: node properties, fills, layout, token bindings, and children summary. Use as the starting point for any inspection or modification workflow. Follow with analyze_design or set_properties.",
2828
3290
  {},
2829
3291
  { readOnlyHint: true },
2830
3292
  async () => {
@@ -2869,8 +3331,8 @@ function register19(server, sendCommand) {
2869
3331
  data,
2870
3332
  warnings,
2871
3333
  recommended_next: [
2872
- { tool: "analyze_color_usage", reason: "Audit color consistency and token coverage" },
2873
- { tool: "update_node", reason: "Modify selected node properties" },
3334
+ { tool: "analyze_design", reason: "Audit color consistency and token coverage", args: { aspect: "color" } },
3335
+ { tool: "set_properties", reason: "Modify selected node properties" },
2874
3336
  { tool: "screenshot", reason: "Visually inspect the selection" }
2875
3337
  ]
2876
3338
  });
@@ -2885,9 +3347,9 @@ function register19(server, sendCommand) {
2885
3347
  );
2886
3348
  server.tool(
2887
3349
  "get_node_details",
2888
- "Get a single node's full properties, CSS, token bindings, and children. Use after search_nodes or get_selection_context to inspect a specific node. Follow with update_node or analyze_* tools.",
3350
+ "Get a single node's full properties, CSS, token bindings, and children. Use after search_nodes or get_selection_context to inspect a specific node. Follow with set_properties or analyze_design.",
2889
3351
  {
2890
- nodeId: z20.string().describe("The node ID to inspect")
3352
+ nodeId: z21.string().describe("The node ID to inspect")
2891
3353
  },
2892
3354
  { readOnlyHint: true },
2893
3355
  async ({ nodeId }) => {
@@ -2909,7 +3371,7 @@ function register19(server, sendCommand) {
2909
3371
  return errorResult2(
2910
3372
  "invalid_input",
2911
3373
  `Could not process node '${nodeId}'`,
2912
- { suggestion: "Try get_node_info directly for raw data", tool: "get_node_info" }
3374
+ { suggestion: "Try get_node_data for raw data", tool: "get_node_data" }
2913
3375
  );
2914
3376
  }
2915
3377
  const bv = boundVars?.boundVariables || null;
@@ -2929,8 +3391,8 @@ function register19(server, sendCommand) {
2929
3391
  summary: `'${node.name}' (${node.type}, ${node.size.width}\xD7${node.size.height})${node.tokenCoverage != null ? `. Token coverage: ${Math.round(node.tokenCoverage * 100)}%` : ""}`,
2930
3392
  data,
2931
3393
  recommended_next: [
2932
- { tool: "update_node", reason: "Modify this node's properties", args: { nodeId } },
2933
- { tool: "analyze_color_usage", reason: "Check color token usage", args: { nodeId } }
3394
+ { tool: "set_properties", reason: "Modify this node's properties", args: { nodeIds: [nodeId] } },
3395
+ { tool: "analyze_design", reason: "Check color token usage", args: { aspect: "color", nodeId } }
2934
3396
  ]
2935
3397
  });
2936
3398
  } catch (error) {
@@ -2944,11 +3406,11 @@ function register19(server, sendCommand) {
2944
3406
  );
2945
3407
  server.tool(
2946
3408
  "search_nodes",
2947
- "Search for nodes by name and/or type within a scope. Returns matching node IDs, names, and types. Use to find specific elements before inspecting or modifying them. Follow with get_node_details or update_node.",
3409
+ "Search for nodes by name and/or type within a scope. Returns matching node IDs, names, and types. Use to find specific elements before inspecting or modifying them. Follow with get_node_details or set_properties.",
2948
3410
  {
2949
- query: z20.string().optional().describe("Name substring to search for"),
2950
- types: z20.array(z20.string()).optional().describe("Node types to filter: FRAME, TEXT, COMPONENT, INSTANCE, RECTANGLE, etc."),
2951
- parentId: z20.string().optional().describe("Scope search to children of this node (default: current page)")
3411
+ query: z21.string().optional().describe("Name substring to search for"),
3412
+ types: z21.array(z21.string()).optional().describe("Node types to filter: FRAME, TEXT, COMPONENT, INSTANCE, RECTANGLE, etc."),
3413
+ parentId: z21.string().optional().describe("Scope search to children of this node (default: current page)")
2952
3414
  },
2953
3415
  { readOnlyHint: true },
2954
3416
  async ({ query, types, parentId }) => {
@@ -2977,7 +3439,7 @@ function register19(server, sendCommand) {
2977
3439
  data,
2978
3440
  recommended_next: matches.length > 0 ? [
2979
3441
  { tool: "get_node_details", reason: "Inspect a specific node", args: { nodeId: matches[0].id } },
2980
- { tool: "navigate_to", reason: "Focus on a found node", args: { nodeId: matches[0].id } }
3442
+ { tool: "navigate", reason: "Focus on a found node", args: { action: "focus", nodeId: matches[0].id } }
2981
3443
  ] : [
2982
3444
  { tool: "get_document_overview", reason: "Check document structure" }
2983
3445
  ]
@@ -2993,7 +3455,7 @@ function register19(server, sendCommand) {
2993
3455
  );
2994
3456
  server.tool(
2995
3457
  "get_design_tokens",
2996
- "Get an overview of the design token system: variable collections, modes, variable counts, and style counts. Use to understand the design system before binding tokens or creating new variables. Follow with manage_variables or bind_tokens.",
3458
+ "Get an overview of the design token system: variable collections, modes, variable counts, and style counts. Use to understand the design system before binding tokens or creating new variables. Follow with manage_variables.",
2997
3459
  {},
2998
3460
  { readOnlyHint: true },
2999
3461
  async () => {
@@ -3019,7 +3481,7 @@ function register19(server, sendCommand) {
3019
3481
  data,
3020
3482
  recommended_next: [
3021
3483
  { tool: "manage_variables", reason: "Create or update design tokens" },
3022
- { tool: "analyze_color_usage", reason: "Check token coverage on selected nodes" }
3484
+ { tool: "analyze_design", reason: "Check token coverage on selected nodes", args: { aspect: "color" } }
3023
3485
  ]
3024
3486
  });
3025
3487
  } catch (error) {
@@ -3035,7 +3497,7 @@ function register19(server, sendCommand) {
3035
3497
  "screenshot",
3036
3498
  "Take a PNG screenshot of a node or the current view. Returns an image visible to the AI. Use to visually verify changes or inspect design details. Works for both context inspection and verification after modifications.",
3037
3499
  {
3038
- nodeId: z20.string().optional().describe("Node ID to screenshot (default: current view)")
3500
+ nodeId: z21.string().optional().describe("Node ID to screenshot (default: current view)")
3039
3501
  },
3040
3502
  { readOnlyHint: true },
3041
3503
  async ({ nodeId }) => {
@@ -3058,7 +3520,7 @@ function register19(server, sendCommand) {
3058
3520
  measured: result.size ? { node: result.node, size: result.size, children: result.children, dominantColors: result.dominantColors } : void 0,
3059
3521
  recommended_next: [
3060
3522
  { tool: "get_selection_context", reason: "Get structured data about the selection" },
3061
- { tool: "update_node", reason: "Make changes based on what you see" }
3523
+ { tool: "set_properties", reason: "Make changes based on what you see" }
3062
3524
  ]
3063
3525
  })
3064
3526
  }
@@ -3096,7 +3558,7 @@ __export(analysis_exports, {
3096
3558
  collectColorIssues: () => collectColorIssues,
3097
3559
  register: () => register20
3098
3560
  });
3099
- import { z as z21 } from "zod";
3561
+ import { z as z22 } from "zod";
3100
3562
 
3101
3563
  // src/semantic/health.ts
3102
3564
  var WEIGHTS = { color: 0.3, layout: 0.25, components: 0.2, accessibility: 0.25 };
@@ -3168,10 +3630,10 @@ function register20(server, sendCommand) {
3168
3630
  "analyze_design",
3169
3631
  `Audit the design from one aspect: color (token coverage, unbound fills/strokes), layout (auto-layout quality, spacing consistency), components (detached instances, component health), accessibility (WCAG contrast incl. large-text thresholds, touch targets, minimum text sizes), tokens (hardcoded values that match an existing variable \u2014 each finding names the variable to bind; fix in one shot with manage_variables action:tokenize fix:true), overall (runs color/layout/components/accessibility and returns a weighted 0-100 health score \u2014 good for audits and reports), voice (the file's statistical fingerprint: radius/spacing/type signatures, tokenized-paint and instance rates \u2014 what "sounds like this file"), readiness (0-100 agent-readiness score with top gaps: conventions, precedents, semantic tokens, components), ghosts (stale references to soft-deleted variables \u2014 they still render but are invisible in pickers and dead on publish), or token_debt (component tokens nobody binds, and component tokens that alias ANOTHER component's token instead of the shared upstream \u2014 both look right on the canvas and bite later). Defaults to the current selection; tokens/voice/ghosts default to the current page.`,
3170
3632
  {
3171
- aspect: z21.enum(["color", "layout", "components", "accessibility", "tokens", "overall", "voice", "readiness", "ghosts", "token_debt"]),
3172
- nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)"),
3173
- pageIds: z21.array(z21.string()).optional().describe("voice/ghosts: pages to scan (default: current page; ghosts auto-chunks any count)"),
3174
- platform: z21.enum(["desktop", "mobile"]).optional().describe("accessibility: target-size profile \u2014 desktop 24px (default), mobile 44px")
3633
+ aspect: z22.enum(["color", "layout", "components", "accessibility", "tokens", "overall", "voice", "readiness", "ghosts", "token_debt"]),
3634
+ nodeId: z22.string().optional().describe("Root node to analyze (default: current selection)"),
3635
+ pageIds: z22.array(z22.string()).optional().describe("voice/ghosts: pages to scan (default: current page; ghosts auto-chunks any count)"),
3636
+ platform: z22.enum(["desktop", "mobile"]).optional().describe("accessibility: target-size profile \u2014 desktop 24px (default), mobile 44px")
3175
3637
  },
3176
3638
  { readOnlyHint: true },
3177
3639
  async ({ aspect, nodeId, pageIds, platform }) => {
@@ -3252,7 +3714,9 @@ async function runTokenDebt(sendCommand, pageIds) {
3252
3714
  }
3253
3715
  const unused = (head.candidates ?? []).filter((c) => !used.has(c.id));
3254
3716
  const borrows = head.borrowedTokens ?? [];
3255
- const summary = `${borrows.length} borrowed token${borrows.length === 1 ? "" : "s"}, ${unused.length} unused across ${pagesScanned.length} page${pagesScanned.length === 1 ? "" : "s"}`;
3717
+ const borrowCount = head.borrowCount ?? borrows.length;
3718
+ const borrowsTruncated = borrowCount > borrows.length;
3719
+ const summary = `${borrowCount} borrowed token${borrowCount === 1 ? "" : "s"}` + (borrowsTruncated ? ` (showing ${borrows.length})` : "") + `, ${unused.length} unused across ${pagesScanned.length} page${pagesScanned.length === 1 ? "" : "s"}`;
3256
3720
  return standardResult({
3257
3721
  summary: summary + (head.noAliasLayer ? " \u2014 this file has no alias layer, so nothing counts as a foundation: the unused list is plain variables, not component tokens" : "") + (pagesScanned.length <= 1 ? " \u2014 only one page was walked; pass pageIds for the whole file before treating anything as unused" : ""),
3258
3722
  data: {
@@ -3261,6 +3725,8 @@ async function runTokenDebt(sendCommand, pageIds) {
3261
3725
  instancesScanned,
3262
3726
  foundationGroups: head.foundationGroups,
3263
3727
  borrowedTokens: borrows,
3728
+ borrowCount,
3729
+ borrowsTruncated,
3264
3730
  unusedTokens: unused.slice(0, 40),
3265
3731
  unusedCount: unused.length,
3266
3732
  allScopesByGroup: head.allScopesByGroup,
@@ -3404,9 +3870,9 @@ async function runOverallAudit(aspectHandlers, nodeId) {
3404
3870
  function registerAnalysisTools(server, sendCommand) {
3405
3871
  server.tool(
3406
3872
  "analyze_color_usage",
3407
- "Audit color consistency and design token coverage for a node tree. Reports which fill/stroke colors are not bound to variables. Use after get_selection_context to diagnose token gaps. Follow with bind_tokens to fix issues.",
3873
+ "Audit color consistency and design token coverage for a node tree. Reports which fill/stroke colors are not bound to variables. Use after get_selection_context to diagnose token gaps. Follow with manage_variables action:tokenize to fix issues.",
3408
3874
  {
3409
- nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)")
3875
+ nodeId: z22.string().optional().describe("Root node to analyze (default: current selection)")
3410
3876
  },
3411
3877
  { readOnlyHint: true },
3412
3878
  async ({ nodeId }) => {
@@ -3473,10 +3939,10 @@ function registerAnalysisTools(server, sendCommand) {
3473
3939
  data,
3474
3940
  warnings,
3475
3941
  recommended_next: unboundColors.length > 0 ? [
3476
- { tool: "bind_tokens", reason: "Bind unbound colors to design tokens" },
3942
+ { tool: "manage_variables", reason: "Bind unbound colors that match an existing variable", args: { action: "tokenize" } },
3477
3943
  { tool: "get_design_tokens", reason: "See available tokens for binding" }
3478
3944
  ] : [
3479
- { tool: "analyze_layout_quality", reason: "Check layout quality next" }
3945
+ { tool: "analyze_design", reason: "Check layout quality next", args: { aspect: "layout" } }
3480
3946
  ]
3481
3947
  });
3482
3948
  } catch (error) {
@@ -3490,9 +3956,9 @@ function registerAnalysisTools(server, sendCommand) {
3490
3956
  );
3491
3957
  server.tool(
3492
3958
  "analyze_layout_quality",
3493
- "Check layout quality: auto-layout usage, magic numbers in spacing/padding, and sizing consistency. Use after get_selection_context to find layout improvements. Follow with set_auto_layout to fix issues.",
3959
+ "Check layout quality: auto-layout usage, magic numbers in spacing/padding, and sizing consistency. Use after get_selection_context to find layout improvements. Follow with set_properties (auto-layout) to fix issues.",
3494
3960
  {
3495
- nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)")
3961
+ nodeId: z22.string().optional().describe("Root node to analyze (default: current selection)")
3496
3962
  },
3497
3963
  { readOnlyHint: true },
3498
3964
  async ({ nodeId }) => {
@@ -3530,7 +3996,7 @@ function registerAnalysisTools(server, sendCommand) {
3530
3996
  message: i.issue,
3531
3997
  nodeId: i.nodeId
3532
3998
  })),
3533
- recommended_next: issues.length > 0 ? [{ tool: "set_auto_layout", reason: "Fix layout issues on specific frames" }] : [{ tool: "analyze_color_usage", reason: "Check color token coverage next" }]
3999
+ recommended_next: issues.length > 0 ? [{ tool: "set_properties", reason: "Fix layout issues on specific frames (auto-layout, spacing, sizing)" }] : [{ tool: "analyze_design", reason: "Check color token coverage next", args: { aspect: "color" } }]
3534
4000
  });
3535
4001
  } catch (error) {
3536
4002
  return errorResult2(
@@ -3543,9 +4009,9 @@ function registerAnalysisTools(server, sendCommand) {
3543
4009
  );
3544
4010
  server.tool(
3545
4011
  "analyze_component_health",
3546
- "Check component system health: detached instances, unused components, and override consistency. Use to audit design system adherence. Follow with create_component or update_instance to fix issues.",
4012
+ "Check component system health: detached instances, unused components, and override consistency. Use to audit design system adherence. Follow with manage_components to fix issues.",
3547
4013
  {
3548
- nodeId: z21.string().optional().describe("Scope to analyze (default: current page)")
4014
+ nodeId: z22.string().optional().describe("Scope to analyze (default: current page)")
3549
4015
  },
3550
4016
  { readOnlyHint: true },
3551
4017
  async ({ nodeId }) => {
@@ -3596,10 +4062,10 @@ function registerAnalysisTools(server, sendCommand) {
3596
4062
  );
3597
4063
  server.tool(
3598
4064
  "analyze_accessibility",
3599
- "Check accessibility: text contrast ratios against backgrounds, interactive target sizes (WCAG 2.5.8: 24px desktop default, 44px with platform:mobile). Use to ensure designs meet WCAG guidelines. Follow with update_node to fix contrast or sizing issues.",
4065
+ "Check accessibility: text contrast ratios against backgrounds, interactive target sizes (WCAG 2.5.8: 24px desktop default, 44px with platform:mobile). Use to ensure designs meet WCAG guidelines. Follow with set_properties to fix contrast or sizing issues.",
3600
4066
  {
3601
- nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)"),
3602
- platform: z21.enum(["desktop", "mobile"]).optional().describe("Target-size profile: desktop 24px (default), mobile 44px")
4067
+ nodeId: z22.string().optional().describe("Root node to analyze (default: current selection)"),
4068
+ platform: z22.enum(["desktop", "mobile"]).optional().describe("Target-size profile: desktop 24px (default), mobile 44px")
3603
4069
  },
3604
4070
  { readOnlyHint: true },
3605
4071
  async ({ nodeId, platform }) => {
@@ -3630,7 +4096,7 @@ function registerAnalysisTools(server, sendCommand) {
3630
4096
  message: i.issue,
3631
4097
  nodeId: i.nodeId
3632
4098
  })),
3633
- recommended_next: issues.length > 0 ? [{ tool: "update_node", reason: "Fix accessibility issues" }] : [{ tool: "validate_design_rules", reason: "Run full design validation" }]
4099
+ recommended_next: issues.length > 0 ? [{ tool: "set_properties", reason: "Fix accessibility issues" }] : [{ tool: "validate_design_rules", reason: "Run full design validation" }]
3634
4100
  });
3635
4101
  } catch (error) {
3636
4102
  return errorResult2(
@@ -3641,14 +4107,15 @@ function registerAnalysisTools(server, sendCommand) {
3641
4107
  }
3642
4108
  }
3643
4109
  );
3644
- const CHECKPOINT_KEY = "relai.checkpoint";
4110
+ const CHECKPOINT_LIMIT = 200;
4111
+ const checkpoints = /* @__PURE__ */ new Map();
3645
4112
  server.tool(
3646
4113
  "diff_nodes",
3647
- `Compare two nodes and list their differences (fills, strokes, size, layout, etc.), or audit changes over time with checkpoints: checkpoint:"save" snapshots nodeIdA's key properties; checkpoint:"compare" later diffs the current state against that snapshot \u2014 useful before/after an editing session to show the designer exactly what changed.`,
4114
+ `Compare two nodes and list their differences (fills, strokes, size, layout, etc.), or audit changes over time with checkpoints: checkpoint:"save" snapshots nodeIdA's key properties; checkpoint:"compare" later diffs the current state against that snapshot \u2014 useful before/after an editing session to show the designer exactly what changed. Checkpoints are kept in the Relai server's memory, never written to the file, and a server restart clears them.`,
3648
4115
  {
3649
- nodeIdA: z21.string().describe("First node ID (or the checkpoint target)"),
3650
- nodeIdB: z21.string().optional().describe("Second node ID (omit when using checkpoint)"),
3651
- checkpoint: z21.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now; compare: diff current vs saved snapshot")
4116
+ nodeIdA: z22.string().describe("First node ID (or the checkpoint target)"),
4117
+ nodeIdB: z22.string().optional().describe("Second node ID (omit when using checkpoint)"),
4118
+ checkpoint: z22.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now (held in server memory until the server restarts, not in the file); compare: diff current vs saved snapshot")
3652
4119
  },
3653
4120
  { readOnlyHint: true },
3654
4121
  async ({ nodeIdA, nodeIdB, checkpoint }) => {
@@ -3656,32 +4123,25 @@ function registerAnalysisTools(server, sendCommand) {
3656
4123
  if (checkpoint === "save") {
3657
4124
  const node = await sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 });
3658
4125
  if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
3659
- const summary = normalizeNode(node);
3660
- await sendCommand("set_plugin_data", {
3661
- nodeId: nodeIdA,
3662
- key: CHECKPOINT_KEY,
3663
- value: JSON.stringify({ ts: Date.now(), name: node.name, summary })
3664
- });
4126
+ checkpoints.delete(nodeIdA);
4127
+ checkpoints.set(nodeIdA, { ts: Date.now(), name: node.name, summary: normalizeNode(node) });
4128
+ if (checkpoints.size > CHECKPOINT_LIMIT) checkpoints.delete(checkpoints.keys().next().value);
3665
4129
  return standardResult({
3666
- summary: `Checkpoint saved for '${node.name}' (${nodeIdA}). Run diff_nodes with checkpoint:"compare" after editing to see what changed.`,
4130
+ summary: `Checkpoint saved for '${node.name}' (${nodeIdA}) in server memory. Run diff_nodes with checkpoint:"compare" after editing to see what changed.`,
3667
4131
  data: { nodeId: nodeIdA, saved: true },
3668
4132
  recommended_next: []
3669
4133
  });
3670
4134
  }
3671
4135
  if (checkpoint === "compare") {
3672
- const [node, stored] = await Promise.all([
3673
- sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 }),
3674
- sendCommand("get_plugin_data", { nodeId: nodeIdA, key: CHECKPOINT_KEY })
3675
- ]);
3676
- if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
3677
- const raw = typeof stored === "string" ? stored : stored?.value;
3678
- if (!raw) {
3679
- return errorResult2("invalid_input", `No checkpoint saved on '${nodeIdA}'`, {
3680
- suggestion: 'Save one first with checkpoint:"save"',
4136
+ const saved = checkpoints.get(nodeIdA);
4137
+ if (!saved) {
4138
+ return errorResult2("invalid_input", `No checkpoint for '${nodeIdA}' in this server session`, {
4139
+ suggestion: `Save one first with checkpoint:"save" \u2014 checkpoints are held in server memory (the latest ${CHECKPOINT_LIMIT}) and a server restart clears them`,
3681
4140
  tool: "diff_nodes"
3682
4141
  });
3683
4142
  }
3684
- const saved = JSON.parse(raw);
4143
+ const node = await sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 });
4144
+ if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
3685
4145
  const before = saved.summary;
3686
4146
  const after = normalizeNode(node);
3687
4147
  const differences2 = [];
@@ -3697,7 +4157,7 @@ function registerAnalysisTools(server, sendCommand) {
3697
4157
  compareField(differences2, "layout.gap", before.layout?.gap, after.layout?.gap);
3698
4158
  compareField(differences2, "layout.padding", before.layout?.padding, after.layout?.padding);
3699
4159
  }
3700
- const age = Math.round((Date.now() - (saved.ts ?? Date.now())) / 1e3);
4160
+ const age = Math.round((Date.now() - saved.ts) / 1e3);
3701
4161
  return standardResult({
3702
4162
  summary: differences2.length === 0 ? `'${node.name}' is unchanged since the checkpoint ${age}s ago` : `${differences2.length} propert${differences2.length === 1 ? "y" : "ies"} changed on '${node.name}' since the checkpoint ${age}s ago`,
3703
4163
  data: {
@@ -3745,7 +4205,7 @@ function registerAnalysisTools(server, sendCommand) {
3745
4205
  return standardResult({
3746
4206
  summary: differences.length === 0 ? `'${nodeA.name}' and '${nodeB.name}' are identical in key properties` : `${differences.length} difference(s) between '${nodeA.name}' and '${nodeB.name}'`,
3747
4207
  data,
3748
- recommended_next: differences.length > 0 ? [{ tool: "update_node", reason: "Align properties between nodes" }] : []
4208
+ recommended_next: differences.length > 0 ? [{ tool: "set_properties", reason: "Align properties between nodes" }] : []
3749
4209
  });
3750
4210
  } catch (error) {
3751
4211
  return errorResult2(
@@ -3832,7 +4292,7 @@ function analyzeLayoutNode(node, issues, _counters) {
3832
4292
  nodeName: node.name,
3833
4293
  issue: `Frame '${node.name}' has ${node.children.length} children but no auto-layout`,
3834
4294
  severity: "warning",
3835
- suggestion: "Add auto-layout with set_auto_layout for responsive behavior"
4295
+ suggestion: "Add auto-layout with set_properties for responsive behavior"
3836
4296
  });
3837
4297
  }
3838
4298
  }
@@ -3946,14 +4406,14 @@ var verification_exports = {};
3946
4406
  __export(verification_exports, {
3947
4407
  register: () => register21
3948
4408
  });
3949
- import { z as z22 } from "zod";
4409
+ import { z as z23 } from "zod";
3950
4410
  function register21(server, sendCommand) {
3951
4411
  server.tool(
3952
4412
  "verify_changes",
3953
- "Verify that a node's current properties match expected values. Use after update_node, set_auto_layout, or batch_execute to confirm changes applied correctly. Returns match/mismatch for each checked field.",
4413
+ "Verify that a node's current properties match expected values. Use after set_properties or batch_execute to confirm changes applied correctly. Returns match/mismatch for each checked field.",
3954
4414
  {
3955
- nodeId: z22.string().describe("Node ID to verify"),
3956
- expected: z22.record(z22.string(), z22.unknown()).describe("Expected property values, e.g. { fill: '#3B82F6', cornerRadius: 8 }")
4415
+ nodeId: z23.string().describe("Node ID to verify"),
4416
+ expected: z23.record(z23.string(), z23.unknown()).describe("Expected property values, e.g. { fill: '#3B82F6', cornerRadius: 8 }")
3957
4417
  },
3958
4418
  { readOnlyHint: true },
3959
4419
  async ({ nodeId, expected }) => {
@@ -3989,7 +4449,7 @@ function register21(server, sendCommand) {
3989
4449
  return standardResult({
3990
4450
  summary: allMatch ? `All ${fields.length} checked properties match expected values` : `${fields.filter((f) => !f.match).length} of ${fields.length} properties don't match expected values`,
3991
4451
  data,
3992
- recommended_next: allMatch ? [{ tool: "screenshot", reason: "Visually confirm the changes" }] : [{ tool: "update_node", reason: "Re-apply changes that didn't match", args: { nodeId } }]
4452
+ recommended_next: allMatch ? [{ tool: "screenshot", reason: "Visually confirm the changes" }] : [{ tool: "set_properties", reason: "Re-apply changes that didn't match", args: { nodeIds: [nodeId] } }]
3993
4453
  });
3994
4454
  } catch (error) {
3995
4455
  return errorResult2(
@@ -4004,8 +4464,8 @@ function register21(server, sendCommand) {
4004
4464
  "validate_design_rules",
4005
4465
  "Run design quality rules on a node: token coverage, token scopes, cross-component token borrowing, dimension stability across states, fixed-size text that would clip, auto-layout usage, naming conventions, and accessibility basics. Use after modifications and before calling a component done \u2014 these rules catch the faults that look perfect on the canvas and surface weeks later in someone else's file. Returns pass/fail for each rule with fix suggestions.",
4006
4466
  {
4007
- nodeId: z22.string().optional().describe("Root node to validate (default: current selection)"),
4008
- platform: z22.enum(["desktop", "mobile"]).optional().describe("Target-size profile for the touch rule: desktop 24px (default), mobile 44px")
4467
+ nodeId: z23.string().optional().describe("Root node to validate (default: current selection)"),
4468
+ platform: z23.enum(["desktop", "mobile"]).optional().describe("Target-size profile for the touch rule: desktop 24px (default), mobile 44px")
4009
4469
  },
4010
4470
  { readOnlyHint: true },
4011
4471
  async ({ nodeId, platform }) => {
@@ -4044,7 +4504,7 @@ function register21(server, sendCommand) {
4044
4504
  severity: coverage >= 0.5 ? "warning" : "error",
4045
4505
  message: `Token coverage: ${Math.round(coverage * 100)}%${coverageDetail} (target: 80%+)`,
4046
4506
  nodeId: targetId,
4047
- fix: coverage < 0.8 ? { tool: "bind_tokens", reason: "Bind unbound properties to design tokens", args: { nodeId: targetId } } : void 0
4507
+ fix: coverage < 0.8 ? { tool: "manage_variables", reason: "Bind hardcoded values that match an existing variable", args: { action: "tokenize", nodeId: targetId } } : void 0
4048
4508
  });
4049
4509
  if (nodeInfo.type === "FRAME" || nodeInfo.type === "COMPONENT") {
4050
4510
  const hasAutoLayout = nodeInfo.layoutMode && nodeInfo.layoutMode !== "NONE";
@@ -4055,7 +4515,7 @@ function register21(server, sendCommand) {
4055
4515
  severity: "warning",
4056
4516
  message: hasAutoLayout ? `Auto-layout: ${nodeInfo.layoutMode}` : hasChildren ? `No auto-layout on frame with ${nodeInfo.children.length} children` : "No children requiring auto-layout",
4057
4517
  nodeId: targetId,
4058
- fix: !hasAutoLayout && hasChildren ? { tool: "set_auto_layout", reason: "Add auto-layout for responsive behavior", args: { nodeId: targetId } } : void 0
4518
+ fix: !hasAutoLayout && hasChildren ? { tool: "set_properties", reason: "Add auto-layout for responsive behavior", args: { nodeIds: [targetId] } } : void 0
4059
4519
  });
4060
4520
  }
4061
4521
  const hasDefaultName = /^(Frame|Rectangle|Ellipse|Group|Text|Vector|Line|Polygon|Star)\s*\d*$/.test(nodeInfo.name);
@@ -4065,7 +4525,7 @@ function register21(server, sendCommand) {
4065
4525
  severity: "info",
4066
4526
  message: hasDefaultName ? `'${nodeInfo.name}' uses a default Figma name \u2014 rename for clarity` : `Name '${nodeInfo.name}' looks intentional`,
4067
4527
  nodeId: targetId,
4068
- fix: hasDefaultName ? { tool: "update_node", reason: "Rename with a semantic name", args: { nodeId: targetId } } : void 0
4528
+ fix: hasDefaultName ? { tool: "set_properties", reason: "Rename with a semantic name", args: { nodeIds: [targetId] } } : void 0
4069
4529
  });
4070
4530
  try {
4071
4531
  const drift = await sendCommand("audit_voice_drift", { nodeId: targetId }, 6e4);
@@ -4092,7 +4552,7 @@ function register21(server, sendCommand) {
4092
4552
  severity: "warning",
4093
4553
  message: adequate ? `Target size: ${Math.round(w)}\xD7${Math.round(h)}px (OK, minimum ${minTarget}px)` : `Target size: ${Math.round(w)}\xD7${Math.round(h)}px (minimum ${minTarget}\xD7${minTarget}px \u2014 WCAG 2.5.8${minTarget >= 44 ? ", mobile" : ""})`,
4094
4554
  nodeId: targetId,
4095
- fix: !adequate ? { tool: "update_node", reason: "Increase size to meet the target-size minimum", args: { nodeId: targetId } } : void 0
4555
+ fix: !adequate ? { tool: "set_properties", reason: "Increase size to meet the target-size minimum", args: { nodeIds: [targetId] } } : void 0
4096
4556
  });
4097
4557
  }
4098
4558
  try {
@@ -4206,8 +4666,8 @@ function register21(server, sendCommand) {
4206
4666
  "verify_visual",
4207
4667
  "The write\u2192see\u2192assert loop in one call: screenshots a node AND (optionally) checks expected property values. Returns the image plus structured pass/fail. Use after visual edits instead of separate screenshot + verify_changes calls.",
4208
4668
  {
4209
- nodeId: z22.string().describe("Node to verify"),
4210
- expected: z22.record(z22.string(), z22.unknown()).optional().describe("Expected property values, e.g. { cornerRadius: 8, width: 320 }")
4669
+ nodeId: z23.string().describe("Node to verify"),
4670
+ expected: z23.record(z23.string(), z23.unknown()).optional().describe("Expected property values, e.g. { cornerRadius: 8, width: 320 }")
4211
4671
  },
4212
4672
  { readOnlyHint: true },
4213
4673
  async ({ nodeId, expected }) => {
@@ -4305,7 +4765,7 @@ function registerRoomTool(server, joinRoom, listRooms) {
4305
4765
  "join_room",
4306
4766
  "Connect to a specific Figma plugin instance. Usually unnecessary \u2014 pairing is automatic when one plugin is connected. Call with no arguments to LIST the connected plugins (room + file name) instead of typing the room by hand, then call again with the room you want.",
4307
4767
  {
4308
- room: z23.string().optional().describe("Room name shown in the Figma plugin. Omit to list connected rooms.")
4768
+ room: z24.string().optional().describe("Room name shown in the Figma plugin. Omit to list connected rooms.")
4309
4769
  },
4310
4770
  async ({ room }) => {
4311
4771
  try {
@@ -4328,21 +4788,28 @@ ${lines.join("\n")}`
4328
4788
  }]
4329
4789
  };
4330
4790
  }
4331
- await joinRoom(room);
4332
- return {
4333
- content: [
4334
- { type: "text", text: `Successfully joined room: ${room}` }
4335
- ]
4791
+ const report = await joinRoom(room);
4792
+ const where = report.pluginPresent ? ` \u2014 "${report.file ?? "unnamed file"}"${report.branchOf ? `, a branch of "${report.branchOf}"` : ""}.` : " \u2014 no plugin is there yet; commands will fail until that file's plugin opens.";
4793
+ const meta = {
4794
+ v: 1,
4795
+ calls: [],
4796
+ target: {
4797
+ room: report.room,
4798
+ ...report.file ? { file: report.file } : {},
4799
+ ...report.branchOf ? { branchOf: report.branchOf } : {}
4800
+ }
4336
4801
  };
4337
- } catch (error) {
4338
4802
  return {
4339
4803
  content: [
4340
4804
  {
4341
4805
  type: "text",
4342
- text: `Error joining room: ${error instanceof Error ? error.message : String(error)}`
4806
+ text: `Joined room ${room}${where} Every agent in this MCP server process (one client session) shares this room \u2014 a join_room from any of them moves them all.`
4343
4807
  }
4344
- ]
4808
+ ],
4809
+ _meta: { [CALL_META_KEY]: meta }
4345
4810
  };
4811
+ } catch (error) {
4812
+ return errorResult(error);
4346
4813
  }
4347
4814
  }
4348
4815
  );
@@ -4350,9 +4817,9 @@ ${lines.join("\n")}`
4350
4817
  function registerEventsTool(server, consumeEvents, getSessionLog) {
4351
4818
  server.tool(
4352
4819
  "get_events",
4353
- "Activity since the last check. scope 'designer' (default): the designer's selection/node/page changes (also piggybacked as designer_events on command results). scope 'agent': this session's own command log \u2014 every plugin command sent, with success/duration \u2014 useful for summarizing what was changed. 'all': both.",
4820
+ "Activity since the last check. scope 'designer' (default): the designer's selection/node/page changes (also piggybacked as designer_events on command results). scope 'agent': this session's own command log \u2014 every plugin command sent, with success/duration, outcome (applied/not_applied/unknown) and error code \u2014 useful for summarizing what was changed. 'all': both.",
4354
4821
  {
4355
- scope: z23.enum(["designer", "agent", "all"]).optional()
4822
+ scope: z24.enum(["designer", "agent", "all"]).optional()
4356
4823
  },
4357
4824
  { readOnlyHint: true },
4358
4825
  async ({ scope = "designer" }) => {
@@ -4376,8 +4843,9 @@ function registerEventsTool(server, consumeEvents, getSessionLog) {
4376
4843
  );
4377
4844
  }
4378
4845
  function registerAllTools(server, sendCommand) {
4846
+ const env = withCallEnvelope(server, sendCommand);
4379
4847
  for (const [mod] of moduleCategories) {
4380
- mod.register(server, sendCommand);
4848
+ mod.register(env.server, env.sendCommand);
4381
4849
  }
4382
4850
  }
4383
4851
  function listToolCatalog() {
@@ -4396,8 +4864,92 @@ function listToolCatalog() {
4396
4864
  return catalog;
4397
4865
  }
4398
4866
 
4867
+ // src/server.ts
4868
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4869
+ function createServer() {
4870
+ return new McpServer(
4871
+ {
4872
+ name: "Relai",
4873
+ version: VERSION
4874
+ },
4875
+ {
4876
+ instructions: `
4877
+ You control Figma through ~30 consolidated tools. Pairing with the Figma plugin is automatic \u2014 just call any tool; join_room is only needed if an error reports multiple plugins. With several files open, pass room (or expect_file) on each call instead of relying on the session's default room.
4878
+
4879
+ \u{1F4D6} UNDERSTAND (start here):
4880
+ get_document_overview \u2014 structure, page/component/style/variable counts
4881
+ get_selection_context \u2014 full context for what the designer selected
4882
+ get_node_details \u2014 deep single-node inspection (CSS, token bindings)
4883
+ search_nodes \u2014 find nodes by name/type \xB7 get_node_data \u2014 raw data/tree/css
4884
+ get_design_tokens \u2014 variable collections, modes, styles
4885
+ screenshot \u2014 see the canvas (use this to verify your work visually)
4886
+
4887
+ \u{1F50D} DIAGNOSE:
4888
+ analyze_design \u2014 aspect: color/layout/components/accessibility, or
4889
+ "overall" for a weighted 0-100 health score across all four
4890
+ diff_nodes \u2014 compare two nodes, or checkpoint save/compare to audit
4891
+ what changed on a node over an editing session
4892
+
4893
+ \u270F\uFE0F EDIT:
4894
+ create_node \u2014 any node type (rectangle/frame/text/svg/image/\u2026)
4895
+ set_properties \u2014 one call for geometry, fills, strokes, effects, text,
4896
+ auto-layout, constraints, style/variable bindings, on one or many nodes
4897
+ set_text \u2014 single, bulk, or character-range text edits
4898
+ edit_structure \u2014 group/reparent/reorder/clone/flatten/boolean/delete
4899
+ navigate \u2014 focus/select/viewport/switch_page \xB7 manage_pages
4900
+
4901
+ \u{1F9F1} DESIGN SYSTEM:
4902
+ manage_components \u2014 create/variants/instantiate/overrides/props/detach
4903
+ manage_variables \u2014 collections, modes, tokens, bind/unbind
4904
+ manage_styles \u2014 paint/text/effect/grid styles
4905
+ import_from_library \u2014 bring in library components/styles/variables
4906
+
4907
+ \u{1F4E6} ASSETS: export_asset (PNG/JPG/SVG/PDF) \xB7 add_image (URL or fill)
4908
+ \u{1F4DD} ANNOTATIONS: annotate (Dev Mode annotations)
4909
+ \u{1F4AC} COMMENTS: manage_comments \u2014 read/apply/reply to file comments
4910
+ (needs FIGMA_TOKEN env; the tool explains setup when missing)
4911
+
4912
+ \u2705 VERIFY: verify_changes \xB7 validate_design_rules
4913
+
4914
+ \u26A1 ADVANCED:
4915
+ batch_execute \u2014 run many plugin-level commands in one round-trip
4916
+ execute_figma \u2014 run JavaScript against the Figma Plugin API directly
4917
+ (the escape hatch when no tool fits; small incremental scripts,
4918
+ screenshot between steps; the designer can disable it)
4919
+
4920
+ OPERATING PRINCIPLES:
4921
+ - Inspect context BEFORE changing things; don't assume selection state
4922
+ - After visual edits, verify: screenshot or verify_changes
4923
+ - Colors are RGBA in 0-1 range (Figma format), not 0-255
4924
+ - layoutSizing FILL requires the node's PARENT to have auto-layout;
4925
+ set layoutMode on a frame before padding/alignment/spacing
4926
+ - Responses include recommended_next tools \u2014 follow them
4927
+ - Errors name the node, its type, and the fix \u2014 read them, don't retry blindly
4928
+
4929
+ READING RESULTS:
4930
+ - Writes and failures end with a "[relai]" line saying whether Figma changed:
4931
+ applied (the command ran) \xB7 not_applied (nothing changed) \xB7
4932
+ unknown (it may have changed \u2014 read the canvas before retrying;
4933
+ never resend a write blindly)
4934
+ - Failures set isError and name an error code (e.g. timeout, guard_blocked);
4935
+ the full receipt is in _meta["${CALL_META_KEY}"]
4936
+ - Text over 50,000 characters is cut; the cut notice names a local file
4937
+ that holds the full result
4938
+
4939
+ TARGETING A FILE (every tool except join_room and get_events):
4940
+ - room: send this one call to that plugin room; the session's default room
4941
+ (set by join_room, shared by every agent in this server process) stays put
4942
+ - expect_file: refuse unless the target file's name is exactly this
4943
+ (a branch has its own name); a refusal changes nothing (not_applied)
4944
+ - room_changed: the plugin moved to a different file since the last call;
4945
+ nothing was sent, and sending again continues in the new file
4946
+ `
4947
+ }
4948
+ );
4949
+ }
4950
+
4399
4951
  // ../../docs/skills/figma-plugin-api.md
4400
- 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- **`setBoundVariable` takes a Variable object, not an id string** \u2014 the string form throws `Cannot call setBoundVariable with a variable id. Please pass a variable object`. Fetch the variable first (`getVariableByIdAsync`, or a map built from `getLocalVariablesAsync`) and pass the object itself.\n- **Rebinding width/height on a node INSIDE an instance is a silent no-op** \u2014 `setBoundVariable` returns without error and `boundVariables` still shows the old id afterwards. Only `setBoundVariable(field, null)` (unbind) persists there. Stuck overrides \u2014 bindings an instance kept from a main that has since been cleaned \u2014 can therefore never be re-pointed from code, only cleared; and if the main is already clean, clearing IS the correct repair (the node falls back to the main\'s shape, pixels stay put). Measured on 301 soft-deleted `Size/height/12` bindings (2026-08-12): rebind reported success \xD7301 and changed nothing; unbind cleared every one. Always read the field back after writing \u2014 success without verification is how this one hides.\n- **One broken component set crashes a whole-file property scan**: `instance.componentProperties` (and a set\'s `componentPropertyDefinitions`) throw `Component set has existing errors` when the backing set has variant conflicts \u2014 and the set may not even be on any page (a soft-deleted draft still referenced by one forgotten instance). A file-wide audit that reads properties uncaught dies on the first such instance. Guard each read with try/catch, report skips, and find the culprit cheaply: collect unique `mainComponent.parent` sets across instances and probe `componentPropertyDefinitions` on each (a few hundred sets) instead of touching every instance.\n- **Dot-prefixed components are never published**, so their keys 404 on every REST endpoint. Figma hides `.`-prefixed assets from the publish dialog; they exist only nested inside published parents. When resolving a key \u2192 file via `/v1/components/{key}`, skip names starting with `.` \u2014 and note a component SET\'s key resolves only at `/v1/component_sets/{key}`, never at `/v1/components/{key}` (and vice versa). Relatedly, `/v1/files/{key}/components` returns the individual VARIANTS (`size=md, state=default`); the components a designer names live in `/component_sets`.\n- **Freshly created frames and components count their strokes into hug sizing** \u2014 `strokesIncludedInLayout` defaults to `true`, so a hairline bottom divider makes an auto-layout row report 40.5 instead of 40, and an outlined container reports two extra pixels. The half-pixel survives into every instance and into any math you do on the height. Components that should keep integer dimensions need `strokesIncludedInLayout = false` set explicitly; existing hand-built components in a file often already have it off, which is why cloning them behaves differently from building from scratch.\n- **Converting a frame to a Slot in the Figma UI freezes its sizing at FIXED and strips the property references of everything inside it.** The converted node keeps the width/height it had at conversion time as a hard FIXED size (a later `layoutSizingHorizontal = "HUG"` is rejected; `"FILL"` is accepted), and a TEXT child that carried a `characters` reference (`label#\u2026`) comes out with the reference gone while the component property itself survives on the set \u2014 the panel shows the control, typing into it does nothing. After any frame\u2192Slot conversion: re-assert the slot\'s sizing explicitly and re-wire `componentPropertyReferences` on its former children. (`figma.createSlot` does not exist \u2014 conversion is UI-only, so this cleanup is always manual-plus-script.)\n- **`combineAsVariants` re-keys every component property and can drop a TEXT property\'s `characters` reference on the way.** The ids in property keys (`title#1033:0`) belong to the standalone components; combining mints new set-level keys (`title#7527:4`) \u2014 so any key strings you saved before combining are stale afterwards. Worse, the re-wiring is lossy: in one measured combine the `title` reference survived on all variants while the `description` reference vanished from all of them, with no error. After every combine, audit `componentPropertyReferences` across all variants against the set\'s `componentPropertyDefinitions` and re-wire the orphans (survival is luck, not a rule \u2014 this is the same disease as duplicated variants keeping no references, in a different coat).\n<!-- PITFALLS:END -->\n\nAlso: `resize(w, h)` throws on `w <= 0 || h <= 0`; `layoutWrap = "WRAP"` is HORIZONTAL-only, `counterAxisAlignItems = "BASELINE"` too; writing to `locked` nodes is allowed by the API but surprises designers \u2014 check first.\n\n## Return values\n\nReturn JSON-serializable data (the bridge summarizes nodes automatically and truncates >50k chars). Prefer returning `{ id, name, type }` summaries over whole nodes. `console.log` is captured and returned in `logs`.\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
4952
+ var figma_plugin_api_default = "# Figma Plugin API Cheat Sheet \u2014 for `execute_figma`\n\nRules and patterns for writing code that runs in the plugin sandbox. Work in small incremental scripts and `screenshot` between steps.\n\n## Non-negotiable rules (dynamic-page mode)\n\n- **Node lookup is async**: `await figma.getNodeByIdAsync(id)` \u2014 `getNodeById` throws in this plugin.\n- **Load pages before traversal**: `await page.loadAsync()` before `page.findAll(...)` on a non-current page; `figma.currentPage` is always loaded.\n- **Load fonts before ANY text edit**: `await figma.loadFontAsync(textNode.fontName)` \u2014 if `fontName === figma.mixed`, load every range: `for (const f of textNode.getRangeAllFontNames(0, textNode.characters.length)) await figma.loadFontAsync(f)`.\n- **`figma.mixed` is a Symbol**: properties like `fontSize`, `cornerRadius`, `fills` return it when values differ across ranges/children. Check `=== figma.mixed` before using; never JSON-serialize it.\n\n## Common patterns\n\n```js\n// Selection\nconst sel = figma.currentPage.selection; // read\nfigma.currentPage.selection = [node]; // write\n\n// Create + place\nconst frame = figma.createFrame();\nframe.resize(320, 200); // width/height are read-only; use resize()\nparent.appendChild(frame); // default parent is currentPage\n\n// Fills/strokes are ARRAYS and must be reassigned wholesale\nnode.fills = [{ type: \"SOLID\", color: { r: 1, g: 0.5, b: 0 } }]; // rgb 0-1, no alpha key \u2014 use opacity\nconst fills = JSON.parse(JSON.stringify(node.fills)); // clone before mutating\nfills[0].color.r = 0; node.fills = fills;\n\n// Auto-layout: set layoutMode FIRST, then padding/spacing/align\nframe.layoutMode = \"VERTICAL\";\nframe.itemSpacing = 8; frame.paddingTop = 16;\nchild.layoutSizingHorizontal = \"FILL\"; // requires PARENT with auto-layout\n\n// Components\nconst comp = await figma.importComponentByKeyAsync(key); // team library\nconst inst = comp.createInstance();\ninst.setProperties({ Variant: \"Primary\" }); // throws on unknown property names\n\n// Variables\nconst collections = await figma.variables.getLocalVariableCollectionsAsync();\nconst v = figma.variables.createVariable(\"name\", collection, \"COLOR\");\nv.setValueForMode(modeId, { r: 0, g: 0, b: 0 });\nnode.setBoundVariable(\"fills\", v); // binding paint needs figma.variables.setBoundVariableForPaint on the paint object for fills in older APIs \u2014 prefer setBoundVariable where available\n\n// Export (returns Uint8Array)\nconst bytes = await node.exportAsync({ format: \"PNG\", constraint: { type: \"SCALE\", value: 2 } });\n```\n\n## `relai.*` helpers \u2014 the shortest path is the correct one\n\nAlongside `figma`, scripts get a `relai` object whose helpers are immune to the pitfalls below. Prefer them:\n\n```js\nawait relai.text(parent, \"Hello\", { font: {family:\"IBM Plex Mono\", style:\"Regular\"}, size: 14, color: {r:0,g:0,b:0} });\n // loads the font BEFORE writing characters\nconst card = relai.autoLayout(\"VERTICAL\", { name: \"Card\", itemSpacing: 8 });\n // auto-layout frame, both axes hugging\nrelai.set(node, { layoutMode: \"HORIZONTAL\", width: 320, opacity: 0.9 });\n // layoutMode applied first; width/height via resize()\nrelai.hug(node); // HUG that sticks \u2014 call after appending children\nrelai.focusRing(button); // clipsContent + double spread shadows that render\nconst page = await relai.page(p => p.children.some(c => c.name === \"Button\"));\n // find pages by CONTENT \u2014 names get renamed\nconst titles = relai.query('FRAME[name^=Card] > TEXT');\n // CSS-like search on the current page; relai.query(node, sel) scopes to a subtree.\n // Supported: TYPE, *, [name=] [name*=] [name^=] [name$=], descendant, >, comma.\n // NOT supported: pseudo-classes, dot-paths, sibling combinators \u2014 use findAll.\nrelai.placeholder(section); // construction veil so the designer sees work-in-progress\nrelai.placeholder(section, false); // ALWAYS remove when the section is done\n```\n\nTwo important facts: **scripts are NOT atomic** \u2014 on error, changes made before the throw persist, so keep scripts small and clean up after failures; and results may carry a `warnings` array for silent mistakes the lint catches on relai-created nodes and any node ids you return (e.g. spread shadows on a non-clipping frame). Convention: **return every created/mutated node id** (`return { createdNodeIds: [...] }`) \u2014 it powers both follow-up calls and the lint.\n\n## Pitfalls that throw (or silently do the wrong thing)\n\nWhen one of these throws inside `execute_figma`, the error already carries the remedy as a `Hint:` \u2014 this list is generated from the same registry (`packages/shared/src/pitfalls.ts`).\n\n<!-- PITFALLS:START -->\n- **`not a function` errors may name no symbol** \u2014 Figma's VM drops the subject. Usual cause: APIs from other agent tools that don't exist here (`node.query()`, `figma.notify()`). Use `findAll`/`findAllWithCriteria` for search; bisect when the message names nothing.\n- **Editing text without loading its font throws** (`unloaded font`). `await figma.loadFontAsync(textNode.fontName)` first; new TextNodes default to Inter Regular; when `fontName === figma.mixed`, load every font from `getRangeAllFontNames()`.\n- **`getNodeById` throws in dynamic-page mode** \u2014 always `await figma.getNodeByIdAsync(id)`.\n- **Traversing a non-current page throws until you `await page.loadAsync()`** \u2014 `figma.currentPage` is always loaded, other pages are not. Two things make this bite later than it should. `getNodeByIdAsync` resolves a node on an unloaded page perfectly well, so a handler handed a node id looks healthy right up to the moment it walks children. And the page is often already loaded by whatever ran first, so identical code passes in a session where another tool touched that page and throws in one where it did not \u2014 which reads as flakiness rather than a missing load. Load the page you need yourself; and if the caller swallows handler errors, this stops being a failure and becomes a check that silently disappears.\n- **`layoutSizingHorizontal/Vertical` throw without auto-layout** \u2014 FILL requires the parent to have a `layoutMode`, HUG requires it on the node itself.\n- **`createPage()` throws on the free plan once a file has 3 pages** \u2014 reuse an existing page instead.\n- **`width`/`height` are read-only** \u2014 use `node.resize(w, h)`.\n- **`figma.mixed` is a Symbol** returned by `fontSize`, `cornerRadius`, `fills` etc. when values differ across ranges/children \u2014 check `=== figma.mixed` before use; never JSON-serialize it.\n- **Exact-name lookups are fragile** \u2014 designers rename pages and layers freely; locate nodes by type/content instead of `name ===`.\n- **Stale node ids throw `does not exist`** \u2014 nodes get deleted while you work; re-read before editing and check `node.removed`.\n- **Nodes are non-extensible** \u2014 `node.myCustomProp = x` throws `object is not extensible`; use `setPluginData` or return the data instead.\n- **Shadow `spread` renders only on shapes or frames with `clipsContent: true`** \u2014 a focus ring built from spread shadows is invisible on a non-clipping frame/component.\n- **`resize()` on an auto-layout frame silently pins that axis to FIXED**, overriding `primaryAxisSizingMode: \"AUTO\"`. Append children first, then set `layoutSizingHorizontal = \"HUG\"`; use `resize` only for the fixed cross-axis.\n- **Per-corner radius** (`topLeftRadius` \u2026) only exists on RectangleCornerMixin nodes (rectangles, frames, components) \u2014 polygons, stars and lines throw.\n- **Instance children can't be added or removed** \u2014 detach first, or edit the main component.\n- **Figma's slot feature (Convert to slot, \u21E7\u2318S) has no Plugin API** \u2014 no createSlot/convertToSlot exists (verified against typings 1.123 and at runtime, 2026-07). Scaffold a frame named \"Slot\", select it for the designer (navigate select), and let them press the shortcut.\n- **A converted SLOT node stops hugging** \u2014 after the designer converts a frame to a slot it carries its own fixed size. Re-assert `layoutSizingVertical = \"HUG\"` (and the intended horizontal mode) on the SLOT node afterwards; the API can still edit its layout props.\n- **Variantizing can silently pin the hug axis to FIXED** \u2014 observed after clone \u2192 counterAxisSizingMode/width-variable changes \u2192 combineAsVariants: the variants' `primaryAxisSizingMode` ended up FIXED though the source hugged. Always verify sizing modes on every variant after combineAsVariants and re-assert `\"AUTO\"`.\n- **A backgrounded Figma window suspends the plugin iframe** \u2014 timers freeze and the socket can't pong, so the relay's staleness sweep kicks the plugin and the frozen 2s redial never fires until the window regains attention. The panel redials on visibilitychange/focus (ui.html \u22650.2.4); when a paired plugin goes missing mid-session, ask the designer to click the Figma window before assuming anything is broken.\n- **A freshly-converted slot can still report `type: \"FRAME\"` to the Plugin API** \u2014 the layer panel shows the slot badge while getNodeByIdAsync returns FRAME (a reload boundary was observed to fix it). Never gate automation on `type === \"SLOT\"`; match the frame by name and trust the designer's visual confirmation.\n- **Deleted variables are soft-deleted ghosts** \u2014 `getVariableByIdAsync` still resolves them with name AND working values (`Variable` has no `removed` property), so bindings to them keep rendering while being invisible in pickers and dead on publish. To test aliveness, check membership in `getLocalVariablesAsync()` (remote variables are legitimately absent \u2014 check `variable.remote`).\n- **Variables cannot be reordered via the API** (`variableIds` is read-only; the `move\u2026After` methods exist only for styles) \u2014 new variables always append last; panel order is manual-drag only. NEVER delete-and-recreate variables to reorder: bindings reference variable IDs, so every binding in the file silently dies (observed in production; the ghosts keep rendering, hiding the damage).\n- **A remote (library) text style dominates variable bindings** \u2014 on text with a remote style applied, `setBoundVariable` writes appear to succeed but snap back to the style's own bindings. Clear the style first (`await setTextStyleIdAsync(\"\")`), then bind.\n- **Binding writes inside instances silently revert \u2014 with two escape hatches.** `setBoundVariable` on a node nested inside an INSTANCE succeeds without error, then reverts (instance roots placed directly in a main ARE writable; anything deeper is not). What does persist: reassigning the whole `fills`/`strokes` array (variable-bound paints ride inside), and range-level `setRangeBoundVariable` on text. Verify after writing; never trust the call alone.\n- **A bound `strokeWeight` renders nothing while `strokes` is empty, and wrappers don't hug stroke thickness.** A LINE's visible weight lives entirely in its stroke paint; the frame around it keeps its own raw height/width, so the panel shows an unbound number with empty Fill/Stroke \u2014 looking broken when it isn't. Inspect the LINE child's stroke bindings, not the wrapper; and bind the wrapper's thickness-axis dimension to the same variable so nothing stays raw.\n- **`setBoundVariable(\"strokeWeight\", \u2026)` is a silent no-op on FRAME-family nodes.** Frames and components bind stroke weight per side \u2014 `strokeTopWeight` / `strokeRightWeight` / `strokeBottomWeight` / `strokeLeftWeight`; the uniform `strokeWeight` field only binds on stroke-geometry nodes like LINE. No error is thrown, the binding just never appears \u2014 read back `boundVariables` after writing.\n- **Cloning a variant escapes its component set \u2014 onto whatever page the user is viewing.** `variant.clone()` parents the copy to `figma.currentPage` as a standalone COMPONENT (not into the set) \u2014 plain nodes inside components clone to the current page too. Every `componentPropertyReferences` pointing at set-level properties is silently severed, and native SLOT children are demoted to plain FRAMEs (slot-ness needs set context). Recover by `componentSet.appendChild(clone)`, re-assigning ALL references (`visible`, `slotContentId`, \u2026) \u2014 a SLOT can be restored by cloning a surviving SLOT node from a sibling variant (SLOT clones keep their type) and relinking `componentPropertyReferences.slotContentId`. Audit every set-scoped feature after adoption; none of it comes back on its own.\n- **Uneven variant renames/deletes poison the whole component set.** If variants end up with mismatched axis segments (some carry `axis=value`, others don't) or duplicate names, the set enters an error state where `componentPropertyDefinitions` AND every instance's `variantProperties`/`setProperties` throw (`Component set has existing errors`). Escape: rename stragglers so all variants share the same axes with unique values (temporary `legacy-*` values work), which heals the set \u2014 then migrate and delete.\n- **`setProperties` DOES work on instances nested inside other instances** \u2014 it records as an override on the outer instance. Only variable-binding writes (`setBoundVariable`) silently revert inside instance internals. Don't skip nested instances when migrating variant or boolean properties; skip them only for binding writes.\n- **`Plugin runtime aborted` is an out-of-memory, not a timeout.** The stack shows `deepWrap` recursing into `newString` before `abort`. Reading deep-wrapped properties \u2014 `componentProperties`, `overrides`, `boundVariables`, `exportSettings` \u2014 copies a fresh object into the QuickJS sandbox on EVERY access, so a loop over tens of thousands of instances kills the runtime even though each read looks free. Chunk the scan and keep only the fields you need. (Real case: `componentProperties` over 29,861 instances aborted; the same scan over 7,139 was fine.)\n- **What a file USES is not what its library HAS.** `get_design_system`'s remote-used list only reports components the file already placed \u2014 a library component nobody has dragged in yet is invisible to the plugin, and no amount of scanning the document will reveal it. Read the published catalog before concluding a component doesn't exist (one real case: a design system's `TextArea` was declared missing and hand-rebuilt from a stretched `Text Field`, because the file had never used the real one).\n- **A component property's identity is the `#id` suffix, not its display name.** Renaming `show label#4525:247` to `hasLabel#4525:247` in the library keeps every instance's override \u2014 text and boolean values survive a library update untouched. VARIANT axes are the opposite: they have no id and are matched by name, so renaming an axis or its values (`layout=horizontal` \u2192 `labelPosition=side`) is the only thing that actually drops instances back to a default. When auditing a library migration, snapshot the variant axes and ignore the renamed text/boolean props.\n- **`importComponentSetByKeyAsync` returns the file's LOCAL copy, not the latest published version.** If the file holds a stale copy of a library component, the import hands back the stale one and looks authoritative. To see what is actually published, read `componentPropertyDefinitions` over REST (`/v1/files/{key}/nodes?ids={node_id}&depth=1`) and compare. Importing is also expensive \u2014 each call materialises the whole set (a 72-variant set is thousands of nodes), and a handful in one script will abort the sandbox.\n- **GRID auto-layout has read-only positions and imperative setters.** `gridRowAnchorIndex` / `gridColumnAnchorIndex` are readonly \u2014 move a child with `node.setGridChildPosition(row, column)` (spans stay writable: `gridColumnSpan`). `gridColumnSizes` / `gridRowSizes` reject array assignment (`no setter for property`), but `gridColumnCount` / `gridRowCount` are writable and each returned `GridTrackSize` has `.type` / `.value` setters \u2014 so add a track by raising the count, then set the new track's type. Shrinking the row count is how you drop a leftover empty row.\n- **`figma.root.findAll` throws in dynamic-page mode until you `await figma.loadAllPagesAsync()`.** Page-scoped work never needs it: `figma.currentPage` is already loaded and `relai.query` searches from a node you pass. Load the whole document only when the search genuinely spans pages \u2014 on a 40-page file it is seconds of work and memory.\n- **`instance.mainComponent` throws in dynamic-page mode** \u2014 `await instance.getMainComponentAsync()`. The same rule catches every sync sibling of an async API (`getNodeById`, `textStyleId =`, `page.children` on an unloaded page): when the message says *use X instead*, it means the whole traversal from that point is async.\n- **A variant has no component property definitions of its own** (`Can only get component property definitions of a component set or non-variant component`). They belong to the COMPONENT_SET \u2014 read `node.parent` when a variant is what you have, and add new TEXT/BOOLEAN properties to the set too: adding them to a single variant either fails or leaves the other variants without them.\n- **`SPACE_BETWEEN` centres a lone child.** A header built as label + value with the value hidden by default looks correct in the variant you were staring at and lands centred everywhere else \u2014 because Figma spreads a single item to the middle rather than leaving it at the start. When one side is optional, align the row `MIN` and let the first child FILL: the optional one stays pinned to the far edge when it appears, and the other stays put when it does not.\n- **Applying a text style changes the node's font, so a `characters` write right after it can throw for a font nobody loaded.** `await node.setTextStyleIdAsync(id)` may swap the family or weight; load the *new* font (`await figma.loadFontAsync(node.fontName)`) before writing text. Applying the style is also the last word on properties it owns \u2014 a `textDecoration` written in the same breath is overwritten by the style landing, so set it in a later call.\n- **Text edits inside a component SET propagate to every variant.** Applying a text style to one variant's label retypes the matching layer in all of them \u2014 same for `textDecoration` and text fills, at node level and via `setRange*` alike. Renaming the layer does not break the link (matching is positional). So a set whose sizes need DIFFERENT text styles cannot be styled in place: apply the styles while the components are still standalone and combine afterwards \u2014 an atom does not save you either unless the atom's variants are standalone components rather than a set of their own. Layers bound to one TEXT component property are linked through the property as well: every layer whose `characters` references it shares a single `textDecoration` \u2014 write it on one variant and all of them change, re-binding a layer pulls it back to the shared value, and only a layer whose reference is removed keeps its own. The binding step writes too: pointing five variants' titles at a new TEXT property replaced their `characters` with the property's default and reset their underline to `NONE`, while the `combineAsVariants` just before it had kept every variant's underline. So a label whose decoration differs per variant cannot sit on a shared TEXT property. And a decoration written in the same execution as `setTextStyleIdAsync` is clobbered by the style landing \u2014 set it in a later call.\n- **Writing `leadingTrim` on a text node detaches its text style** and strands the style's typography variables as node-level bindings \u2014 the node keeps rendering but no longer follows the style. `textDecoration` is safe (the style survives); `leadingTrim` is not. When a cap-trimmed label needs to sit in a full line box (so an icon beside it doesn't hang below the baseline, and toggling the icon doesn't change the height), leave the trim alone and bind the PARENT frame's height to the same leading variable the style uses.\n- **A variable bound to a component property's VALUE hides from `boundVariables`.** `node.boundVariables.componentProperties` lists the VARIANT properties and nothing else \u2014 a token bound to a TEXT or INSTANCE_SWAP property (an icon glyph name, a swapped instance) lives only in `instance.componentProperties[key].boundVariables.value`. Read one location and every such token looks unreferenced. Bindings authored as a property's *default* sit in a third place, `componentPropertyDefinitions[key].boundVariables`, on the SET.\n- **\"Plugin runtime aborted\" is out of memory, not a timeout**, and reading `instance.componentProperties` in bulk is the fastest way there: the getter deep-wraps every property object, so 1,000 instances costs about 4 seconds and 5,700 kills the sandbox. Memory is only reclaimed when the command returns, so the fix is slices \u2014 walk a fixed number of nodes per command and have the caller ask again \u2014 not a tidier traversal inside one script.\n- **`component.clone()` loses every `componentPropertyReferences` until it is back inside a SET** \u2014 and it does not get them back when you append it. Cloning a variant to make a new one therefore yields a variant whose label, `show*` toggles and swaps are all dead: the layers are there, the wiring is not. The clone renders convincingly because each layer keeps the *value* it had. After `set.appendChild(clone)`, walk the source variant and the clone in `findAll` order and copy `componentPropertyReferences` node for node.\n- **An instance whose variant you deleted becomes a ghost, and `setProperties` cannot reach it.** It still reports a `mainComponent` \u2014 named after the *deleted* variant, parented to the live set \u2014 but its `componentProperties` no longer carry the set's variant axes, so writes are dropped without an error. Figma does not fall back to the default variant. The repair is `instance.swapComponent(targetVariant)` and then `setProperties`; a variant-count audit will not find these, because the set itself looks healthy.\n- **SLOT properties are per-node and giving two of them the same name does not merge them.** A set whose variants each carry their own slot ends up with two entries \u2014 `Items Slot#1938:15` and `Items Slot#1938:18` \u2014 and renaming them alike only makes the panel show two rows nobody can tell apart, which is worse than the honest `Sm`/`Md` names. The consequence worth knowing before designing around it: content placed in one variant's slot does **not** survive switching to a variant that owns the other slot, and there is no way to express \"one slot, shared across variants\" today.\n- **`componentPropertyReferences` cannot be set on a layer inside a nested instance \u2014 but almost everything else on that layer can.** The references belong to that instance's own main component, so writing them throws `Cannot set component property references on instance sublayer`. The trap is over-correcting: excluding instance sublayers from the *paint* loop as well leaves half the job done and no error, because fills, characters and visibility on those same sublayers are perfectly writable \u2014 and in a component whose label or icon lives inside a nested atom, that is exactly where the colour you meant to change lives. Skip sublayers when copying references; include them when painting.\n- **Deleting a layer from a component can silently orphan instance-side overrides on layers you did not touch.** Figma re-creates part of the instance's subtree with fresh node ids, and any override keyed to the old ids is dropped \u2014 on the instance only. The component still renders correctly, which is exactly why nobody notices: the master is where you look. After removing layers from a set, diff each instance against its main property by property; a screenshot of the component proves nothing about its instances.\n- **Setting `layoutSizingHorizontal/Vertical = \"FIXED\"` right after `insertChild` freezes the node at the size the row just stretched it to**, not the size it wants. An icon instance that is 12\xD712 on its own lands in a 16-tall row, gets stretched, and the FIXED you meant as *don't grow* records 16\xD716 permanently. Insert, let the layout settle, and set `layoutAlign`/`layoutGrow` instead \u2014 or copy the posture of a healthy sibling in the same row rather than asserting sizing modes from scratch.\n- **A component that sizes itself from the inside must not be re-sized from the outside.** Where a wrapper hugs a child whose own width/height are bound to size tokens, the wrapper carries no bindings at all \u2014 that absence *is* the mechanism. Binding the outer instance's width/height to look tidy overrides the child and pins one size across every variant, and the mistake is invisible because the number is right in whichever variant you were looking at. Before binding a dimension, check whether the node already derives it.\n- **A long editing session leaves `findAll` enumerating instance sublayers that no longer exist.** After a few hundred `swapComponent` calls and nested component-property writes, `page.findAll` still returns proxies whose ids are gone: touching one \u2014 `getMainComponentAsync`, or merely `.name` \u2014 throws *The node (instance sublayer or table cell) with id \u2026 does not exist*, while `getNodeByIdAsync` on the same id answers `null`. The document is fine (it renders and validates); it is the enumeration that is stale, and a plugin reload clears it. Two consequences for doc-wide sweeps: wrap each node in its own try/catch, and never read the failing node inside the catch to report it.\n- **A plain write clears a variable-bound component property only on the node you write to \u2014 read back to confirm which node still holds the alias.** `instance.setProperties({ key: \"diamond\" })` does drop the `VARIABLE_ALIAS` on that instance (measured both with a different value and with the token's own resolved string). What surprises people is where the binding *else* lives: set it on one instance and the main component keeps its own alias, so re-reading the master still reports bound; and an instance nested two levels down (an icon instance inside a `Body Cell` inside a `Column`) carries its own override that the parent write never touches. Always read the property back on the exact node you meant to change, and sweep nested instances separately.\n- **Component property keys are `name#id`, and the name may itself contain `#`** \u2014 an icon component can ship `Icon Name#4866:0`, `Icon Name w/##4866:28` and `Icon Name w/###4866:56`. Splitting on the *first* `#` maps the last two to the same base name, so a lookup silently binds only one of them and the duotone layers keep their placeholder glyph. Split on `lastIndexOf(\"#\")`. The mirror trick is useful: matching on the `#id` tail alone follows a property across a rename, since `editComponentProperty` keeps the id.\n- **An instance under an explicit variable mode can get stuck on the placeholder \u2014 when a plugin creates it there, or switches its variant there \u2014 and waiting does not fix it.** Create an instance, drop it under a node whose Theme mode differs from the page's, and read it back: the nested variant *does* swap (`iconFamily=Phosphor` \u2192 `FA7`), but a component property bound to a variable resolves to the new variant's **default** rather than to the token, so every icon reads as the placeholder glyph. Measured: still wrong after three minutes untouched \u2014 in `characters` and in the rendered screenshot alike \u2014 and setting the *same* mode again changes nothing. What unsticks it is `clearExplicitVariableModeForCollection` and then `setExplicitVariableModeForCollection` **in two separate `execute_figma` calls**: clearing and re-setting inside one script leaves it stuck (measured both when read 1.5 seconds later in the same script and when read in the next call). The binding was never destroyed, only unresolved. The order does not matter: appending to a parent with no explicit mode and setting the parent's mode in a later call leaves the same placeholder. Nor does it take a new instance: switching an existing instance's variant with `setProperties` under a pinned parent drops the same way \u2014 glyph properties fall back to the unbound placeholder, icon fills to the default variable, a bound size to none \u2014 but only in modes that swap a nested instance to a different variant than its main uses; where the mode resolves to the main's own icon family, nothing drops. Clear-then-set in separate calls restored every binding in both cases. It is not a sure cure: in another probe (five button instances created in one call, their parent's mode set in the next), clearing and setting the mode in two further calls, then setting it on each instance too, left the two loading buttons' variable-bound glyph names on the placeholder. Writing the resolved values with `setProperties` did stick there, as long as only properties whose value differs are written: writes that repeated the variant properties' current values flipped the nested icon to the main's own variant in 2 of 7 cases (both on the instances with bound glyph names), and writing the differing value back fixed it. That write costs the binding \u2014 a plain write drops the alias on that node (see the plain-write entry) \u2014 so it suits a throwaway probe, not a file's instances. (Distinct from the few-second repaint lag a designer sees after switching a theme by hand on content that already exists.) A brand-switch probe built and screenshotted in one pass will therefore report that a whole file's icon tokens are broken when nothing is wrong: clear in one call, set in the next, read after that \u2014 and confirm against a node that was already in the file.\n- **`variable.remove()` succeeds even when instances still bind it, and the variable keeps working \u2014 so a retirement can look clean and change nothing.** After removing it, `getLocalVariablesAsync()` no longer lists it (the designer sees it gone from the panel) but `getVariableByIdAsync(id)` still resolves it, the bindings still render its last value, and no error is raised anywhere. The trap is upstream, in the consumer scan that told you it was safe: a walker that does `if (node.type === 'INSTANCE') { check its componentProperties; return }` never descends into nested instances, and component-property bindings mostly live there \u2014 an icon instance inside a `Body Cell` inside a `Column`. One retirement measured 0 consumers by that walk and 160 by a walk that recursed. Before removing a variable, recurse through instance children; afterwards the id keeps resolving forever (it is a tombstone, not proof of failure), so verify instead that it has left `getLocalVariablesAsync()` and that a recursive scan finds zero bindings.\n- **`clone()` on a variant downgrades a SLOT child to a plain FRAME and drops its `slotContentId` reference** \u2014 the copy looks right and quietly loses the slot. Re-assigning `componentPropertyReferences = { slotContentId: '<Slot#id>' }` to the cloned frame restores the behaviour: the master still reports `FRAME`, but instances of it materialise a real `SLOT` child, expose the SLOT property, and accept appended content. There is no `figma.createSlot()`, so this reassignment is the only repair from code; duplicating the variant in the Figma UI keeps the SLOT type intact. Relai's post-run lint now names the slotless variants, but only for component sets it can reach \u2014 return the ids of what you cloned.\n- **`setBoundVariable` takes a Variable object, not an id string** \u2014 the string form throws `Cannot call setBoundVariable with a variable id. Please pass a variable object`. Fetch the variable first (`getVariableByIdAsync`, or a map built from `getLocalVariablesAsync`) and pass the object itself.\n- **Giving a node INSIDE an instance a new width/height from code does nothing \u2014 rebinding width/height and `resize()` are silent no-ops, and `rescale()` throws.** `setBoundVariable` returns without error and `boundVariables` still shows the old id afterwards. Only `setBoundVariable(field, null)` (unbind) persists there. On a nested instance (a logo inside a footer instance), `resize()`, `resizeWithoutConstraints()` and `resize()` after `unlockAspectRatio()` all returned normally and read back the old 105.3\xD748, and `rescale(0.75)` threw `This property cannot be overridden in an instance: size` \u2014 change the size in the main (a variant with that size) instead. To back out, `removeOverrides()` on the nested instance clears only the overrides from that layer down; the outer instance keeps its own. Stuck overrides \u2014 bindings an instance kept from a main that has since been cleaned \u2014 can therefore never be re-pointed from code, only cleared; and if the main is already clean, clearing IS the correct repair (the node falls back to the main's shape, pixels stay put). Measured on 301 soft-deleted `Size/height/12` bindings (2026-08-12): rebind reported success \xD7301 and changed nothing; unbind cleared every one. Always read the field back after writing \u2014 success without verification is how this one hides. One sizing write does persist inside an instance: setting `layoutSizingHorizontal` / `layoutSizingVertical` to `HUG` on the inner node is kept, and it also clears stale overrides that point at nodes which no longer exist.\n- **One broken component set crashes a whole-file property scan**: `instance.componentProperties` (and a set's `componentPropertyDefinitions`) throw `Component set has existing errors` when the backing set has variant conflicts \u2014 and the set may not even be on any page (a soft-deleted draft still referenced by one forgotten instance). A file-wide audit that reads properties uncaught dies on the first such instance. Guard each read with try/catch, report skips, and find the culprit cheaply: collect unique `mainComponent.parent` sets across instances and probe `componentPropertyDefinitions` on each (a few hundred sets) instead of touching every instance.\n- **Dot-prefixed components are never published**, so their keys 404 on every REST endpoint. Figma hides `.`-prefixed assets from the publish dialog; they exist only nested inside published parents. When resolving a key \u2192 file via `/v1/components/{key}`, skip names starting with `.` \u2014 and note a component SET's key resolves only at `/v1/component_sets/{key}`, never at `/v1/components/{key}` (and vice versa). Relatedly, `/v1/files/{key}/components` returns the individual VARIANTS (`size=md, state=default`); the components a designer names live in `/component_sets`.\n- **Freshly created frames and components count their strokes into hug sizing** \u2014 `strokesIncludedInLayout` defaults to `true`, so a hairline bottom divider makes an auto-layout row report 40.5 instead of 40, and an outlined container reports two extra pixels. The half-pixel survives into every instance and into any math you do on the height. Components that should keep integer dimensions need `strokesIncludedInLayout = false` set explicitly; existing hand-built components in a file often already have it off, which is why cloning them behaves differently from building from scratch.\n- **Converting a frame to a Slot in the Figma UI freezes its sizing at FIXED and strips the property references of everything inside it.** The converted node keeps the width/height it had at conversion time as a hard FIXED size (a later `layoutSizingHorizontal = \"HUG\"` is rejected; `\"FILL\"` is accepted), and a TEXT child that carried a `characters` reference (`label#\u2026`) comes out with the reference gone while the component property itself survives on the set \u2014 the panel shows the control, typing into it does nothing. After any frame\u2192Slot conversion: re-assert the slot's sizing explicitly and re-wire `componentPropertyReferences` on its former children. (`figma.createSlot` does not exist \u2014 conversion is UI-only, so this cleanup is always manual-plus-script.)\n- **`combineAsVariants` re-keys every component property and can drop a TEXT property's `characters` reference on the way.** The ids in property keys (`title#1033:0`) belong to the standalone components; combining mints new set-level keys (`title#7527:4`) \u2014 so any key strings you saved before combining are stale afterwards. Worse, the re-wiring is lossy: in one measured combine the `title` reference survived on all variants while the `description` reference vanished from all of them, with no error. Existing instances do ride through the re-keying: when `combineAsVariants` turned a lone component into a set of one variant (the second was added afterwards), its TEXT key changed (`text#N:61` \u2192 `text#N:62`), the reference survived, and all 176 instances kept their text, size and position. After every combine, audit `componentPropertyReferences` across all variants against the set's `componentPropertyDefinitions` and re-wire the orphans (survival is luck, not a rule \u2014 this is the same disease as duplicated variants keeping no references, in a different coat).\n- **Writing `textAutoResize` resets a text node's truncation.** Assigning `NONE`, `HEIGHT` or `WIDTH_AND_HEIGHT` \u2014 including the value it already holds \u2014 sets `textTruncation` back to `DISABLED` and `maxLines` to `null`, with no error (24/24 cases, auto-layout children and free nodes alike). The deprecated `TRUNCATE` keeps `ENDING` but still drops `maxLines`; don't lean on it. `resize()` and `layoutSizingHorizontal` leave `textTruncation` alone, but they can flip `textAutoResize` between `HEIGHT` and `WIDTH_AND_HEIGHT` on the way. Order the writes: `textAutoResize` first, then `textTruncation` and `maxLines`, and re-read all three after any sizing change. On a node whose `textAutoResize` is `NONE`, writing `ENDING` reads back as `TRUNCATE`, and `maxLines = 1` is accepted but reads back `null`.\n- **Removing an instance that has slots leaves undeletable orphans behind \u2014 whether it came from `createInstance()` or `clone()`.** An instance with SLOT content keeps each slot's content in a `FRAME` with `parent === null`, holding the slotted instances; while the instance exists that frame is its live storage, not litter. Delete the instance and the frame stays. It sits on no page and nothing shows it, but `remove()` on it (or on the instances inside it) throws `Removing this node is not allowed`, and `component.getInstancesAsync()` keeps returning those instances: one probe that cloned slot-bearing instances to check variable modes took a component set's instance count (summed over its variants) from 545 to 959, and two probes built with `createInstance()` and deleted again left 24 such frames. Whether reopening the file clears them has not been measured. Don't build probes out of slot-bearing instances \u2014 to see one under another mode, resolve its variables against an existing node in that mode (`variable.resolveForConsumer(node)`) \u2014 and in instance audits drop any result whose ancestor chain never reaches a PAGE.\n- **Overriding an instance's text freezes the layer's styling along with it.** Writing `characters` on a text layer inside an instance stores the layer's `styledTextSegments` in the override, so the layer stops following its main per variant: an instance made from a variant whose title has a 1px underline, switched to a variant whose title has a 2px one, kept its own text *and* the 1px \u2014 and it will not follow a later change to the main's text styling either. Binding the layer to a TEXT property instead has its own edge: switch to a variant whose layer is not bound to that property and the text reverts to that variant's own. Create the instance from the variant it should show before overriding its text, and read the decoration back after any variant switch.\n- **An instance reads its main component's pluginData as its own, and emptying a key on the instance leaves an override behind.** `instance.getPluginData(key)` returns the main's value when the instance never set one \u2014 clear the key on the main and it disappears from every instance, and a sweep that counts keys node by node counts each inherited copy again (one cleanup found 188 keys, 94 of them inherited). Writing `''` on an instance does empty its value, but `pluginData` stays in that instance's `overriddenFields`, and nothing removes that entry alone: `removeOverrides()` also discards every rename and resize beside it. Clear a marker on the node you set it on, and don't read a key found on an instance as proof that the instance itself was marked.\n- **`getNodeByIdAsync` throws on the bare id of a variable or collection.** Variables and collections draw from the same `N:M` counter as nodes, so a scan over an id range lands on them: `getNodeByIdAsync('N:M')` for a variable's number throws `unsupported type in createVMObject: VARIABLE` (`VARIABLE_SET` for a collection) instead of returning `null`, while the prefixed `VariableID:N:M` just returns `null`. Catch per id when sweeping ranges, and resolve variables through `figma.variables.getVariableByIdAsync`.\n- **Range reads throw on an empty range, so an empty text node breaks the usual font-loading loop.** `getRangeAllFontNames(0, node.characters.length)` on a node whose `characters` is `\"\"` throws `Empty range selected. 'end' must be greater than 'start'`, and so does any `getRange*` read with `start === end` (`getRangeFontName(0, 0)`, `getRangeAllFontNames(1, 1)` on `\"ab\"`). `node.fontName` answers normally there and `getStyledTextSegments(['fontName'])` returns `[]`. Load `fontName` when the text is empty, and call range APIs only when `end > start`.\n- **`exportAsync` renders only what a clipping ancestor leaves visible, so pixel diffs of clipped content compare nothing.** By default a node is exported at its `absoluteRenderBounds`, which a `clipsContent` ancestor cuts: a 100\xD7300 child of a clipping 100\xD7100 frame exports as 100\xD7100, and a child clipped away entirely (its `absoluteRenderBounds` is `null`) exports as a 1\xD71 image \u2014 so the hidden rows of a clipped table \"match\" before and after whatever you changed in them. `exportAsync({ format: \"PNG\", useAbsoluteBounds: true })` renders the whole node, byte-identical to exporting it with the ancestor unclipped; use it for before/after comparisons, or compare `absoluteRenderBounds` with `absoluteBoundingBox` before trusting a diff.\n- **Switching a nested instance's variant can discard the overrides its container put inside it.** Inside a component, `setProperties({ state: \"default\" })` on a nested icon button moved it to the new variant but dropped what the component had set on the button's icon: the three glyph properties bound to variables came back as the new variant's unbound default, and the icon's bound fill as the default variable \u2014 only the binding on the icon's variant property (`style`) survived. No error, and instances of the component show the placeholder from then on. After switching a nested instance's variant, re-read its content node by node and rebind from a sibling variant that is already in the target state.\n- **Slot content that is already on another variant when it enters the slot can come back with ids that resolve to nothing.** If a nested instance in the content is already on a different variant than the component defines for it when the content lands in the slot \u2014 an explicit mode swapped an icon to another family, or an override set a menu item to `state=hovered` \u2014 its descendants get ids missing the slot's segment: `getNodeByIdAsync` answers `null` for them, and two items can report the same id. Switching the variant after the content is in the slot keeps the ids whole. The repair measured on a 311-node menu: clear the mode in one call, set the swapped item to its default variant in place in the next and back in the one after, then set the mode \u2014 14 unreadable nodes went to 0; clearing and re-setting the mode alone fixed 13 of them, not a hidden layer. Walk slot content with a per-node try/catch that records the path instead of stopping.\n- **When a variable mode swaps a nested instance to another variant, the swap is stored as overrides, and later binding changes on the main stop reaching it.** Eighteen instances were created in one call and their parents' Theme modes set in the next: in the mode that resolves the nested icon to the main's own variant, the nested instance carried no overrides; in each of the five modes that swapped it, it carried `boundVariables`, `componentProperties`, `fills`, `name`, both axis sizing modes and `targetAspectRatio` as overrides. Rebinding that nested instance's `style` property to a new variable in the main then reached only the unswapped instance \u2014 the other five kept reading the old variable \u2014 while fresh instances with the modes set again all showed the new binding. Instances that were already in the file carried the same overrides. After changing a binding in a main, re-create probe instances rather than re-reading old ones, and read `overrides` on the nested layer before trusting that the change reached instances under other modes.\n- **A VECTOR whose corners are rounded per vertex ignores `cornerRadius` writes on an instance, and only `rescale` scales those corners.** When the radius lives in `vectorNetwork.vertices[i].cornerRadius` (a glyph outline with 12 and 9 rounded vertices), writing the node's `cornerRadius` inside an instance adds a `cornerRadius` entry to `overrides` and nothing else: it reads back the main's 0.449, the vertices keep it, and the render is pixel-identical. Writing the main's value back leaves the entry behind; only `removeOverrides()` clears it, along with every other override on that instance. `resize()` scales the outline but not the vertex radii, so an instance resized 2.4\xD7 draws sharper corners than the main scaled 2.4\xD7 (alpha differs by up to 175/255 at the corners). `rescale(2.4)` scales the vertex radii too (0.449 \u2192 1.079, within 16/255 of the scaled main), but it does not coexist with a variable-bound height: binding `height` after `rescale` resets `scaleFactor` to 1 and the radii to 0.449 (with the aspect ratio locked as well), and `rescale` after binding multiplies the bound height (72 \u2192 259.2). Pick either the bound height or proportional corners.\n- **A bound paint's `color` is only a fallback \u2014 and one component set kept the fallback on screen.** `figma.variables.setBoundVariableForPaint(paint, 'color', v)` returns the paint with your `color` untouched (a `{0,0,0}` placeholder stays `{0,0,0}`). Assigned through `fills` or `setRangeFills`, the stored color is normally replaced by the variable's resolved value: in eight probes (node fills on text and on a rectangle; `setRangeFills` over a whole text, part of one, a text in a set variant, in a standalone component and on a TEXT-property layer) it read back as the resolved gray, and the exported ones rendered it. But four ranges written with `setRangeFills` in two variants of a working component set kept `{0,0,0}` while still bound, and rendered black; a scan of the 168 bound paints around them found no other mismatch, and the trigger was not isolated. Build bound paints with the resolved color (take `{ r, g, b }` from `v.resolveForConsumer(node).value` \u2014 it carries `a`, and a paint's `color` has no alpha) instead of a dummy, and after writing compare each bound paint's stored `color` with the resolved value. The same showed up on an instance whose fill override was bound to the very variable its main uses but stored `{0,0,0}`: it exported black where the main exported light.\n- **A blank symbol in a text node is not proof the font lacks it \u2014 the node can be holding a stale layout.** Key labels drew `\u2318` as empty space on some nodes and fine on others, all in the same monospace font, which has no `\u2318`, `\u21E7` or `\u23CE`: Figma draws those with its own fallback glyph, and the blank nodes held an old layout \u2014 `width` 7, the missing-glyph advance, where drawn ones measured 9 for `\u2318`/`\u23CE` and 8 for `\u21E7`. Switching the font does not help: a font whose file does contain `\u2318` and `\u23CE` still drew the same fallback glyph, and writing that font on the key text inside the masters' nested instances spread wrongly \u2014 145 of the file's 301 keys came out wrong, including keys with no symbol at all. What brought the glyph back was a fresh layout, reached the long way: on the five keys that had been given that font, `removeOverrides()` (which also returned the TEXT property to the main's default), then restoring the layer name and writing the property back to its symbol \u2014 the glyph appeared and the five keys grew 1\u20132 px. Writing the same value again without clearing first was not tried. Before blaming the font, compare the node's `width` with a node that renders; to repair, clear the overrides, write the text property back, and re-read the width.\n- **`remove()` on a COMPONENT leaves its id resolving, so a `null` lookup cannot confirm the cleanup.** After removing a component \u2014 standalone, or a variant removed along with its set \u2014 `getNodeByIdAsync(id)` still returns it, children included, with `parent === null` and `removed === false`; calling `remove()` on it again returns normally and changes nothing. Measured on four components removed in the call that created them; the COMPONENT_SET itself, and plain frames, text and rectangles removed the same way, did resolve to `null`. The component sits on no page and no page's children list it, but id sweeps find it; whether it goes away later has not been measured. Confirm component cleanup by ancestry (the chain must no longer reach a PAGE) rather than `null`, and build temporary probes from frames or instances of existing components rather than new components.\n- **Renaming a variant axis with `editComponentProperty` unbinds the instances, and can reset their plain overrides too.** Instances bound to that property keep their value but lose the `boundVariables.componentProperties` alias, nested explicit overrides included. In one measured rename, 71 instances whose value for the axis was a plain override \u2014 not bound to anything \u2014 fell back to the set's default variant. Right after rebinding, some instances kept an old value that disagreed with what the mode resolves to; writing the value first and then rebinding fixed them. Before renaming an axis, record every instance's value and binding for it; afterwards write the values back, rebind, and read both back.\n- **`figma.skipInvisibleInstanceChildren` is plugin-wide, not per script.** Setting it to `true` \u2014 a common speed-up for `findAll` \u2014 hides invisible sublayers of instances from every search that runs while it is set, not only the script that set it. Relai puts the previous value back after each `execute_figma` script, so it does not leak into later commands; set it again in every script that wants it, and while it is on, do not read an instance sublayer missing from a search as deleted.\n<!-- PITFALLS:END -->\n\nAlso: `resize(w, h)` throws on `w <= 0 || h <= 0`; `layoutWrap = \"WRAP\"` is HORIZONTAL-only, `counterAxisAlignItems = \"BASELINE\"` too; writing to `locked` nodes is allowed by the API but surprises designers \u2014 check first.\n\n## Return values\n\nReturn JSON-serializable data (the bridge summarizes nodes automatically and truncates >50k chars). Prefer returning `{ id, name, type }` summaries over whole nodes. `console.log` is captured and returned in `logs`.\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n";
4401
4953
 
4402
4954
  // ../../docs/skills/design-tokens-strategy.md
4403
4955
  var design_tokens_strategy_default = "# Design Tokens Strategy \u2014 4-Tier Token Architecture\n\n## Token Hierarchy\n\n### Tier 1 \u2014 Core (static)\nRaw values. Never referenced directly by components.\n- Naming: `color-static/{hue}/{shade}`, `spacing-static/{value}`\n- Examples: `color-static/slate/50`, `color-static/rose/500`\n- Single mode: Value\n- hiddenFromPublishing: true\n\n### Tier 1 \u2014 Core (dynamic)\nReferences static tokens. Handles Light/Dark inversion.\n- Naming: `color-dynamic/{hue}/{shade}`\n- Light mode: shade 50 \u2192 static/50, shade 900 \u2192 static/900\n- Dark mode: shade 50 \u2192 static/900, shade 900 \u2192 static/50 (inverted)\n- 2 modes: Light / Dark\n- hiddenFromPublishing: true\n\n### Tier 2 \u2014 Themes\nSemantic tokens. Define brand personality.\n- Naming: `color/{role}/{variant}`, `typography/{style}/{property}`, `border/{type}/{size}`\n- Color roles: background, content, border, icon\n- Color variants: surface-page, surface-default, surface-subtle, surface-knockout, disabled, utility/{status}/{emphasis|subtle}\n- Typography: headline-{lg|md|sm}, title-{lg|md}, body-{lg|md|sm}[-bold], label-{lg|md|sm}, code-{md|sm}[-bold]\n- Each typography style has: font-family, font-weight, font-size, letter-spacing, line-height\n- Modes: one per brand (e.g., Brand A, Brand B)\n- description: required \u2014 explain purpose and usage constraints\n- scopes: set appropriately (TEXT_FILL for content, STROKE_COLOR for border, FRAME_FILL for background, etc.)\n\n### Tier 3 \u2014 Components\nComponent-specific tokens. Reference Tier 2 or dynamic Tier 1.\n- Naming: `{component}/color/{role}/{state}`, `{component}/size/{variant}`\n- States: default, hover, pressed, disabled, selected, selected-hover, selected-pressed, selected-disabled\n- Sizes: sm, md, lg\n- Examples: `button/color/background/default`, `button/size/md`, `navigation/color/content/selected`\n- Modes: same as Tier 2 (one per brand)\n- Not every component needs Tier 3 \u2014 only when reuse or shared decision-making justifies it\n\n## Key Rules\n- Components MUST reference tokens only \u2014 never raw hex/px values\n- Tier 1 is internal plumbing \u2014 never expose to consumers\n- Adding a new brand = adding a mode to Tier 2 and Tier 3 collections\n- Light/Dark switching is handled entirely in Tier 1 dynamic \u2014 no brand-level light/dark logic needed\n\n## Workflow\n1. `get_design_tokens` \u2192 understand existing collections and modes\n2. `manage_variables` (list) with collectionId \u2192 inspect current tokens\n3. `manage_variables` (create_collection) \u2192 new collection with modes\n4. `manage_variables` (create) \u2192 define tokens with values per mode\n5. `manage_variables` (bind) \u2192 connect tokens to node properties\n6. `manage_variables` (set_scopes) \u2192 restrict UI picker visibility\n7. `manage_variables` (set_code_syntax) \u2192 set Dev Mode code references\n\n## Verification\n- `analyze_design` (aspect: color) \u2192 find unbound colors (token coverage)\n- `validate_design_rules` \u2192 check token_coverage rule\n- `get_node_data` (detail: variables) on key nodes \u2192 confirm bindings are correct\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n";
@@ -4424,7 +4976,7 @@ var design_system_first_default = '# Design-system-first building\n\nThe differe
4424
4976
  var janitorial_cleanup_default = '# Janitorial cleanup\n\nThe chores designers postpone for months \u2014 layer renaming, token drift, spacing normalization \u2014 are where an agent earns trust fastest. These jobs are big, mechanical, and easy to verify. Do them conservatively and loudly.\n\n## Ground rules\n\n- **Preview before bulk.** Use `dryRun:true` on `batch_execute` / `set_properties`, or report findings first (`analyze_design`), and tell the designer the scale ("about 140 renames on this page \u2014 go?"). If the plugin\'s approval gate is on, big writes will ask them anyway.\n- **Scope tightly.** Work page by page (or within the designer\'s selection). Never sweep the whole file unasked.\n- **Never rename or restyle library instances\' internals** \u2014 only top-level frames/groups the team owns.\n\n## Recipes\n\n### Rename layers by content\n`Frame 427` tells nobody anything. Walk the target scope with `execute_figma` + `relai.query`, and derive names:\n- A frame whose only text child says "Submit" \u2192 `Button/Submit` (match the file\'s existing naming pattern from `get_design_system`, don\'t invent one).\n- Text nodes \u2192 their content, truncated (`Heading: Pricing plans`).\n- Skip nodes that already have deliberate names (anything not matching `/^(Frame|Group|Rectangle|Ellipse|Vector|Line) \\d+$/`).\nReturn the rename list; apply via one `batch_execute` of `rename_node` commands.\n\n### Tokenize hardcoded values\n`analyze_design aspect:"tokens"` finds hardcoded colors/numbers that visually match existing variables; `manage_variables action:"tokenize" fix:true` binds them in one pass. Report anything close-but-not-matching (deltaE just over tolerance) instead of silently snapping it.\n\n### Normalize spacing\nIn auto-layout scopes, collect `itemSpacing`/padding values; flag off-scale values (not on the file\'s 4/8pt grid or spacing variables) and propose the nearest on-scale value. Apply only after the designer confirms the mapping.\n\n### Sweep detached instances\n`analyze_design aspect:"components"` lists likely-detached instances. Offer to re-link obvious ones (matching component still exists) and just report the rest \u2014 re-attaching wrong is worse than detached.\n\n## Report format\n\nEnd with counts and jump-points: what changed, what was skipped and why, node ids for spot-checking (the designer can click entries in the plugin feed).\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
4425
4977
 
4426
4978
  // ../../docs/skills/memory-and-precedents.md
4427
- var memory_and_precedents_default = '# File memory & precedents\n\nEvery Figma file this plugin touches can carry its own case law: **precedents** \u2014 single adjudications the designer made, stored inside the file (shared plugin data) so they reach every future session from any AI client. Conventions are the statutes; precedents are the case law. Together they are the file\'s law, and `manage_conventions action:get` returns both.\n\nWhen a write touches something a precedent references, **the file speaks**: the ruling arrives attached to your tool result. Treat it as the designer\'s voice from a past session calling a halt \u2014 the four-act shape is always the same: they said it once, the file kept it, you came to "fix" it knowing nothing, their words stop you. Back off before you undo something intentional.\n\n## Read before you work\n\nAt the start of any session that will modify the file, call `manage_conventions action:get`. Treat what comes back the way you treat user instructions:\n\n- `content` \u2014 the conventions doc (statutes).\n- `precedents` \u2014 recent adjudications (case law), newest first, with `precedentCount` for the total.\n\nA precedent outranks your own judgment about what "looks wrong". If a precedent says a deviation is intent, do not "fix" it \u2014 and do not re-ask about it.\n\n## When to record\n\nRecord a precedent (`action:record_precedent`) when the designer **rules** on something durable. The signal is adjudication language:\n\n- "This is intentional, not drift" \u2192 `kind: "intent"`\n- "Approved \u2014 but never restructure this table again" \u2192 `kind: "decision"`\n- "Don\'t do it that way; do X instead" \u2192 `kind: "correction"`\n\nOne precedent = one sentence (\u2264280 chars), written so a future session with zero context understands the ruling. Attach `refs` \u2014 the variable IDs, node IDs, or page IDs the ruling concerns \u2014 because refs power in-band surfacing: any future write that touches them gets the precedent attached to its result automatically.\n\n**Always say in your reply that you recorded it** ("\u5DF2\u8BB0\u5165\u6587\u4EF6\u8BB0\u5FC6: \u2026"). Recording silently breaks the designer\'s trust in what the file knows.\n\n## When NOT to record\n\n- Transient, one-off choices ("make this one red for the screenshot").\n- Anything already in the conventions doc (point to it instead; propose a conventions edit if it deserves statute status).\n- Secrets, personal information, or anything about people rather than the file.\n- Your own inferences the designer has not confirmed. Precedents are the DESIGNER\'s rulings, not your observations. When unsure, ask: "\u8BB0\u4E3A\u5224\u4F8B\u5417?"\n\n## Promote rulings into enforcement\n\nWhen conventions or repeated precedents describe something ENFORCEABLE, propose the promotion \u2014 never enact it silently:\n\n- "Never touch page X" (in conventions or a repeated ruling) \u2192 send a panel proposal: `propose_rule` (via batch_execute) with `kind: "page_guard"`, the `pageIds`, and a `reason` that QUOTES the sentence it comes from. The designer gets a card and decides there; do not wait for the verdict and do not guard on your own. Mention in your reply that you proposed it.\n- Repeated identical adjudications ("this is intent" three times on the same token family) \u2192 offer to consolidate them into one conventions line, then remove the superseded precedents.\n\nGuards belong to the designer: never guard/unguard on your own judgment, and mention any guard change in your reply.\n\n## Kits \u2014 the law\'s common ancestor\n\nA designer who runs one product keeps its shared law as a **kit** (`manage_rulesets`; the panel\'s KIT row) \u2014 named, stored by the plugin on their machine, spanning every file. The file\'s carried law stays the working copy; the kit is the ancestor it descends from. When speaking to the designer, say kit.\n\nThis matters because **Figma discards file-carried law on branch\u2192main merges**. At session start, if `manage_rulesets action:status` says `file-empty`, the law was wiped: say so, and restore it (`action:restore`) with the designer\'s consent \u2014 or note it happened automatically when the set\'s autoRestore is on. Other states: `drifted` means file and ancestor differ \u2014 the designer chooses pull (`restore`) or promote the file\'s version (`push`); never resolve drift on your own.\n\nWhen the designer rules something clearly product-wide (not one-file-local), offer to promote that precedent into the linked kit (`action:promote` with the precedent id) so every linked file inherits it on restore.\n\n## Reporting style as trust matures\n\nEarly sessions: report every change. Once the designer clearly stops reading full receipts (they say "\u7B80\u62A5\u5C31\u597D" or approve digests), switch to digest mode: lead with **the two changes you are least sure about** and anything a precedent or guard touched, then one line for the rest ("\u5176\u4F59 N \u5904\u6309\u89C4\u7EA6"). Full detail must always remain one question away \u2014 digest is a summary, never a omission. Never digest away errors, guard rejections, or precedent hits.\n\n## Maintenance\n\n- The designer sees and can delete every entry in the plugin panel (Memory section). Their record, their rules.\n- Memory holds 200 entries / 64KB. When recording fails with a capacity error, offer a consolidation pass: `list_precedents`, merge superseded rulings into fewer summary entries (`record_precedent` the summaries, `remove_precedent` the merged ones), preserving every still-active ruling. Show the merge plan before applying it.\n- If a precedent contradicts current reality (the token it references is gone, the ruling was reversed), ask the designer whether to update or remove it \u2014 never silently drop case law.\n';
4979
+ var memory_and_precedents_default = '# File memory & precedents\n\nEvery Figma file this plugin touches can carry its own case law: **precedents** \u2014 single adjudications the designer made, stored inside the file (shared plugin data) so they reach every future session from any AI client. Conventions are the statutes; precedents are the case law. Together they are the file\'s law, and `manage_conventions action:get` returns both.\n\nWhen a write touches something a precedent references, **the file speaks**: the ruling arrives attached to your tool result. Treat it as the designer\'s voice from a past session calling a halt \u2014 the four-act shape is always the same: they said it once, the file kept it, you came to "fix" it knowing nothing, their words stop you. Back off before you undo something intentional.\n\n## Read before you work\n\nAt the start of any session that will modify the file, call `manage_conventions action:get`. Treat what comes back the way you treat user instructions:\n\n- `content` \u2014 the conventions doc (statutes).\n- `precedents` \u2014 recent adjudications (case law), newest first, with `precedentCount` for the total.\n\nA precedent outranks your own judgment about what "looks wrong". If a precedent says a deviation is intent, do not "fix" it \u2014 and do not re-ask about it.\n\n## When to record\n\nRecord a precedent (`action:record_precedent`) when the designer **rules** on something durable. The signal is adjudication language:\n\n- "This is intentional, not drift" \u2192 `kind: "intent"`\n- "Approved \u2014 but never restructure this table again" \u2192 `kind: "decision"`\n- "Don\'t do it that way; do X instead" \u2192 `kind: "correction"`\n\nOne precedent = one sentence (\u2264280 chars), written so a future session with zero context understands the ruling. Attach `refs` \u2014 the variable IDs, node IDs, or page IDs the ruling concerns \u2014 because refs power in-band surfacing: any future write that touches them gets the precedent attached to its result automatically.\n\n**Always say in your reply that you recorded it** ("\u5DF2\u8BB0\u5165\u6587\u4EF6\u8BB0\u5FC6: \u2026"). Recording silently breaks the designer\'s trust in what the file knows.\n\n## When NOT to record\n\n- Transient, one-off choices ("make this one red for the screenshot").\n- Anything already in the conventions doc (point to it instead; propose a conventions edit if it deserves statute status).\n- Secrets, personal information, or anything about people rather than the file.\n- Your own inferences the designer has not confirmed. Precedents are the DESIGNER\'s rulings, not your observations. When unsure, ask: "\u8BB0\u4E3A\u5224\u4F8B\u5417?"\n\n## Promote rulings into enforcement\n\nWhen conventions or repeated precedents describe something ENFORCEABLE, propose the promotion \u2014 never enact it silently:\n\n- "Never touch page X" (in conventions or a repeated ruling) \u2192 send a panel proposal: `propose_rule` (via batch_execute) with `kind: "page_guard"`, the `pageIds`, and a `reason` that QUOTES the sentence it comes from. The designer gets a card and decides there; do not wait for the verdict and do not guard on your own. Mention in your reply that you proposed it.\n- Repeated identical adjudications ("this is intent" three times on the same token family) \u2192 offer to consolidate them into one conventions line, then remove the superseded precedents.\n\nGuards belong to the designer: never guard/unguard on your own judgment, and mention any guard change in your reply.\n\n## Kits \u2014 the law\'s common ancestor\n\nA designer who runs one product keeps its shared law as a **kit** (`manage_rulesets`; the panel\'s KIT row) \u2014 named, stored by the plugin on their machine, spanning every file. The file\'s carried law stays the working copy; the kit is the ancestor it descends from. When speaking to the designer, say kit.\n\nThis matters because **Figma discards file-carried law on branch\u2192main merges**. At session start, if `manage_rulesets action:status` says `file-empty`, the law was wiped: say so, and restore it (`action:restore`) with the designer\'s consent \u2014 or note it happened automatically when the set\'s autoRestore is on. Other states: `drifted` means file and ancestor differ \u2014 the designer chooses pull (`restore`) or promote the file\'s version (`push`); never resolve drift on your own.\n\nWhen the designer rules something clearly product-wide (not one-file-local), offer to promote that precedent into the linked kit (`action:promote` with the precedent id) so every linked file inherits it on restore.\n\n## Reporting style as trust matures\n\nEarly sessions: report every change. Once the designer clearly stops reading full receipts (they say "\u7B80\u62A5\u5C31\u597D" or approve digests), switch to digest mode: lead with **the two changes you are least sure about** and anything a precedent or guard touched, then one line for the rest ("\u5176\u4F59 N \u5904\u6309\u89C4\u7EA6"). Full detail must always remain one question away \u2014 digest is a summary, never a omission. Never digest away errors, guard rejections, or precedent hits.\n\n## Maintenance\n\n- The designer sees and can delete every entry in the plugin panel (Memory section). Their record, their rules.\n- Memory holds 200 entries / 64KB. The size cap applies to rewrites too: an `update_precedent` that grows the memory past it is refused, while one that shrinks it always passes. A restore from a kit writes the conventions but skips the whole batch of seed precedents when they would not fit (`precedentsSkipped` in the result) \u2014 consolidate, then restore again. When recording fails with a capacity error, offer a consolidation pass: `list_precedents`, merge superseded rulings into fewer summary entries (`record_precedent` the summaries, `remove_precedent` the merged ones), preserving every still-active ruling. Show the merge plan before applying it.\n- If a precedent contradicts current reality (the token it references is gone, the ruling was reversed), ask the designer whether to update or remove it \u2014 never silently drop case law.\n';
4428
4980
 
4429
4981
  // ../../docs/skills/qa-gate.md
4430
4982
  var qa_gate_default = '# QA gate \u2014 the full physical, on demand\n\nHuman-triggered, review-moment audit of a file (or a set of pages). Run it when the designer says "\u4F53\u68C0" / "is this ready for review?" / before a library merge. It composes the gauges into one report with receipts.\n\n## The pass\n\nRun in this order (each is read-only):\n\n1. `manage_conventions action:get` \u2014 the law you\'ll be judging against (conventions + precedents). If precedents mark deviations as intent, they are NOT findings \u2014 the file already ruled. List them as "waived by precedent", never as problems.\n2. `analyze_design aspect:readiness` \u2014 agent-readiness score + top gaps.\n3. `analyze_design aspect:ghosts` (pass `pageIds` for a multi-page pass) \u2014 stale references to soft-deleted variables. Any ghost count > 0 is a red flag for publish.\n4. `analyze_design aspect:voice` \u2014 the file\'s signature (radius/spacing/type, tokenized-paint rate, instance rate).\n5. `analyze_design aspect:overall` on the key screens the designer names (health score 0-100).\n6. `analyze_design aspect:tokens` \u2014 hardcoded values that match existing variables (each is one `tokenize` away from bound).\n\n## The report\n\nProduce a compact markdown report, structured exactly like this:\n\n```\n# QA gate \u2014 <file name> \u2014 <date>\nverdict: PASS | PASS WITH NOTES | BLOCK\n\nreadiness NN/100 (top gap: \u2026)\nghosts N refs (0 required for publish)\nhealth NN/100 (screens: \u2026)\ntokens N unbound-but-matchable\nvoice radius \u2026, spacing \u2026, \u2026% tokenized\n\n## findings (ranked)\n1. [BLOCK|NOTE] \u2026 \u2014 evidence: <numbers/nodeIds> \u2014 fix: <one tool call>\n\u2026\n\n## precedent-protected (not findings)\n- \u2026 (per precedent "\u2026", YYYY-MM-DD)\n```\n\nVerdict rules: any ghosts or health < 60 on a named key screen \u2192 BLOCK; readiness < 50 \u2192 note it but readiness alone never blocks; everything protected by a precedent is listed separately, never as a finding.\n\n## Rules\n\n- Numbers unedited: report what the tools returned, never round a failing number into a passing one.\n- Findings must carry evidence (counts, nodeIds) and a one-call fix each.\n- Offer \u2014 do not perform \u2014 fixes. The QA gate is a read-only pass; repairs are a separate, approved session.\n- If the designer wants the report in-file: create a page named `\xB7 QA Report <date>` and write the report as text there (ask first \u2014 it adds a page to their file).\n- Big files: run ghosts page-set by page-set (`pageIds`) instead of one giant pass, and say which pages were covered. Silence about coverage reads as "covered everything" \u2014 never let it.\n';
@@ -4511,16 +5063,24 @@ function registerPrompts(server) {
4511
5063
  }
4512
5064
 
4513
5065
  export {
4514
- VERSION,
4515
- createServer,
4516
5066
  FIGMA_COMMANDS,
5067
+ isErrorCode,
5068
+ RelaiError,
5069
+ PROTOCOL_VERSION,
5070
+ inferLegacyCode,
5071
+ isAllowedOrigin,
4517
5072
  RelayCore,
4518
5073
  PITFALLS,
5074
+ currentCommandSlot,
5075
+ noteSettled,
5076
+ currentCallTarget,
5077
+ VERSION,
4519
5078
  resolveFileIdentity,
4520
5079
  registerRoomTool,
4521
5080
  registerEventsTool,
4522
5081
  registerAllTools,
4523
5082
  listToolCatalog,
5083
+ createServer,
4524
5084
  registerPrompts
4525
5085
  };
4526
- //# sourceMappingURL=chunk-PYVHVZTO.js.map
5086
+ //# sourceMappingURL=chunk-62ZA4AUN.js.map