figma-relai 0.7.9 → 0.7.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -10
- package/dist/{chunk-DDD2NSPB.js → chunk-5IKROMKE.js} +167 -71
- package/dist/chunk-5IKROMKE.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/{manifest-OTK4LUFB.js → manifest-TO6EQLRL.js} +2 -2
- package/package.json +1 -1
- package/dist/chunk-DDD2NSPB.js.map +0 -1
- /package/dist/{manifest-OTK4LUFB.js.map → manifest-TO6EQLRL.js.map} +0 -0
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./chunk-2H7UOFLK.js";
|
|
12
12
|
|
|
13
13
|
// src/version.ts
|
|
14
|
-
var VERSION = "0.7.
|
|
14
|
+
var VERSION = "0.7.11";
|
|
15
15
|
|
|
16
16
|
// src/server.ts
|
|
17
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -487,7 +487,7 @@ var PITFALLS = [
|
|
|
487
487
|
{
|
|
488
488
|
pattern: null,
|
|
489
489
|
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.
|
|
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. 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
491
|
},
|
|
492
492
|
{
|
|
493
493
|
pattern: null,
|
|
@@ -547,22 +547,22 @@ var PITFALLS = [
|
|
|
547
547
|
{
|
|
548
548
|
pattern: null,
|
|
549
549
|
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 (
|
|
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 (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
551
|
},
|
|
552
552
|
{
|
|
553
553
|
pattern: null,
|
|
554
554
|
hint: "",
|
|
555
|
-
doc: '**Component property keys are `name#id`, and the name may itself contain `#`** \u2014
|
|
555
|
+
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
556
|
},
|
|
557
557
|
{
|
|
558
558
|
pattern: null,
|
|
559
559
|
hint: "",
|
|
560
|
-
doc: "**
|
|
560
|
+
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
561
|
},
|
|
562
562
|
{
|
|
563
563
|
pattern: null,
|
|
564
564
|
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
|
|
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 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
566
|
},
|
|
567
567
|
{
|
|
568
568
|
pattern: null,
|
|
@@ -575,9 +575,9 @@ var PITFALLS = [
|
|
|
575
575
|
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
576
|
},
|
|
577
577
|
{
|
|
578
|
-
pattern:
|
|
579
|
-
hint: "",
|
|
580
|
-
doc: "**
|
|
578
|
+
pattern: "cannot be overridden in an instance: size",
|
|
579
|
+
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.",
|
|
580
|
+
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
581
|
},
|
|
582
582
|
{
|
|
583
583
|
pattern: "Component set (for node )?has existing errors",
|
|
@@ -588,6 +588,96 @@ var PITFALLS = [
|
|
|
588
588
|
pattern: null,
|
|
589
589
|
hint: "",
|
|
590
590
|
doc: "**Dot-prefixed components are never published**, so their keys 404 on every REST endpoint. Figma hides `.`-prefixed assets from the publish dialog; they exist only nested inside published parents. When resolving a key \u2192 file via `/v1/components/{key}`, skip names starting with `.` \u2014 and note a component SET's key resolves only at `/v1/component_sets/{key}`, never at `/v1/components/{key}` (and vice versa). Relatedly, `/v1/files/{key}/components` returns the individual VARIANTS (`size=md, state=default`); the components a designer names live in `/component_sets`."
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
pattern: null,
|
|
594
|
+
hint: "",
|
|
595
|
+
doc: "**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."
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
pattern: null,
|
|
599
|
+
hint: "",
|
|
600
|
+
doc: '**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.)'
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
pattern: null,
|
|
604
|
+
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. 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)."
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
pattern: null,
|
|
609
|
+
hint: "",
|
|
610
|
+
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`."
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
pattern: "Removing this node is not allowed",
|
|
614
|
+
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.",
|
|
615
|
+
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."
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
pattern: null,
|
|
619
|
+
hint: "",
|
|
620
|
+
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."
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
pattern: null,
|
|
624
|
+
hint: "",
|
|
625
|
+
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."
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
pattern: "createVMObject",
|
|
629
|
+
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.",
|
|
630
|
+
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`."
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
pattern: "Empty range selected",
|
|
634
|
+
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.",
|
|
635
|
+
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`."
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
pattern: null,
|
|
639
|
+
hint: "",
|
|
640
|
+
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.'
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
pattern: null,
|
|
644
|
+
hint: "",
|
|
645
|
+
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."
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
pattern: null,
|
|
649
|
+
hint: "",
|
|
650
|
+
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."
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
pattern: null,
|
|
654
|
+
hint: "",
|
|
655
|
+
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."
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
pattern: null,
|
|
659
|
+
hint: "",
|
|
660
|
+
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."
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
pattern: null,
|
|
664
|
+
hint: "",
|
|
665
|
+
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."
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
pattern: null,
|
|
669
|
+
hint: "",
|
|
670
|
+
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."
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
pattern: null,
|
|
674
|
+
hint: "",
|
|
675
|
+
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."
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
pattern: null,
|
|
679
|
+
hint: "",
|
|
680
|
+
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."
|
|
591
681
|
}
|
|
592
682
|
];
|
|
593
683
|
|
|
@@ -1932,7 +2022,7 @@ import { z as z13 } from "zod";
|
|
|
1932
2022
|
function register12(server, sendCommand) {
|
|
1933
2023
|
server.tool(
|
|
1934
2024
|
"annotate",
|
|
1935
|
-
"Read or write Dev Mode annotations. get: read annotations on a node. set: add
|
|
2025
|
+
"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.",
|
|
1936
2026
|
{
|
|
1937
2027
|
action: z13.enum(["get", "set", "set_multiple"]),
|
|
1938
2028
|
nodeId: z13.string().optional().describe("Target node (get/set)"),
|
|
@@ -2279,7 +2369,9 @@ function register16(server, sendCommand) {
|
|
|
2279
2369
|
text: z17.string().optional().describe("record/update_precedent: the adjudication, one sentence, \u2264280 chars"),
|
|
2280
2370
|
id: z17.string().optional().describe("update/remove_precedent: precedent id (from list_precedents)"),
|
|
2281
2371
|
refs: z17.object({
|
|
2282
|
-
tokens: z17.array(z17.string()).optional().describe(
|
|
2372
|
+
tokens: z17.array(z17.string()).optional().describe(
|
|
2373
|
+
'Variable IDs this precedent concerns, with the "VariableID:" prefix (a bare N:M gets it added; an id that names no variable is an error and nothing is saved)'
|
|
2374
|
+
),
|
|
2283
2375
|
nodes: z17.array(z17.string()).optional().describe("Node IDs this precedent concerns"),
|
|
2284
2376
|
pages: z17.array(z17.string()).optional().describe("Page IDs this precedent concerns")
|
|
2285
2377
|
}).optional().describe("What the precedent is anchored to \u2014 enables in-band surfacing when writes touch these"),
|
|
@@ -2482,7 +2574,7 @@ var ACTIONS2 = {
|
|
|
2482
2574
|
function register17(server, sendCommand) {
|
|
2483
2575
|
server.tool(
|
|
2484
2576
|
"manage_rulesets",
|
|
2485
|
-
"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.",
|
|
2577
|
+
"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.",
|
|
2486
2578
|
{
|
|
2487
2579
|
action: z18.enum(Object.keys(ACTIONS2)),
|
|
2488
2580
|
name: z18.string().optional().describe("Ruleset name (save/link/delete/export; import fallback)"),
|
|
@@ -2809,7 +2901,7 @@ function register19(server, sendCommand) {
|
|
|
2809
2901
|
);
|
|
2810
2902
|
server.tool(
|
|
2811
2903
|
"get_selection_context",
|
|
2812
|
-
"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
|
|
2904
|
+
"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.",
|
|
2813
2905
|
{},
|
|
2814
2906
|
{ readOnlyHint: true },
|
|
2815
2907
|
async () => {
|
|
@@ -2854,8 +2946,8 @@ function register19(server, sendCommand) {
|
|
|
2854
2946
|
data,
|
|
2855
2947
|
warnings,
|
|
2856
2948
|
recommended_next: [
|
|
2857
|
-
{ tool: "
|
|
2858
|
-
{ tool: "
|
|
2949
|
+
{ tool: "analyze_design", reason: "Audit color consistency and token coverage", args: { aspect: "color" } },
|
|
2950
|
+
{ tool: "set_properties", reason: "Modify selected node properties" },
|
|
2859
2951
|
{ tool: "screenshot", reason: "Visually inspect the selection" }
|
|
2860
2952
|
]
|
|
2861
2953
|
});
|
|
@@ -2870,7 +2962,7 @@ function register19(server, sendCommand) {
|
|
|
2870
2962
|
);
|
|
2871
2963
|
server.tool(
|
|
2872
2964
|
"get_node_details",
|
|
2873
|
-
"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
|
|
2965
|
+
"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.",
|
|
2874
2966
|
{
|
|
2875
2967
|
nodeId: z20.string().describe("The node ID to inspect")
|
|
2876
2968
|
},
|
|
@@ -2894,7 +2986,7 @@ function register19(server, sendCommand) {
|
|
|
2894
2986
|
return errorResult2(
|
|
2895
2987
|
"invalid_input",
|
|
2896
2988
|
`Could not process node '${nodeId}'`,
|
|
2897
|
-
{ suggestion: "Try
|
|
2989
|
+
{ suggestion: "Try get_node_data for raw data", tool: "get_node_data" }
|
|
2898
2990
|
);
|
|
2899
2991
|
}
|
|
2900
2992
|
const bv = boundVars?.boundVariables || null;
|
|
@@ -2914,8 +3006,8 @@ function register19(server, sendCommand) {
|
|
|
2914
3006
|
summary: `'${node.name}' (${node.type}, ${node.size.width}\xD7${node.size.height})${node.tokenCoverage != null ? `. Token coverage: ${Math.round(node.tokenCoverage * 100)}%` : ""}`,
|
|
2915
3007
|
data,
|
|
2916
3008
|
recommended_next: [
|
|
2917
|
-
{ tool: "
|
|
2918
|
-
{ tool: "
|
|
3009
|
+
{ tool: "set_properties", reason: "Modify this node's properties", args: { nodeIds: [nodeId] } },
|
|
3010
|
+
{ tool: "analyze_design", reason: "Check color token usage", args: { aspect: "color", nodeId } }
|
|
2919
3011
|
]
|
|
2920
3012
|
});
|
|
2921
3013
|
} catch (error) {
|
|
@@ -2929,7 +3021,7 @@ function register19(server, sendCommand) {
|
|
|
2929
3021
|
);
|
|
2930
3022
|
server.tool(
|
|
2931
3023
|
"search_nodes",
|
|
2932
|
-
"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
|
|
3024
|
+
"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.",
|
|
2933
3025
|
{
|
|
2934
3026
|
query: z20.string().optional().describe("Name substring to search for"),
|
|
2935
3027
|
types: z20.array(z20.string()).optional().describe("Node types to filter: FRAME, TEXT, COMPONENT, INSTANCE, RECTANGLE, etc."),
|
|
@@ -2962,7 +3054,7 @@ function register19(server, sendCommand) {
|
|
|
2962
3054
|
data,
|
|
2963
3055
|
recommended_next: matches.length > 0 ? [
|
|
2964
3056
|
{ tool: "get_node_details", reason: "Inspect a specific node", args: { nodeId: matches[0].id } },
|
|
2965
|
-
{ tool: "
|
|
3057
|
+
{ tool: "navigate", reason: "Focus on a found node", args: { action: "focus", nodeId: matches[0].id } }
|
|
2966
3058
|
] : [
|
|
2967
3059
|
{ tool: "get_document_overview", reason: "Check document structure" }
|
|
2968
3060
|
]
|
|
@@ -2978,7 +3070,7 @@ function register19(server, sendCommand) {
|
|
|
2978
3070
|
);
|
|
2979
3071
|
server.tool(
|
|
2980
3072
|
"get_design_tokens",
|
|
2981
|
-
"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
|
|
3073
|
+
"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.",
|
|
2982
3074
|
{},
|
|
2983
3075
|
{ readOnlyHint: true },
|
|
2984
3076
|
async () => {
|
|
@@ -3004,7 +3096,7 @@ function register19(server, sendCommand) {
|
|
|
3004
3096
|
data,
|
|
3005
3097
|
recommended_next: [
|
|
3006
3098
|
{ tool: "manage_variables", reason: "Create or update design tokens" },
|
|
3007
|
-
{ tool: "
|
|
3099
|
+
{ tool: "analyze_design", reason: "Check token coverage on selected nodes", args: { aspect: "color" } }
|
|
3008
3100
|
]
|
|
3009
3101
|
});
|
|
3010
3102
|
} catch (error) {
|
|
@@ -3043,7 +3135,7 @@ function register19(server, sendCommand) {
|
|
|
3043
3135
|
measured: result.size ? { node: result.node, size: result.size, children: result.children, dominantColors: result.dominantColors } : void 0,
|
|
3044
3136
|
recommended_next: [
|
|
3045
3137
|
{ tool: "get_selection_context", reason: "Get structured data about the selection" },
|
|
3046
|
-
{ tool: "
|
|
3138
|
+
{ tool: "set_properties", reason: "Make changes based on what you see" }
|
|
3047
3139
|
]
|
|
3048
3140
|
})
|
|
3049
3141
|
}
|
|
@@ -3237,7 +3329,9 @@ async function runTokenDebt(sendCommand, pageIds) {
|
|
|
3237
3329
|
}
|
|
3238
3330
|
const unused = (head.candidates ?? []).filter((c) => !used.has(c.id));
|
|
3239
3331
|
const borrows = head.borrowedTokens ?? [];
|
|
3240
|
-
const
|
|
3332
|
+
const borrowCount = head.borrowCount ?? borrows.length;
|
|
3333
|
+
const borrowsTruncated = borrowCount > borrows.length;
|
|
3334
|
+
const summary = `${borrowCount} borrowed token${borrowCount === 1 ? "" : "s"}` + (borrowsTruncated ? ` (showing ${borrows.length})` : "") + `, ${unused.length} unused across ${pagesScanned.length} page${pagesScanned.length === 1 ? "" : "s"}`;
|
|
3241
3335
|
return standardResult({
|
|
3242
3336
|
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" : ""),
|
|
3243
3337
|
data: {
|
|
@@ -3246,6 +3340,8 @@ async function runTokenDebt(sendCommand, pageIds) {
|
|
|
3246
3340
|
instancesScanned,
|
|
3247
3341
|
foundationGroups: head.foundationGroups,
|
|
3248
3342
|
borrowedTokens: borrows,
|
|
3343
|
+
borrowCount,
|
|
3344
|
+
borrowsTruncated,
|
|
3249
3345
|
unusedTokens: unused.slice(0, 40),
|
|
3250
3346
|
unusedCount: unused.length,
|
|
3251
3347
|
allScopesByGroup: head.allScopesByGroup,
|
|
@@ -3389,7 +3485,7 @@ async function runOverallAudit(aspectHandlers, nodeId) {
|
|
|
3389
3485
|
function registerAnalysisTools(server, sendCommand) {
|
|
3390
3486
|
server.tool(
|
|
3391
3487
|
"analyze_color_usage",
|
|
3392
|
-
"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
|
|
3488
|
+
"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.",
|
|
3393
3489
|
{
|
|
3394
3490
|
nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)")
|
|
3395
3491
|
},
|
|
@@ -3458,10 +3554,10 @@ function registerAnalysisTools(server, sendCommand) {
|
|
|
3458
3554
|
data,
|
|
3459
3555
|
warnings,
|
|
3460
3556
|
recommended_next: unboundColors.length > 0 ? [
|
|
3461
|
-
{ tool: "
|
|
3557
|
+
{ tool: "manage_variables", reason: "Bind unbound colors that match an existing variable", args: { action: "tokenize" } },
|
|
3462
3558
|
{ tool: "get_design_tokens", reason: "See available tokens for binding" }
|
|
3463
3559
|
] : [
|
|
3464
|
-
{ tool: "
|
|
3560
|
+
{ tool: "analyze_design", reason: "Check layout quality next", args: { aspect: "layout" } }
|
|
3465
3561
|
]
|
|
3466
3562
|
});
|
|
3467
3563
|
} catch (error) {
|
|
@@ -3475,7 +3571,7 @@ function registerAnalysisTools(server, sendCommand) {
|
|
|
3475
3571
|
);
|
|
3476
3572
|
server.tool(
|
|
3477
3573
|
"analyze_layout_quality",
|
|
3478
|
-
"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
|
|
3574
|
+
"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.",
|
|
3479
3575
|
{
|
|
3480
3576
|
nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)")
|
|
3481
3577
|
},
|
|
@@ -3515,7 +3611,7 @@ function registerAnalysisTools(server, sendCommand) {
|
|
|
3515
3611
|
message: i.issue,
|
|
3516
3612
|
nodeId: i.nodeId
|
|
3517
3613
|
})),
|
|
3518
|
-
recommended_next: issues.length > 0 ? [{ tool: "
|
|
3614
|
+
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" } }]
|
|
3519
3615
|
});
|
|
3520
3616
|
} catch (error) {
|
|
3521
3617
|
return errorResult2(
|
|
@@ -3528,7 +3624,7 @@ function registerAnalysisTools(server, sendCommand) {
|
|
|
3528
3624
|
);
|
|
3529
3625
|
server.tool(
|
|
3530
3626
|
"analyze_component_health",
|
|
3531
|
-
"Check component system health: detached instances, unused components, and override consistency. Use to audit design system adherence. Follow with
|
|
3627
|
+
"Check component system health: detached instances, unused components, and override consistency. Use to audit design system adherence. Follow with manage_components to fix issues.",
|
|
3532
3628
|
{
|
|
3533
3629
|
nodeId: z21.string().optional().describe("Scope to analyze (default: current page)")
|
|
3534
3630
|
},
|
|
@@ -3581,7 +3677,7 @@ function registerAnalysisTools(server, sendCommand) {
|
|
|
3581
3677
|
);
|
|
3582
3678
|
server.tool(
|
|
3583
3679
|
"analyze_accessibility",
|
|
3584
|
-
"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
|
|
3680
|
+
"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.",
|
|
3585
3681
|
{
|
|
3586
3682
|
nodeId: z21.string().optional().describe("Root node to analyze (default: current selection)"),
|
|
3587
3683
|
platform: z21.enum(["desktop", "mobile"]).optional().describe("Target-size profile: desktop 24px (default), mobile 44px")
|
|
@@ -3615,7 +3711,7 @@ function registerAnalysisTools(server, sendCommand) {
|
|
|
3615
3711
|
message: i.issue,
|
|
3616
3712
|
nodeId: i.nodeId
|
|
3617
3713
|
})),
|
|
3618
|
-
recommended_next: issues.length > 0 ? [{ tool: "
|
|
3714
|
+
recommended_next: issues.length > 0 ? [{ tool: "set_properties", reason: "Fix accessibility issues" }] : [{ tool: "validate_design_rules", reason: "Run full design validation" }]
|
|
3619
3715
|
});
|
|
3620
3716
|
} catch (error) {
|
|
3621
3717
|
return errorResult2(
|
|
@@ -3626,14 +3722,15 @@ function registerAnalysisTools(server, sendCommand) {
|
|
|
3626
3722
|
}
|
|
3627
3723
|
}
|
|
3628
3724
|
);
|
|
3629
|
-
const
|
|
3725
|
+
const CHECKPOINT_LIMIT = 200;
|
|
3726
|
+
const checkpoints = /* @__PURE__ */ new Map();
|
|
3630
3727
|
server.tool(
|
|
3631
3728
|
"diff_nodes",
|
|
3632
|
-
`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.`,
|
|
3729
|
+
`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.`,
|
|
3633
3730
|
{
|
|
3634
3731
|
nodeIdA: z21.string().describe("First node ID (or the checkpoint target)"),
|
|
3635
3732
|
nodeIdB: z21.string().optional().describe("Second node ID (omit when using checkpoint)"),
|
|
3636
|
-
checkpoint: z21.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now; compare: diff current vs saved snapshot")
|
|
3733
|
+
checkpoint: z21.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now (held in server memory until the server restarts, not in the file); compare: diff current vs saved snapshot")
|
|
3637
3734
|
},
|
|
3638
3735
|
{ readOnlyHint: true },
|
|
3639
3736
|
async ({ nodeIdA, nodeIdB, checkpoint }) => {
|
|
@@ -3641,32 +3738,25 @@ function registerAnalysisTools(server, sendCommand) {
|
|
|
3641
3738
|
if (checkpoint === "save") {
|
|
3642
3739
|
const node = await sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 });
|
|
3643
3740
|
if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
key: CHECKPOINT_KEY,
|
|
3648
|
-
value: JSON.stringify({ ts: Date.now(), name: node.name, summary })
|
|
3649
|
-
});
|
|
3741
|
+
checkpoints.delete(nodeIdA);
|
|
3742
|
+
checkpoints.set(nodeIdA, { ts: Date.now(), name: node.name, summary: normalizeNode(node) });
|
|
3743
|
+
if (checkpoints.size > CHECKPOINT_LIMIT) checkpoints.delete(checkpoints.keys().next().value);
|
|
3650
3744
|
return standardResult({
|
|
3651
|
-
summary: `Checkpoint saved for '${node.name}' (${nodeIdA}). Run diff_nodes with checkpoint:"compare" after editing to see what changed.`,
|
|
3745
|
+
summary: `Checkpoint saved for '${node.name}' (${nodeIdA}) in server memory. Run diff_nodes with checkpoint:"compare" after editing to see what changed.`,
|
|
3652
3746
|
data: { nodeId: nodeIdA, saved: true },
|
|
3653
3747
|
recommended_next: []
|
|
3654
3748
|
});
|
|
3655
3749
|
}
|
|
3656
3750
|
if (checkpoint === "compare") {
|
|
3657
|
-
const
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
|
|
3662
|
-
const raw = typeof stored === "string" ? stored : stored?.value;
|
|
3663
|
-
if (!raw) {
|
|
3664
|
-
return errorResult2("invalid_input", `No checkpoint saved on '${nodeIdA}'`, {
|
|
3665
|
-
suggestion: 'Save one first with checkpoint:"save"',
|
|
3751
|
+
const saved = checkpoints.get(nodeIdA);
|
|
3752
|
+
if (!saved) {
|
|
3753
|
+
return errorResult2("invalid_input", `No checkpoint for '${nodeIdA}' in this server session`, {
|
|
3754
|
+
suggestion: `Save one first with checkpoint:"save" \u2014 checkpoints are held in server memory (the latest ${CHECKPOINT_LIMIT}) and a server restart clears them`,
|
|
3666
3755
|
tool: "diff_nodes"
|
|
3667
3756
|
});
|
|
3668
3757
|
}
|
|
3669
|
-
const
|
|
3758
|
+
const node = await sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 });
|
|
3759
|
+
if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
|
|
3670
3760
|
const before = saved.summary;
|
|
3671
3761
|
const after = normalizeNode(node);
|
|
3672
3762
|
const differences2 = [];
|
|
@@ -3682,7 +3772,7 @@ function registerAnalysisTools(server, sendCommand) {
|
|
|
3682
3772
|
compareField(differences2, "layout.gap", before.layout?.gap, after.layout?.gap);
|
|
3683
3773
|
compareField(differences2, "layout.padding", before.layout?.padding, after.layout?.padding);
|
|
3684
3774
|
}
|
|
3685
|
-
const age = Math.round((Date.now() -
|
|
3775
|
+
const age = Math.round((Date.now() - saved.ts) / 1e3);
|
|
3686
3776
|
return standardResult({
|
|
3687
3777
|
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`,
|
|
3688
3778
|
data: {
|
|
@@ -3730,7 +3820,7 @@ function registerAnalysisTools(server, sendCommand) {
|
|
|
3730
3820
|
return standardResult({
|
|
3731
3821
|
summary: differences.length === 0 ? `'${nodeA.name}' and '${nodeB.name}' are identical in key properties` : `${differences.length} difference(s) between '${nodeA.name}' and '${nodeB.name}'`,
|
|
3732
3822
|
data,
|
|
3733
|
-
recommended_next: differences.length > 0 ? [{ tool: "
|
|
3823
|
+
recommended_next: differences.length > 0 ? [{ tool: "set_properties", reason: "Align properties between nodes" }] : []
|
|
3734
3824
|
});
|
|
3735
3825
|
} catch (error) {
|
|
3736
3826
|
return errorResult2(
|
|
@@ -3817,7 +3907,7 @@ function analyzeLayoutNode(node, issues, _counters) {
|
|
|
3817
3907
|
nodeName: node.name,
|
|
3818
3908
|
issue: `Frame '${node.name}' has ${node.children.length} children but no auto-layout`,
|
|
3819
3909
|
severity: "warning",
|
|
3820
|
-
suggestion: "Add auto-layout with
|
|
3910
|
+
suggestion: "Add auto-layout with set_properties for responsive behavior"
|
|
3821
3911
|
});
|
|
3822
3912
|
}
|
|
3823
3913
|
}
|
|
@@ -3935,7 +4025,7 @@ import { z as z22 } from "zod";
|
|
|
3935
4025
|
function register21(server, sendCommand) {
|
|
3936
4026
|
server.tool(
|
|
3937
4027
|
"verify_changes",
|
|
3938
|
-
"Verify that a node's current properties match expected values. Use after
|
|
4028
|
+
"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.",
|
|
3939
4029
|
{
|
|
3940
4030
|
nodeId: z22.string().describe("Node ID to verify"),
|
|
3941
4031
|
expected: z22.record(z22.string(), z22.unknown()).describe("Expected property values, e.g. { fill: '#3B82F6', cornerRadius: 8 }")
|
|
@@ -3974,7 +4064,7 @@ function register21(server, sendCommand) {
|
|
|
3974
4064
|
return standardResult({
|
|
3975
4065
|
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`,
|
|
3976
4066
|
data,
|
|
3977
|
-
recommended_next: allMatch ? [{ tool: "screenshot", reason: "Visually confirm the changes" }] : [{ tool: "
|
|
4067
|
+
recommended_next: allMatch ? [{ tool: "screenshot", reason: "Visually confirm the changes" }] : [{ tool: "set_properties", reason: "Re-apply changes that didn't match", args: { nodeIds: [nodeId] } }]
|
|
3978
4068
|
});
|
|
3979
4069
|
} catch (error) {
|
|
3980
4070
|
return errorResult2(
|
|
@@ -4029,7 +4119,7 @@ function register21(server, sendCommand) {
|
|
|
4029
4119
|
severity: coverage >= 0.5 ? "warning" : "error",
|
|
4030
4120
|
message: `Token coverage: ${Math.round(coverage * 100)}%${coverageDetail} (target: 80%+)`,
|
|
4031
4121
|
nodeId: targetId,
|
|
4032
|
-
fix: coverage < 0.8 ? { tool: "
|
|
4122
|
+
fix: coverage < 0.8 ? { tool: "manage_variables", reason: "Bind hardcoded values that match an existing variable", args: { action: "tokenize", nodeId: targetId } } : void 0
|
|
4033
4123
|
});
|
|
4034
4124
|
if (nodeInfo.type === "FRAME" || nodeInfo.type === "COMPONENT") {
|
|
4035
4125
|
const hasAutoLayout = nodeInfo.layoutMode && nodeInfo.layoutMode !== "NONE";
|
|
@@ -4040,7 +4130,7 @@ function register21(server, sendCommand) {
|
|
|
4040
4130
|
severity: "warning",
|
|
4041
4131
|
message: hasAutoLayout ? `Auto-layout: ${nodeInfo.layoutMode}` : hasChildren ? `No auto-layout on frame with ${nodeInfo.children.length} children` : "No children requiring auto-layout",
|
|
4042
4132
|
nodeId: targetId,
|
|
4043
|
-
fix: !hasAutoLayout && hasChildren ? { tool: "
|
|
4133
|
+
fix: !hasAutoLayout && hasChildren ? { tool: "set_properties", reason: "Add auto-layout for responsive behavior", args: { nodeIds: [targetId] } } : void 0
|
|
4044
4134
|
});
|
|
4045
4135
|
}
|
|
4046
4136
|
const hasDefaultName = /^(Frame|Rectangle|Ellipse|Group|Text|Vector|Line|Polygon|Star)\s*\d*$/.test(nodeInfo.name);
|
|
@@ -4050,7 +4140,7 @@ function register21(server, sendCommand) {
|
|
|
4050
4140
|
severity: "info",
|
|
4051
4141
|
message: hasDefaultName ? `'${nodeInfo.name}' uses a default Figma name \u2014 rename for clarity` : `Name '${nodeInfo.name}' looks intentional`,
|
|
4052
4142
|
nodeId: targetId,
|
|
4053
|
-
fix: hasDefaultName ? { tool: "
|
|
4143
|
+
fix: hasDefaultName ? { tool: "set_properties", reason: "Rename with a semantic name", args: { nodeIds: [targetId] } } : void 0
|
|
4054
4144
|
});
|
|
4055
4145
|
try {
|
|
4056
4146
|
const drift = await sendCommand("audit_voice_drift", { nodeId: targetId }, 6e4);
|
|
@@ -4077,7 +4167,7 @@ function register21(server, sendCommand) {
|
|
|
4077
4167
|
severity: "warning",
|
|
4078
4168
|
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" : ""})`,
|
|
4079
4169
|
nodeId: targetId,
|
|
4080
|
-
fix: !adequate ? { tool: "
|
|
4170
|
+
fix: !adequate ? { tool: "set_properties", reason: "Increase size to meet the target-size minimum", args: { nodeIds: [targetId] } } : void 0
|
|
4081
4171
|
});
|
|
4082
4172
|
}
|
|
4083
4173
|
try {
|
|
@@ -4295,28 +4385,34 @@ function registerRoomTool(server, joinRoom, listRooms) {
|
|
|
4295
4385
|
async ({ room }) => {
|
|
4296
4386
|
try {
|
|
4297
4387
|
if (!room) {
|
|
4298
|
-
const
|
|
4299
|
-
if (
|
|
4388
|
+
const rooms2 = (await listRooms()).filter((r) => r.hasPlugin);
|
|
4389
|
+
if (rooms2.length === 0) {
|
|
4300
4390
|
return {
|
|
4301
4391
|
content: [{ type: "text", text: "No Figma plugin is connected. Open the Relai plugin in Figma (it connects automatically), then try again." }]
|
|
4302
4392
|
};
|
|
4303
4393
|
}
|
|
4304
|
-
const lines =
|
|
4394
|
+
const lines = rooms2.map(
|
|
4305
4395
|
(r) => `- ${r.room}${r.fileName ? ` \u2014 "${r.fileName}"` : ""}`
|
|
4306
4396
|
);
|
|
4307
4397
|
return {
|
|
4308
4398
|
content: [{
|
|
4309
4399
|
type: "text",
|
|
4310
|
-
text:
|
|
4311
|
-
${lines[0]}` : `${
|
|
4400
|
+
text: rooms2.length === 1 ? `One plugin connected (auto-pairing will use it):
|
|
4401
|
+
${lines[0]}` : `${rooms2.length} plugins connected \u2014 call join_room with the room of the file you want:
|
|
4312
4402
|
${lines.join("\n")}`
|
|
4313
4403
|
}]
|
|
4314
4404
|
};
|
|
4315
4405
|
}
|
|
4316
4406
|
await joinRoom(room);
|
|
4407
|
+
const rooms = await listRooms().catch(() => null);
|
|
4408
|
+
const here = rooms?.find((r) => r.room === room && r.hasPlugin);
|
|
4409
|
+
const where = !rooms ? "." : here ? ` \u2014 "${here.fileName ?? "unnamed file"}".` : " \u2014 no plugin is there yet; commands will fail until that file's plugin opens.";
|
|
4317
4410
|
return {
|
|
4318
4411
|
content: [
|
|
4319
|
-
{
|
|
4412
|
+
{
|
|
4413
|
+
type: "text",
|
|
4414
|
+
text: `Joined room ${room}${where} Every agent in this MCP server process (one client session) shares this room \u2014 a join_room from any of them moves them all.`
|
|
4415
|
+
}
|
|
4320
4416
|
]
|
|
4321
4417
|
};
|
|
4322
4418
|
} catch (error) {
|
|
@@ -4382,7 +4478,7 @@ function listToolCatalog() {
|
|
|
4382
4478
|
}
|
|
4383
4479
|
|
|
4384
4480
|
// ../../docs/skills/figma-plugin-api.md
|
|
4385
|
-
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<!-- 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';
|
|
4481
|
+
var figma_plugin_api_default = "# Figma Plugin API Cheat Sheet \u2014 for `execute_figma`\n\nRules and patterns for writing code that runs in the plugin sandbox. Work in small incremental scripts and `screenshot` between steps.\n\n## Non-negotiable rules (dynamic-page mode)\n\n- **Node lookup is async**: `await figma.getNodeByIdAsync(id)` \u2014 `getNodeById` throws in this plugin.\n- **Load pages before traversal**: `await page.loadAsync()` before `page.findAll(...)` on a non-current page; `figma.currentPage` is always loaded.\n- **Load fonts before ANY text edit**: `await figma.loadFontAsync(textNode.fontName)` \u2014 if `fontName === figma.mixed`, load every range: `for (const f of textNode.getRangeAllFontNames(0, textNode.characters.length)) await figma.loadFontAsync(f)`.\n- **`figma.mixed` is a Symbol**: properties like `fontSize`, `cornerRadius`, `fills` return it when values differ across ranges/children. Check `=== figma.mixed` before using; never JSON-serialize it.\n\n## Common patterns\n\n```js\n// Selection\nconst sel = figma.currentPage.selection; // read\nfigma.currentPage.selection = [node]; // write\n\n// Create + place\nconst frame = figma.createFrame();\nframe.resize(320, 200); // width/height are read-only; use resize()\nparent.appendChild(frame); // default parent is currentPage\n\n// Fills/strokes are ARRAYS and must be reassigned wholesale\nnode.fills = [{ type: \"SOLID\", color: { r: 1, g: 0.5, b: 0 } }]; // rgb 0-1, no alpha key \u2014 use opacity\nconst fills = JSON.parse(JSON.stringify(node.fills)); // clone before mutating\nfills[0].color.r = 0; node.fills = fills;\n\n// Auto-layout: set layoutMode FIRST, then padding/spacing/align\nframe.layoutMode = \"VERTICAL\";\nframe.itemSpacing = 8; frame.paddingTop = 16;\nchild.layoutSizingHorizontal = \"FILL\"; // requires PARENT with auto-layout\n\n// Components\nconst comp = await figma.importComponentByKeyAsync(key); // team library\nconst inst = comp.createInstance();\ninst.setProperties({ Variant: \"Primary\" }); // throws on unknown property names\n\n// Variables\nconst collections = await figma.variables.getLocalVariableCollectionsAsync();\nconst v = figma.variables.createVariable(\"name\", collection, \"COLOR\");\nv.setValueForMode(modeId, { r: 0, g: 0, b: 0 });\nnode.setBoundVariable(\"fills\", v); // binding paint needs figma.variables.setBoundVariableForPaint on the paint object for fills in older APIs \u2014 prefer setBoundVariable where available\n\n// Export (returns Uint8Array)\nconst bytes = await node.exportAsync({ format: \"PNG\", constraint: { type: \"SCALE\", value: 2 } });\n```\n\n## `relai.*` helpers \u2014 the shortest path is the correct one\n\nAlongside `figma`, scripts get a `relai` object whose helpers are immune to the pitfalls below. Prefer them:\n\n```js\nawait relai.text(parent, \"Hello\", { font: {family:\"IBM Plex Mono\", style:\"Regular\"}, size: 14, color: {r:0,g:0,b:0} });\n // loads the font BEFORE writing characters\nconst card = relai.autoLayout(\"VERTICAL\", { name: \"Card\", itemSpacing: 8 });\n // auto-layout frame, both axes hugging\nrelai.set(node, { layoutMode: \"HORIZONTAL\", width: 320, opacity: 0.9 });\n // layoutMode applied first; width/height via resize()\nrelai.hug(node); // HUG that sticks \u2014 call after appending children\nrelai.focusRing(button); // clipsContent + double spread shadows that render\nconst page = await relai.page(p => p.children.some(c => c.name === \"Button\"));\n // find pages by CONTENT \u2014 names get renamed\nconst titles = relai.query('FRAME[name^=Card] > TEXT');\n // CSS-like search on the current page; relai.query(node, sel) scopes to a subtree.\n // Supported: TYPE, *, [name=] [name*=] [name^=] [name$=], descendant, >, comma.\n // NOT supported: pseudo-classes, dot-paths, sibling combinators \u2014 use findAll.\nrelai.placeholder(section); // construction veil so the designer sees work-in-progress\nrelai.placeholder(section, false); // ALWAYS remove when the section is done\n```\n\nTwo important facts: **scripts are NOT atomic** \u2014 on error, changes made before the throw persist, so keep scripts small and clean up after failures; and results may carry a `warnings` array for silent mistakes the lint catches on relai-created nodes and any node ids you return (e.g. spread shadows on a non-clipping frame). Convention: **return every created/mutated node id** (`return { createdNodeIds: [...] }`) \u2014 it powers both follow-up calls and the lint.\n\n## Pitfalls that throw (or silently do the wrong thing)\n\nWhen one of these throws inside `execute_figma`, the error already carries the remedy as a `Hint:` \u2014 this list is generated from the same registry (`packages/shared/src/pitfalls.ts`).\n\n<!-- PITFALLS:START -->\n- **`not a function` errors may name no symbol** \u2014 Figma's VM drops the subject. Usual cause: APIs from other agent tools that don't exist here (`node.query()`, `figma.notify()`). Use `findAll`/`findAllWithCriteria` for search; bisect when the message names nothing.\n- **Editing text without loading its font throws** (`unloaded font`). `await figma.loadFontAsync(textNode.fontName)` first; new TextNodes default to Inter Regular; when `fontName === figma.mixed`, load every font from `getRangeAllFontNames()`.\n- **`getNodeById` throws in dynamic-page mode** \u2014 always `await figma.getNodeByIdAsync(id)`.\n- **Traversing a non-current page throws until you `await page.loadAsync()`** \u2014 `figma.currentPage` is always loaded, other pages are not. Two things make this bite later than it should. `getNodeByIdAsync` resolves a node on an unloaded page perfectly well, so a handler handed a node id looks healthy right up to the moment it walks children. And the page is often already loaded by whatever ran first, so identical code passes in a session where another tool touched that page and throws in one where it did not \u2014 which reads as flakiness rather than a missing load. Load the page you need yourself; and if the caller swallows handler errors, this stops being a failure and becomes a check that silently disappears.\n- **`layoutSizingHorizontal/Vertical` throw without auto-layout** \u2014 FILL requires the parent to have a `layoutMode`, HUG requires it on the node itself.\n- **`createPage()` throws on the free plan once a file has 3 pages** \u2014 reuse an existing page instead.\n- **`width`/`height` are read-only** \u2014 use `node.resize(w, h)`.\n- **`figma.mixed` is a Symbol** returned by `fontSize`, `cornerRadius`, `fills` etc. when values differ across ranges/children \u2014 check `=== figma.mixed` before use; never JSON-serialize it.\n- **Exact-name lookups are fragile** \u2014 designers rename pages and layers freely; locate nodes by type/content instead of `name ===`.\n- **Stale node ids throw `does not exist`** \u2014 nodes get deleted while you work; re-read before editing and check `node.removed`.\n- **Nodes are non-extensible** \u2014 `node.myCustomProp = x` throws `object is not extensible`; use `setPluginData` or return the data instead.\n- **Shadow `spread` renders only on shapes or frames with `clipsContent: true`** \u2014 a focus ring built from spread shadows is invisible on a non-clipping frame/component.\n- **`resize()` on an auto-layout frame silently pins that axis to FIXED**, overriding `primaryAxisSizingMode: \"AUTO\"`. Append children first, then set `layoutSizingHorizontal = \"HUG\"`; use `resize` only for the fixed cross-axis.\n- **Per-corner radius** (`topLeftRadius` \u2026) only exists on RectangleCornerMixin nodes (rectangles, frames, components) \u2014 polygons, stars and lines throw.\n- **Instance children can't be added or removed** \u2014 detach first, or edit the main component.\n- **Figma's slot feature (Convert to slot, \u21E7\u2318S) has no Plugin API** \u2014 no createSlot/convertToSlot exists (verified against typings 1.123 and at runtime, 2026-07). Scaffold a frame named \"Slot\", select it for the designer (navigate select), and let them press the shortcut.\n- **A converted SLOT node stops hugging** \u2014 after the designer converts a frame to a slot it carries its own fixed size. Re-assert `layoutSizingVertical = \"HUG\"` (and the intended horizontal mode) on the SLOT node afterwards; the API can still edit its layout props.\n- **Variantizing can silently pin the hug axis to FIXED** \u2014 observed after clone \u2192 counterAxisSizingMode/width-variable changes \u2192 combineAsVariants: the variants' `primaryAxisSizingMode` ended up FIXED though the source hugged. Always verify sizing modes on every variant after combineAsVariants and re-assert `\"AUTO\"`.\n- **A backgrounded Figma window suspends the plugin iframe** \u2014 timers freeze and the socket can't pong, so the relay's staleness sweep kicks the plugin and the frozen 2s redial never fires until the window regains attention. The panel redials on visibilitychange/focus (ui.html \u22650.2.4); when a paired plugin goes missing mid-session, ask the designer to click the Figma window before assuming anything is broken.\n- **A freshly-converted slot can still report `type: \"FRAME\"` to the Plugin API** \u2014 the layer panel shows the slot badge while getNodeByIdAsync returns FRAME (a reload boundary was observed to fix it). Never gate automation on `type === \"SLOT\"`; match the frame by name and trust the designer's visual confirmation.\n- **Deleted variables are soft-deleted ghosts** \u2014 `getVariableByIdAsync` still resolves them with name AND working values (`Variable` has no `removed` property), so bindings to them keep rendering while being invisible in pickers and dead on publish. To test aliveness, check membership in `getLocalVariablesAsync()` (remote variables are legitimately absent \u2014 check `variable.remote`).\n- **Variables cannot be reordered via the API** (`variableIds` is read-only; the `move\u2026After` methods exist only for styles) \u2014 new variables always append last; panel order is manual-drag only. NEVER delete-and-recreate variables to reorder: bindings reference variable IDs, so every binding in the file silently dies (observed in production; the ghosts keep rendering, hiding the damage).\n- **A remote (library) text style dominates variable bindings** \u2014 on text with a remote style applied, `setBoundVariable` writes appear to succeed but snap back to the style's own bindings. Clear the style first (`await setTextStyleIdAsync(\"\")`), then bind.\n- **Binding writes inside instances silently revert \u2014 with two escape hatches.** `setBoundVariable` on a node nested inside an INSTANCE succeeds without error, then reverts (instance roots placed directly in a main ARE writable; anything deeper is not). What does persist: reassigning the whole `fills`/`strokes` array (variable-bound paints ride inside), and range-level `setRangeBoundVariable` on text. Verify after writing; never trust the call alone.\n- **A bound `strokeWeight` renders nothing while `strokes` is empty, and wrappers don't hug stroke thickness.** A LINE's visible weight lives entirely in its stroke paint; the frame around it keeps its own raw height/width, so the panel shows an unbound number with empty Fill/Stroke \u2014 looking broken when it isn't. Inspect the LINE child's stroke bindings, not the wrapper; and bind the wrapper's thickness-axis dimension to the same variable so nothing stays raw.\n- **`setBoundVariable(\"strokeWeight\", \u2026)` is a silent no-op on FRAME-family nodes.** Frames and components bind stroke weight per side \u2014 `strokeTopWeight` / `strokeRightWeight` / `strokeBottomWeight` / `strokeLeftWeight`; the uniform `strokeWeight` field only binds on stroke-geometry nodes like LINE. No error is thrown, the binding just never appears \u2014 read back `boundVariables` after writing.\n- **Cloning a variant escapes its component set \u2014 onto whatever page the user is viewing.** `variant.clone()` parents the copy to `figma.currentPage` as a standalone COMPONENT (not into the set) \u2014 plain nodes inside components clone to the current page too. Every `componentPropertyReferences` pointing at set-level properties is silently severed, and native SLOT children are demoted to plain FRAMEs (slot-ness needs set context). Recover by `componentSet.appendChild(clone)`, re-assigning ALL references (`visible`, `slotContentId`, \u2026) \u2014 a SLOT can be restored by cloning a surviving SLOT node from a sibling variant (SLOT clones keep their type) and relinking `componentPropertyReferences.slotContentId`. Audit every set-scoped feature after adoption; none of it comes back on its own.\n- **Uneven variant renames/deletes poison the whole component set.** If variants end up with mismatched axis segments (some carry `axis=value`, others don't) or duplicate names, the set enters an error state where `componentPropertyDefinitions` AND every instance's `variantProperties`/`setProperties` throw (`Component set has existing errors`). Escape: rename stragglers so all variants share the same axes with unique values (temporary `legacy-*` values work), which heals the set \u2014 then migrate and delete.\n- **`setProperties` DOES work on instances nested inside other instances** \u2014 it records as an override on the outer instance. Only variable-binding writes (`setBoundVariable`) silently revert inside instance internals. Don't skip nested instances when migrating variant or boolean properties; skip them only for binding writes.\n- **`Plugin runtime aborted` is an out-of-memory, not a timeout.** The stack shows `deepWrap` recursing into `newString` before `abort`. Reading deep-wrapped properties \u2014 `componentProperties`, `overrides`, `boundVariables`, `exportSettings` \u2014 copies a fresh object into the QuickJS sandbox on EVERY access, so a loop over tens of thousands of instances kills the runtime even though each read looks free. Chunk the scan and keep only the fields you need. (Real case: `componentProperties` over 29,861 instances aborted; the same scan over 7,139 was fine.)\n- **What a file USES is not what its library HAS.** `get_design_system`'s remote-used list only reports components the file already placed \u2014 a library component nobody has dragged in yet is invisible to the plugin, and no amount of scanning the document will reveal it. Read the published catalog before concluding a component doesn't exist (one real case: a design system's `TextArea` was declared missing and hand-rebuilt from a stretched `Text Field`, because the file had never used the real one).\n- **A component property's identity is the `#id` suffix, not its display name.** Renaming `show label#4525:247` to `hasLabel#4525:247` in the library keeps every instance's override \u2014 text and boolean values survive a library update untouched. VARIANT axes are the opposite: they have no id and are matched by name, so renaming an axis or its values (`layout=horizontal` \u2192 `labelPosition=side`) is the only thing that actually drops instances back to a default. When auditing a library migration, snapshot the variant axes and ignore the renamed text/boolean props.\n- **`importComponentSetByKeyAsync` returns the file's LOCAL copy, not the latest published version.** If the file holds a stale copy of a library component, the import hands back the stale one and looks authoritative. To see what is actually published, read `componentPropertyDefinitions` over REST (`/v1/files/{key}/nodes?ids={node_id}&depth=1`) and compare. Importing is also expensive \u2014 each call materialises the whole set (a 72-variant set is thousands of nodes), and a handful in one script will abort the sandbox.\n- **GRID auto-layout has read-only positions and imperative setters.** `gridRowAnchorIndex` / `gridColumnAnchorIndex` are readonly \u2014 move a child with `node.setGridChildPosition(row, column)` (spans stay writable: `gridColumnSpan`). `gridColumnSizes` / `gridRowSizes` reject array assignment (`no setter for property`), but `gridColumnCount` / `gridRowCount` are writable and each returned `GridTrackSize` has `.type` / `.value` setters \u2014 so add a track by raising the count, then set the new track's type. Shrinking the row count is how you drop a leftover empty row.\n- **`figma.root.findAll` throws in dynamic-page mode until you `await figma.loadAllPagesAsync()`.** Page-scoped work never needs it: `figma.currentPage` is already loaded and `relai.query` searches from a node you pass. Load the whole document only when the search genuinely spans pages \u2014 on a 40-page file it is seconds of work and memory.\n- **`instance.mainComponent` throws in dynamic-page mode** \u2014 `await instance.getMainComponentAsync()`. The same rule catches every sync sibling of an async API (`getNodeById`, `textStyleId =`, `page.children` on an unloaded page): when the message says *use X instead*, it means the whole traversal from that point is async.\n- **A variant has no component property definitions of its own** (`Can only get component property definitions of a component set or non-variant component`). They belong to the COMPONENT_SET \u2014 read `node.parent` when a variant is what you have, and add new TEXT/BOOLEAN properties to the set too: adding them to a single variant either fails or leaves the other variants without them.\n- **`SPACE_BETWEEN` centres a lone child.** A header built as label + value with the value hidden by default looks correct in the variant you were staring at and lands centred everywhere else \u2014 because Figma spreads a single item to the middle rather than leaving it at the start. When one side is optional, align the row `MIN` and let the first child FILL: the optional one stays pinned to the far edge when it appears, and the other stays put when it does not.\n- **Applying a text style changes the node's font, so a `characters` write right after it can throw for a font nobody loaded.** `await node.setTextStyleIdAsync(id)` may swap the family or weight; load the *new* font (`await figma.loadFontAsync(node.fontName)`) before writing text. Applying the style is also the last word on properties it owns \u2014 a `textDecoration` written in the same breath is overwritten by the style landing, so set it in a later call.\n- **Text edits inside a component SET propagate to every variant.** Applying a text style to one variant's label retypes the matching layer in all of them \u2014 same for `textDecoration` and text fills, at node level and via `setRange*` alike. Renaming the layer does not break the link (matching is positional). So a set whose sizes need DIFFERENT text styles cannot be styled in place: apply the styles while the components are still standalone and combine afterwards \u2014 an atom does not save you either unless the atom's variants are standalone components rather than a set of their own. Layers bound to one TEXT component property are linked through the property as well: every layer whose `characters` references it shares a single `textDecoration` \u2014 write it on one variant and all of them change, re-binding a layer pulls it back to the shared value, and only a layer whose reference is removed keeps its own. The binding step writes too: pointing five variants' titles at a new TEXT property replaced their `characters` with the property's default and reset their underline to `NONE`, while the `combineAsVariants` just before it had kept every variant's underline. So a label whose decoration differs per variant cannot sit on a shared TEXT property. And a decoration written in the same execution as `setTextStyleIdAsync` is clobbered by the style landing \u2014 set it in a later call.\n- **Writing `leadingTrim` on a text node detaches its text style** and strands the style's typography variables as node-level bindings \u2014 the node keeps rendering but no longer follows the style. `textDecoration` is safe (the style survives); `leadingTrim` is not. When a cap-trimmed label needs to sit in a full line box (so an icon beside it doesn't hang below the baseline, and toggling the icon doesn't change the height), leave the trim alone and bind the PARENT frame's height to the same leading variable the style uses.\n- **A variable bound to a component property's VALUE hides from `boundVariables`.** `node.boundVariables.componentProperties` lists the VARIANT properties and nothing else \u2014 a token bound to a TEXT or INSTANCE_SWAP property (an icon glyph name, a swapped instance) lives only in `instance.componentProperties[key].boundVariables.value`. Read one location and every such token looks unreferenced. Bindings authored as a property's *default* sit in a third place, `componentPropertyDefinitions[key].boundVariables`, on the SET.\n- **\"Plugin runtime aborted\" is out of memory, not a timeout**, and reading `instance.componentProperties` in bulk is the fastest way there: the getter deep-wraps every property object, so 1,000 instances costs about 4 seconds and 5,700 kills the sandbox. Memory is only reclaimed when the command returns, so the fix is slices \u2014 walk a fixed number of nodes per command and have the caller ask again \u2014 not a tidier traversal inside one script.\n- **`component.clone()` loses every `componentPropertyReferences` until it is back inside a SET** \u2014 and it does not get them back when you append it. Cloning a variant to make a new one therefore yields a variant whose label, `show*` toggles and swaps are all dead: the layers are there, the wiring is not. The clone renders convincingly because each layer keeps the *value* it had. After `set.appendChild(clone)`, walk the source variant and the clone in `findAll` order and copy `componentPropertyReferences` node for node.\n- **An instance whose variant you deleted becomes a ghost, and `setProperties` cannot reach it.** It still reports a `mainComponent` \u2014 named after the *deleted* variant, parented to the live set \u2014 but its `componentProperties` no longer carry the set's variant axes, so writes are dropped without an error. Figma does not fall back to the default variant. The repair is `instance.swapComponent(targetVariant)` and then `setProperties`; a variant-count audit will not find these, because the set itself looks healthy.\n- **SLOT properties are per-node and giving two of them the same name does not merge them.** A set whose variants each carry their own slot ends up with two entries \u2014 `Items Slot#1938:15` and `Items Slot#1938:18` \u2014 and renaming them alike only makes the panel show two rows nobody can tell apart, which is worse than the honest `Sm`/`Md` names. The consequence worth knowing before designing around it: content placed in one variant's slot does **not** survive switching to a variant that owns the other slot, and there is no way to express \"one slot, shared across variants\" today.\n- **`componentPropertyReferences` cannot be set on a layer inside a nested instance \u2014 but almost everything else on that layer can.** The references belong to that instance's own main component, so writing them throws `Cannot set component property references on instance sublayer`. The trap is over-correcting: excluding instance sublayers from the *paint* loop as well leaves half the job done and no error, because fills, characters and visibility on those same sublayers are perfectly writable \u2014 and in a component whose label or icon lives inside a nested atom, that is exactly where the colour you meant to change lives. Skip sublayers when copying references; include them when painting.\n- **Deleting a layer from a component can silently orphan instance-side overrides on layers you did not touch.** Figma re-creates part of the instance's subtree with fresh node ids, and any override keyed to the old ids is dropped \u2014 on the instance only. The component still renders correctly, which is exactly why nobody notices: the master is where you look. After removing layers from a set, diff each instance against its main property by property; a screenshot of the component proves nothing about its instances.\n- **Setting `layoutSizingHorizontal/Vertical = \"FIXED\"` right after `insertChild` freezes the node at the size the row just stretched it to**, not the size it wants. An icon instance that is 12\xD712 on its own lands in a 16-tall row, gets stretched, and the FIXED you meant as *don't grow* records 16\xD716 permanently. Insert, let the layout settle, and set `layoutAlign`/`layoutGrow` instead \u2014 or copy the posture of a healthy sibling in the same row rather than asserting sizing modes from scratch.\n- **A component that sizes itself from the inside must not be re-sized from the outside.** Where a wrapper hugs a child whose own width/height are bound to size tokens, the wrapper carries no bindings at all \u2014 that absence *is* the mechanism. Binding the outer instance's width/height to look tidy overrides the child and pins one size across every variant, and the mistake is invisible because the number is right in whichever variant you were looking at. Before binding a dimension, check whether the node already derives it.\n- **A long editing session leaves `findAll` enumerating instance sublayers that no longer exist.** After a few hundred `swapComponent` calls and nested component-property writes, `page.findAll` still returns proxies whose ids are gone: touching one \u2014 `getMainComponentAsync`, or merely `.name` \u2014 throws *The node (instance sublayer or table cell) with id \u2026 does not exist*, while `getNodeByIdAsync` on the same id answers `null`. The document is fine (it renders and validates); it is the enumeration that is stale, and a plugin reload clears it. Two consequences for doc-wide sweeps: wrap each node in its own try/catch, and never read the failing node inside the catch to report it.\n- **A plain write clears a variable-bound component property only on the node you write to \u2014 read back to confirm which node still holds the alias.** `instance.setProperties({ key: \"diamond\" })` does drop the `VARIABLE_ALIAS` on that instance (measured both with a different value and with the token's own resolved string). What surprises people is where the binding *else* lives: set it on one instance and the main component keeps its own alias, so re-reading the master still reports bound; and an instance nested two levels down (an icon instance inside a `Body Cell` inside a `Column`) carries its own override that the parent write never touches. Always read the property back on the exact node you meant to change, and sweep nested instances separately.\n- **Component property keys are `name#id`, and the name may itself contain `#`** \u2014 an icon component can ship `Icon Name#4866:0`, `Icon Name w/##4866:28` and `Icon Name w/###4866:56`. Splitting on the *first* `#` maps the last two to the same base name, so a lookup silently binds only one of them and the duotone layers keep their placeholder glyph. Split on `lastIndexOf(\"#\")`. The mirror trick is useful: matching on the `#id` tail alone follows a property across a rename, since `editComponentProperty` keeps the id.\n- **An instance under an explicit variable mode can get stuck on the placeholder \u2014 when a plugin creates it there, or switches its variant there \u2014 and waiting does not fix it.** Create an instance, drop it under a node whose Theme mode differs from the page's, and read it back: the nested variant *does* swap (`iconFamily=Phosphor` \u2192 `FA7`), but a component property bound to a variable resolves to the new variant's **default** rather than to the token, so every icon reads as the placeholder glyph. Measured: still wrong after three minutes untouched \u2014 in `characters` and in the rendered screenshot alike \u2014 and setting the *same* mode again changes nothing. What unsticks it is `clearExplicitVariableModeForCollection` and then `setExplicitVariableModeForCollection` **in two separate `execute_figma` calls**: clearing and re-setting inside one script leaves it stuck (measured both when read 1.5 seconds later in the same script and when read in the next call). The binding was never destroyed, only unresolved. The order does not matter: appending to a parent with no explicit mode and setting the parent's mode in a later call leaves the same placeholder. Nor does it take a new instance: switching an existing instance's variant with `setProperties` under a pinned parent drops the same way \u2014 glyph properties fall back to the unbound placeholder, icon fills to the default variable, a bound size to none \u2014 but only in modes that swap a nested instance to a different variant than its main uses; where the mode resolves to the main's own icon family, nothing drops. Clear-then-set in separate calls restored every binding in both cases. It is not a sure cure: in another probe (five button instances created in one call, their parent's mode set in the next), clearing and setting the mode in two further calls, then setting it on each instance too, left the two loading buttons' variable-bound glyph names on the placeholder. Writing the resolved values with `setProperties` did stick there, as long as only properties whose value differs are written: writes that repeated the variant properties' current values flipped the nested icon to the main's own variant in 2 of 7 cases (both on the instances with bound glyph names), and writing the differing value back fixed it. That write costs the binding \u2014 a plain write drops the alias on that node (see the plain-write entry) \u2014 so it suits a throwaway probe, not a file's instances. (Distinct from the few-second repaint lag a designer sees after switching a theme by hand on content that already exists.) A brand-switch probe built and screenshotted in one pass will therefore report that a whole file's icon tokens are broken when nothing is wrong: clear in one call, set in the next, read after that \u2014 and confirm against a node that was already in the file.\n- **`variable.remove()` succeeds even when instances still bind it, and the variable keeps working \u2014 so a retirement can look clean and change nothing.** After removing it, `getLocalVariablesAsync()` no longer lists it (the designer sees it gone from the panel) but `getVariableByIdAsync(id)` still resolves it, the bindings still render its last value, and no error is raised anywhere. The trap is upstream, in the consumer scan that told you it was safe: a walker that does `if (node.type === 'INSTANCE') { check its componentProperties; return }` never descends into nested instances, and component-property bindings mostly live there \u2014 an icon instance inside a `Body Cell` inside a `Column`. One retirement measured 0 consumers by that walk and 160 by a walk that recursed. Before removing a variable, recurse through instance children; afterwards the id keeps resolving forever (it is a tombstone, not proof of failure), so verify instead that it has left `getLocalVariablesAsync()` and that a recursive scan finds zero bindings.\n- **`clone()` on a variant downgrades a SLOT child to a plain FRAME and drops its `slotContentId` reference** \u2014 the copy looks right and quietly loses the slot. Re-assigning `componentPropertyReferences = { slotContentId: '<Slot#id>' }` to the cloned frame restores the behaviour: the master still reports `FRAME`, but instances of it materialise a real `SLOT` child, expose the SLOT property, and accept appended content. There is no `figma.createSlot()`, so this reassignment is the only repair from code; duplicating the variant in the Figma UI keeps the SLOT type intact. Relai's post-run lint now names the slotless variants, but only for component sets it can reach \u2014 return the ids of what you cloned.\n- **`setBoundVariable` takes a Variable object, not an id string** \u2014 the string form throws `Cannot call setBoundVariable with a variable id. Please pass a variable object`. Fetch the variable first (`getVariableByIdAsync`, or a map built from `getLocalVariablesAsync`) and pass the object itself.\n- **Giving a node INSIDE an instance a new width/height from code does nothing \u2014 rebinding width/height and `resize()` are silent no-ops, and `rescale()` throws.** `setBoundVariable` returns without error and `boundVariables` still shows the old id afterwards. Only `setBoundVariable(field, null)` (unbind) persists there. On a nested instance (a logo inside a footer instance), `resize()`, `resizeWithoutConstraints()` and `resize()` after `unlockAspectRatio()` all returned normally and read back the old 105.3\xD748, and `rescale(0.75)` threw `This property cannot be overridden in an instance: size` \u2014 change the size in the main (a variant with that size) instead. To back out, `removeOverrides()` on the nested instance clears only the overrides from that layer down; the outer instance keeps its own. Stuck overrides \u2014 bindings an instance kept from a main that has since been cleaned \u2014 can therefore never be re-pointed from code, only cleared; and if the main is already clean, clearing IS the correct repair (the node falls back to the main's shape, pixels stay put). Measured on 301 soft-deleted `Size/height/12` bindings (2026-08-12): rebind reported success \xD7301 and changed nothing; unbind cleared every one. Always read the field back after writing \u2014 success without verification is how this one hides. One sizing write does persist inside an instance: setting `layoutSizingHorizontal` / `layoutSizingVertical` to `HUG` on the inner node is kept, and it also clears stale overrides that point at nodes which no longer exist.\n- **One broken component set crashes a whole-file property scan**: `instance.componentProperties` (and a set's `componentPropertyDefinitions`) throw `Component set has existing errors` when the backing set has variant conflicts \u2014 and the set may not even be on any page (a soft-deleted draft still referenced by one forgotten instance). A file-wide audit that reads properties uncaught dies on the first such instance. Guard each read with try/catch, report skips, and find the culprit cheaply: collect unique `mainComponent.parent` sets across instances and probe `componentPropertyDefinitions` on each (a few hundred sets) instead of touching every instance.\n- **Dot-prefixed components are never published**, so their keys 404 on every REST endpoint. Figma hides `.`-prefixed assets from the publish dialog; they exist only nested inside published parents. When resolving a key \u2192 file via `/v1/components/{key}`, skip names starting with `.` \u2014 and note a component SET's key resolves only at `/v1/component_sets/{key}`, never at `/v1/components/{key}` (and vice versa). Relatedly, `/v1/files/{key}/components` returns the individual VARIANTS (`size=md, state=default`); the components a designer names live in `/component_sets`.\n- **Freshly created frames and components count their strokes into hug sizing** \u2014 `strokesIncludedInLayout` defaults to `true`, so a hairline bottom divider makes an auto-layout row report 40.5 instead of 40, and an outlined container reports two extra pixels. The half-pixel survives into every instance and into any math you do on the height. Components that should keep integer dimensions need `strokesIncludedInLayout = false` set explicitly; existing hand-built components in a file often already have it off, which is why cloning them behaves differently from building from scratch.\n- **Converting a frame to a Slot in the Figma UI freezes its sizing at FIXED and strips the property references of everything inside it.** The converted node keeps the width/height it had at conversion time as a hard FIXED size (a later `layoutSizingHorizontal = \"HUG\"` is rejected; `\"FILL\"` is accepted), and a TEXT child that carried a `characters` reference (`label#\u2026`) comes out with the reference gone while the component property itself survives on the set \u2014 the panel shows the control, typing into it does nothing. After any frame\u2192Slot conversion: re-assert the slot's sizing explicitly and re-wire `componentPropertyReferences` on its former children. (`figma.createSlot` does not exist \u2014 conversion is UI-only, so this cleanup is always manual-plus-script.)\n- **`combineAsVariants` re-keys every component property and can drop a TEXT property's `characters` reference on the way.** The ids in property keys (`title#1033:0`) belong to the standalone components; combining mints new set-level keys (`title#7527:4`) \u2014 so any key strings you saved before combining are stale afterwards. Worse, the re-wiring is lossy: in one measured combine the `title` reference survived on all variants while the `description` reference vanished from all of them, with no error. Existing instances do ride through the re-keying: when `combineAsVariants` turned a lone component into a set of one variant (the second was added afterwards), its TEXT key changed (`text#N:61` \u2192 `text#N:62`), the reference survived, and all 176 instances kept their text, size and position. After every combine, audit `componentPropertyReferences` across all variants against the set's `componentPropertyDefinitions` and re-wire the orphans (survival is luck, not a rule \u2014 this is the same disease as duplicated variants keeping no references, in a different coat).\n- **Writing `textAutoResize` resets a text node's truncation.** Assigning `NONE`, `HEIGHT` or `WIDTH_AND_HEIGHT` \u2014 including the value it already holds \u2014 sets `textTruncation` back to `DISABLED` and `maxLines` to `null`, with no error (24/24 cases, auto-layout children and free nodes alike). The deprecated `TRUNCATE` keeps `ENDING` but still drops `maxLines`; don't lean on it. `resize()` and `layoutSizingHorizontal` leave `textTruncation` alone, but they can flip `textAutoResize` between `HEIGHT` and `WIDTH_AND_HEIGHT` on the way. Order the writes: `textAutoResize` first, then `textTruncation` and `maxLines`, and re-read all three after any sizing change. On a node whose `textAutoResize` is `NONE`, writing `ENDING` reads back as `TRUNCATE`, and `maxLines = 1` is accepted but reads back `null`.\n- **Removing an instance that has slots leaves undeletable orphans behind \u2014 whether it came from `createInstance()` or `clone()`.** An instance with SLOT content keeps each slot's content in a `FRAME` with `parent === null`, holding the slotted instances; while the instance exists that frame is its live storage, not litter. Delete the instance and the frame stays. It sits on no page and nothing shows it, but `remove()` on it (or on the instances inside it) throws `Removing this node is not allowed`, and `component.getInstancesAsync()` keeps returning those instances: one probe that cloned slot-bearing instances to check variable modes took a component set's instance count (summed over its variants) from 545 to 959, and two probes built with `createInstance()` and deleted again left 24 such frames. Whether reopening the file clears them has not been measured. Don't build probes out of slot-bearing instances \u2014 to see one under another mode, resolve its variables against an existing node in that mode (`variable.resolveForConsumer(node)`) \u2014 and in instance audits drop any result whose ancestor chain never reaches a PAGE.\n- **Overriding an instance's text freezes the layer's styling along with it.** Writing `characters` on a text layer inside an instance stores the layer's `styledTextSegments` in the override, so the layer stops following its main per variant: an instance made from a variant whose title has a 1px underline, switched to a variant whose title has a 2px one, kept its own text *and* the 1px \u2014 and it will not follow a later change to the main's text styling either. Binding the layer to a TEXT property instead has its own edge: switch to a variant whose layer is not bound to that property and the text reverts to that variant's own. Create the instance from the variant it should show before overriding its text, and read the decoration back after any variant switch.\n- **An instance reads its main component's pluginData as its own, and emptying a key on the instance leaves an override behind.** `instance.getPluginData(key)` returns the main's value when the instance never set one \u2014 clear the key on the main and it disappears from every instance, and a sweep that counts keys node by node counts each inherited copy again (one cleanup found 188 keys, 94 of them inherited). Writing `''` on an instance does empty its value, but `pluginData` stays in that instance's `overriddenFields`, and nothing removes that entry alone: `removeOverrides()` also discards every rename and resize beside it. Clear a marker on the node you set it on, and don't read a key found on an instance as proof that the instance itself was marked.\n- **`getNodeByIdAsync` throws on the bare id of a variable or collection.** Variables and collections draw from the same `N:M` counter as nodes, so a scan over an id range lands on them: `getNodeByIdAsync('N:M')` for a variable's number throws `unsupported type in createVMObject: VARIABLE` (`VARIABLE_SET` for a collection) instead of returning `null`, while the prefixed `VariableID:N:M` just returns `null`. Catch per id when sweeping ranges, and resolve variables through `figma.variables.getVariableByIdAsync`.\n- **Range reads throw on an empty range, so an empty text node breaks the usual font-loading loop.** `getRangeAllFontNames(0, node.characters.length)` on a node whose `characters` is `\"\"` throws `Empty range selected. 'end' must be greater than 'start'`, and so does any `getRange*` read with `start === end` (`getRangeFontName(0, 0)`, `getRangeAllFontNames(1, 1)` on `\"ab\"`). `node.fontName` answers normally there and `getStyledTextSegments(['fontName'])` returns `[]`. Load `fontName` when the text is empty, and call range APIs only when `end > start`.\n- **`exportAsync` renders only what a clipping ancestor leaves visible, so pixel diffs of clipped content compare nothing.** By default a node is exported at its `absoluteRenderBounds`, which a `clipsContent` ancestor cuts: a 100\xD7300 child of a clipping 100\xD7100 frame exports as 100\xD7100, and a child clipped away entirely (its `absoluteRenderBounds` is `null`) exports as a 1\xD71 image \u2014 so the hidden rows of a clipped table \"match\" before and after whatever you changed in them. `exportAsync({ format: \"PNG\", useAbsoluteBounds: true })` renders the whole node, byte-identical to exporting it with the ancestor unclipped; use it for before/after comparisons, or compare `absoluteRenderBounds` with `absoluteBoundingBox` before trusting a diff.\n- **Switching a nested instance's variant can discard the overrides its container put inside it.** Inside a component, `setProperties({ state: \"default\" })` on a nested icon button moved it to the new variant but dropped what the component had set on the button's icon: the three glyph properties bound to variables came back as the new variant's unbound default, and the icon's bound fill as the default variable \u2014 only the binding on the icon's variant property (`style`) survived. No error, and instances of the component show the placeholder from then on. After switching a nested instance's variant, re-read its content node by node and rebind from a sibling variant that is already in the target state.\n- **Slot content that is already on another variant when it enters the slot can come back with ids that resolve to nothing.** If a nested instance in the content is already on a different variant than the component defines for it when the content lands in the slot \u2014 an explicit mode swapped an icon to another family, or an override set a menu item to `state=hovered` \u2014 its descendants get ids missing the slot's segment: `getNodeByIdAsync` answers `null` for them, and two items can report the same id. Switching the variant after the content is in the slot keeps the ids whole. The repair measured on a 311-node menu: clear the mode in one call, set the swapped item to its default variant in place in the next and back in the one after, then set the mode \u2014 14 unreadable nodes went to 0; clearing and re-setting the mode alone fixed 13 of them, not a hidden layer. Walk slot content with a per-node try/catch that records the path instead of stopping.\n- **When a variable mode swaps a nested instance to another variant, the swap is stored as overrides, and later binding changes on the main stop reaching it.** Eighteen instances were created in one call and their parents' Theme modes set in the next: in the mode that resolves the nested icon to the main's own variant, the nested instance carried no overrides; in each of the five modes that swapped it, it carried `boundVariables`, `componentProperties`, `fills`, `name`, both axis sizing modes and `targetAspectRatio` as overrides. Rebinding that nested instance's `style` property to a new variable in the main then reached only the unswapped instance \u2014 the other five kept reading the old variable \u2014 while fresh instances with the modes set again all showed the new binding. Instances that were already in the file carried the same overrides. After changing a binding in a main, re-create probe instances rather than re-reading old ones, and read `overrides` on the nested layer before trusting that the change reached instances under other modes.\n- **A VECTOR whose corners are rounded per vertex ignores `cornerRadius` writes on an instance, and only `rescale` scales those corners.** When the radius lives in `vectorNetwork.vertices[i].cornerRadius` (a glyph outline with 12 and 9 rounded vertices), writing the node's `cornerRadius` inside an instance adds a `cornerRadius` entry to `overrides` and nothing else: it reads back the main's 0.449, the vertices keep it, and the render is pixel-identical. Writing the main's value back leaves the entry behind; only `removeOverrides()` clears it, along with every other override on that instance. `resize()` scales the outline but not the vertex radii, so an instance resized 2.4\xD7 draws sharper corners than the main scaled 2.4\xD7 (alpha differs by up to 175/255 at the corners). `rescale(2.4)` scales the vertex radii too (0.449 \u2192 1.079, within 16/255 of the scaled main), but it does not coexist with a variable-bound height: binding `height` after `rescale` resets `scaleFactor` to 1 and the radii to 0.449 (with the aspect ratio locked as well), and `rescale` after binding multiplies the bound height (72 \u2192 259.2). Pick either the bound height or proportional corners.\n- **A bound paint's `color` is only a fallback \u2014 and one component set kept the fallback on screen.** `figma.variables.setBoundVariableForPaint(paint, 'color', v)` returns the paint with your `color` untouched (a `{0,0,0}` placeholder stays `{0,0,0}`). Assigned through `fills` or `setRangeFills`, the stored color is normally replaced by the variable's resolved value: in eight probes (node fills on text and on a rectangle; `setRangeFills` over a whole text, part of one, a text in a set variant, in a standalone component and on a TEXT-property layer) it read back as the resolved gray, and the exported ones rendered it. But four ranges written with `setRangeFills` in two variants of a working component set kept `{0,0,0}` while still bound, and rendered black; a scan of the 168 bound paints around them found no other mismatch, and the trigger was not isolated. Build bound paints with the resolved color (take `{ r, g, b }` from `v.resolveForConsumer(node).value` \u2014 it carries `a`, and a paint's `color` has no alpha) instead of a dummy, and after writing compare each bound paint's stored `color` with the resolved value. The same showed up on an instance whose fill override was bound to the very variable its main uses but stored `{0,0,0}`: it exported black where the main exported light.\n- **A blank symbol in a text node is not proof the font lacks it \u2014 the node can be holding a stale layout.** Key labels drew `\u2318` as empty space on some nodes and fine on others, all in the same monospace font, which has no `\u2318`, `\u21E7` or `\u23CE`: Figma draws those with its own fallback glyph, and the blank nodes held an old layout \u2014 `width` 7, the missing-glyph advance, where drawn ones measured 9 for `\u2318`/`\u23CE` and 8 for `\u21E7`. Switching the font does not help: a font whose file does contain `\u2318` and `\u23CE` still drew the same fallback glyph, and writing that font on the key text inside the masters' nested instances spread wrongly \u2014 145 of the file's 301 keys came out wrong, including keys with no symbol at all. What brought the glyph back was a fresh layout, reached the long way: on the five keys that had been given that font, `removeOverrides()` (which also returned the TEXT property to the main's default), then restoring the layer name and writing the property back to its symbol \u2014 the glyph appeared and the five keys grew 1\u20132 px. Writing the same value again without clearing first was not tried. Before blaming the font, compare the node's `width` with a node that renders; to repair, clear the overrides, write the text property back, and re-read the width.\n- **`remove()` on a COMPONENT leaves its id resolving, so a `null` lookup cannot confirm the cleanup.** After removing a component \u2014 standalone, or a variant removed along with its set \u2014 `getNodeByIdAsync(id)` still returns it, children included, with `parent === null` and `removed === false`; calling `remove()` on it again returns normally and changes nothing. Measured on four components removed in the call that created them; the COMPONENT_SET itself, and plain frames, text and rectangles removed the same way, did resolve to `null`. The component sits on no page and no page's children list it, but id sweeps find it; whether it goes away later has not been measured. Confirm component cleanup by ancestry (the chain must no longer reach a PAGE) rather than `null`, and build temporary probes from frames or instances of existing components rather than new components.\n- **Renaming a variant axis with `editComponentProperty` unbinds the instances, and can reset their plain overrides too.** Instances bound to that property keep their value but lose the `boundVariables.componentProperties` alias, nested explicit overrides included. In one measured rename, 71 instances whose value for the axis was a plain override \u2014 not bound to anything \u2014 fell back to the set's default variant. Right after rebinding, some instances kept an old value that disagreed with what the mode resolves to; writing the value first and then rebinding fixed them. Before renaming an axis, record every instance's value and binding for it; afterwards write the values back, rebind, and read both back.\n<!-- PITFALLS:END -->\n\nAlso: `resize(w, h)` throws on `w <= 0 || h <= 0`; `layoutWrap = \"WRAP\"` is HORIZONTAL-only, `counterAxisAlignItems = \"BASELINE\"` too; writing to `locked` nodes is allowed by the API but surprises designers \u2014 check first.\n\n## Return values\n\nReturn JSON-serializable data (the bridge summarizes nodes automatically and truncates >50k chars). Prefer returning `{ id, name, type }` summaries over whole nodes. `console.log` is captured and returned in `logs`.\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n";
|
|
4386
4482
|
|
|
4387
4483
|
// ../../docs/skills/design-tokens-strategy.md
|
|
4388
4484
|
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";
|
|
@@ -4409,7 +4505,7 @@ var design_system_first_default = '# Design-system-first building\n\nThe differe
|
|
|
4409
4505
|
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';
|
|
4410
4506
|
|
|
4411
4507
|
// ../../docs/skills/memory-and-precedents.md
|
|
4412
|
-
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';
|
|
4508
|
+
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';
|
|
4413
4509
|
|
|
4414
4510
|
// ../../docs/skills/qa-gate.md
|
|
4415
4511
|
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';
|
|
@@ -4508,4 +4604,4 @@ export {
|
|
|
4508
4604
|
listToolCatalog,
|
|
4509
4605
|
registerPrompts
|
|
4510
4606
|
};
|
|
4511
|
-
//# sourceMappingURL=chunk-
|
|
4607
|
+
//# sourceMappingURL=chunk-5IKROMKE.js.map
|