From 3482e6db9fd636c6678bac1d9bcedbeaa3fa131e Mon Sep 17 00:00:00 2001 From: Roya Date: Sat, 22 Aug 2026 18:47:32 +0800 Subject: [PATCH 1/5] feat(shared): carry a paint / effect / grid's own variable bindings on the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Figma does not keep a fill or shadow colour's variable binding in the owning node's `boundVariables` — it keeps it on the paint, the gradient stop, the effect or the layout grid itself, and a text style keeps its typography bindings on the style (its values are scalars, so there is no per-object level to hang them on). None of those had a place in the wire shapes. `SerializedBindings` is `field -> variable id`, optional everywhere it appears, so anything unbound serializes exactly as before. Field names pass through as Figma reports them rather than being filtered against a hard-coded list: a newly bindable field then rides along instead of being silently dropped. `GetStylesResult` gains the `variables` id -> token table get_design_context already returns, so a binding is readable without a second round trip. Names are not inlined into the bindings: variable names collide across collections (a local and a library `primary`), so the id stays the key. --- packages/shared/src/serialized-node.ts | 27 ++++++++++++++++++++++++++ packages/shared/src/styles.ts | 18 +++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/shared/src/serialized-node.ts b/packages/shared/src/serialized-node.ts index a03377c..9280667 100644 --- a/packages/shared/src/serialized-node.ts +++ b/packages/shared/src/serialized-node.ts @@ -19,17 +19,37 @@ export const SerializedRGBASchema = z.object({ }); export type SerializedRGBA = z.infer; +/** + * Variable bindings carried _on_ a paint / effect / layout grid / gradient stop — `field` → the + * bound variable's id (e.g. `{ color: "VariableID:5:12" }`). Figma keeps these bindings on the + * object itself, not in the owning node's `boundVariables`, which is why a bound shadow colour or + * fill colour is invisible unless it is read from here (issue #164). Field names are passed through + * as Figma reports them rather than filtered against a hard-coded list, so a newly bindable field + * is never silently dropped. Each field binds exactly one variable (unlike a node's own + * `boundVariables`, where a field maps to a list). Ids resolve to names via the result's + * `variables` table (get_styles / get_design_context) or get_variable_defs. + * + * Read-only: the write tools (set_fills / set_effects / set_layout_grids / create_* / update_*) + * ignore this field — binding a variable goes through bind_variable_to_paint. + */ +export const SerializedBindingsSchema = z.record(z.string(), z.string()); +export type SerializedBindings = z.infer; + const SolidPaintSchema = z.object({ type: z.literal('SOLID'), visible: z.boolean(), opacity: z.number(), color: SerializedColorSchema, + /** `{ color: variableId }` when the designer bound the paint's colour to a variable. */ + boundVariables: SerializedBindingsSchema.optional(), }); /** A gradient color stop: position 0–1 along the gradient + its RGBA color. */ export const SerializedColorStopSchema = z.object({ position: z.number(), color: SerializedRGBASchema, + /** `{ color: variableId }` — a gradient stop binds its colour independently of its siblings. */ + boundVariables: SerializedBindingsSchema.optional(), }); export type SerializedColorStop = z.infer; @@ -110,6 +130,11 @@ export const SerializedEffectSchema = z.object({ color: SerializedRGBASchema.optional(), offset: z.object({ x: z.number(), y: z.number() }).optional(), spread: z.number().optional(), + /** + * Per-field bindings on this shadow — Figma allows `color`, `radius`, `spread`, `offsetX` and + * `offsetY`. Present only when at least one is bound. + */ + boundVariables: SerializedBindingsSchema.optional(), }); export type SerializedEffect = z.infer; @@ -141,6 +166,8 @@ export const SerializedLayoutGridSchema = z.object({ gutterSize: z.number().optional(), alignment: z.string().optional(), offset: z.number().optional(), + /** Per-field bindings — Figma allows `sectionSize`, `count`, `offset` and `gutterSize`. */ + boundVariables: SerializedBindingsSchema.optional(), }); export type SerializedLayoutGrid = z.infer; diff --git a/packages/shared/src/styles.ts b/packages/shared/src/styles.ts index 136f9cb..e0a7c1c 100644 --- a/packages/shared/src/styles.ts +++ b/packages/shared/src/styles.ts @@ -1,8 +1,10 @@ import { z } from 'zod'; +import { ResolvedTokenSchema } from './design-context.js'; // SerializedEffect / SerializedLayoutGrid / SerializedLineHeight / SerializedLetterSpacing now live // in serialized-node.ts (shared by node + style serialization) and reach consumers via the barrel. import { + SerializedBindingsSchema, SerializedEffectSchema, SerializedFontNameSchema, SerializedLayoutGridSchema, @@ -33,6 +35,13 @@ export const SerializedTextStyleSchema = z.object({ // AUTO | BALANCE | PRETTY — a text style carries its own wrap balancing, so a style named // "Heading/H1" can mean `text-wrap: balance` for every node bound to it. textWrapStyle: z.string(), + /** + * `field` → variable id for the typography fields Figma lets a text style bind (fontSize, + * lineHeight, letterSpacing, fontFamily, fontStyle, fontWeight, paragraphSpacing, + * paragraphIndent). Unlike a paint or effect style — whose bindings sit on the individual paint / + * effect — a text style's values are scalars, so this is the only place its bindings exist. + */ + boundVariables: SerializedBindingsSchema.optional(), }); export type SerializedTextStyle = z.infer; @@ -53,5 +62,14 @@ export const GetStylesResultSchema = z.object({ texts: z.array(SerializedTextStyleSchema), effects: z.array(SerializedEffectStyleSchema), grids: z.array(SerializedGridStyleSchema), + /** + * Id → token, for every variable id referenced by a `boundVariables` anywhere in this result — + * the same id→name table get_design_context returns. Without it a binding is an opaque + * `VariableID:5:12`; with it the consumer reads the token name (and any declared codeSyntax) + * without a second round-trip. Names are NOT inlined into the bindings themselves: variable names + * collide across collections (a local and a library `primary`), so the id stays the key. Omitted + * when nothing in the document is bound. + */ + variables: z.record(z.string(), ResolvedTokenSchema).optional(), }); export type GetStylesResult = z.infer; From 337b1d8a405fb2682311955f7dd965786852777d Mon Sep 17 00:00:00 2001 From: Roya Date: Sat, 22 Aug 2026 18:47:32 +0800 Subject: [PATCH 2/5] fix(serializer): stop dropping the variable a paint, stop, effect or grid binds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serializePaint / serializeEffect / serializeLayoutGrid each picked a fixed set of fields and never read `boundVariables`, so a value the designer bound to a variable came back as a plain literal — indistinguishable from one that was hard-coded. That is every SOLID paint's colour, every gradient stop's colour, a shadow's colour / radius / spread / offsetX / offsetY, and a layout grid's sectionSize / count / offset / gutterSize, on both style and node reads. The fixtures are the raw shapes a live file actually returns, measured against the plugin API rather than inferred — including the one a hand-written fixture would never guess: an UNBOUND paint carries `boundVariables: {}`, which must not turn into an empty field in the payload. get_design_context is deliberately left alone: its globalVars bundles are the budget-constrained hot path, a node's own `boundVariables` already names every variable its fills / strokes / effects reference (measured: populated both for a binding made on the node and for one inherited from a shared style), and all the bundle would add is which field of which paint. Pinned by tests so the boundary cannot flip by accident, including that a bound and an unbound copy of the same colour still share one bundle. --- packages/plugin/src/serializer.ts | 57 +++++- packages/plugin/test/serializer.test.ts | 173 ++++++++++++++++++ .../shared/test/design-context-dedupe.test.ts | 56 ++++++ 3 files changed, 281 insertions(+), 5 deletions(-) diff --git a/packages/plugin/src/serializer.ts b/packages/plugin/src/serializer.ts index 930713f..a07a9d5 100644 --- a/packages/plugin/src/serializer.ts +++ b/packages/plugin/src/serializer.ts @@ -2,6 +2,8 @@ import { MIXED, type SerializedAnnotation, type SerializedAutoLayout, + type SerializedBindings, + type SerializedColorStop, type SerializedComponentProperty, type SerializedEffect, type SerializedGridChild, @@ -41,15 +43,39 @@ const hasImageAdjustments = (filters: unknown): boolean => filters !== null && Object.values(filters).some(v => typeof v === 'number' && v !== 0); +/** + * A paint / effect / layout-grid / gradient-stop `boundVariables` record → `field` → variable id. + * Figma keeps these bindings on the object itself (not in the owning node's `boundVariables`), and + * each field binds exactly one variable — so this is the single-alias counterpart to + * `collectBoundVariables` below. Field names pass through as Figma reports them, so a newly + * bindable field is carried rather than silently dropped. Returns undefined when nothing is bound, + * keeping unbound paints/effects byte-identical to before. + */ +export const collectBindings = (raw: unknown): SerializedBindings | undefined => { + if (typeof raw !== 'object' || raw === null) return undefined; + const out: SerializedBindings = {}; + for (const [field, alias] of Object.entries(raw)) { + if (typeof alias === 'object' && alias !== null && 'id' in alias) { + const id = (alias as { id: unknown }).id; + if (typeof id === 'string') out[field] = id; + } + } + return Object.keys(out).length > 0 ? out : undefined; +}; + export const serializePaint = (paint: Paint): SerializedPaint => { const visible = paint.visible ?? true; const opacity = paint.opacity ?? 1; if (paint.type === 'SOLID') { + // A bound colour lives on the paint, not on the owning node — carry it, or a variable-driven + // fill is indistinguishable from a hard-coded one downstream (issue #164). + const bound = collectBindings(paint.boundVariables); return { type: 'SOLID', visible, opacity, color: { r: paint.color.r, g: paint.color.g, b: paint.color.b }, + ...(bound === undefined ? {} : { boundVariables: bound }), }; } if (isGradient(paint)) { @@ -64,10 +90,16 @@ export const serializePaint = (paint: Paint): SerializedPaint => { type: paint.type, visible, opacity, - gradientStops: stops.map(s => ({ - position: s.position, - color: { r: s.color.r, g: s.color.g, b: s.color.b, a: s.color.a }, - })), + gradientStops: stops.map(s => { + const stop: SerializedColorStop = { + position: s.position, + color: { r: s.color.r, g: s.color.g, b: s.color.b, a: s.color.a }, + }; + // Each stop binds its own colour, so the binding has to ride on the stop, not the paint. + const bound = collectBindings(s.boundVariables); + if (bound !== undefined) stop.boundVariables = bound; + return stop; + }), gradientTransform: transform.map(row => row.slice()), }; } @@ -746,6 +778,9 @@ export const serializeTree = async (node: SceneNode): Promise => }; export const serializeEffect = (effect: Effect): SerializedEffect => { + // Shadow bindings (colour / radius / spread / offsetX / offsetY) live on the effect itself; a + // shadow whose colour is a variable otherwise reads as a hard-coded RGBA (issue #164). + const bound = collectBindings((effect as { boundVariables?: unknown }).boundVariables); if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') { return { type: effect.type, @@ -754,11 +789,13 @@ export const serializeEffect = (effect: Effect): SerializedEffect => { color: { r: effect.color.r, g: effect.color.g, b: effect.color.b, a: effect.color.a }, offset: { x: effect.offset.x, y: effect.offset.y }, spread: effect.spread ?? 0, + ...(bound === undefined ? {} : { boundVariables: bound }), }; } // Blurs / textures carry radius; noise / glass carry only type + visible. const out: SerializedEffect = { type: effect.type, visible: effect.visible }; if ('radius' in effect && typeof effect.radius === 'number') out.radius = effect.radius; + if (bound !== undefined) out.boundVariables = bound; return out; }; @@ -778,8 +815,15 @@ export const serializeCodeSyntax = (raw: unknown): Record | unde }; export const serializeLayoutGrid = (grid: LayoutGrid): SerializedLayoutGrid => { + // sectionSize / count / offset / gutterSize can each be variable-bound, on the grid object. + const bound = collectBindings((grid as { boundVariables?: unknown }).boundVariables); if (grid.pattern === 'GRID') { - return { pattern: 'GRID', visible: grid.visible ?? true, sectionSize: grid.sectionSize }; + return { + pattern: 'GRID', + visible: grid.visible ?? true, + sectionSize: grid.sectionSize, + ...(bound === undefined ? {} : { boundVariables: bound }), + }; } const out: SerializedLayoutGrid = { pattern: grid.pattern, @@ -794,5 +838,8 @@ export const serializeLayoutGrid = (grid: LayoutGrid): SerializedLayoutGrid => { if (typeof grid.offset === 'number' && grid.offset !== 0 && grid.alignment !== 'CENTER') { out.offset = grid.offset; } + // Last, so a bound grid reads as "the grid, then what it binds" — the order every other + // serializer here emits. + if (bound !== undefined) out.boundVariables = bound; return out; }; diff --git a/packages/plugin/test/serializer.test.ts b/packages/plugin/test/serializer.test.ts index 10bdb68..70db36c 100644 --- a/packages/plugin/test/serializer.test.ts +++ b/packages/plugin/test/serializer.test.ts @@ -6,6 +6,7 @@ import { serializeFlat, serializeFlatSync, serializeLayoutGrid, + serializePaint, serializeTree, } from '../src/serializer.js'; @@ -1361,3 +1362,175 @@ describe('serializeLayoutGrid', () => { expect(centered.offset).toBeUndefined(); }); }); + +// Variable bindings live ON the paint / stop / effect / grid, not in the owning node's +// `boundVariables` — so dropping them made a variable-driven colour indistinguishable from a +// hard-coded one (issue #164). The raw shapes below are the ones a live Figma file actually +// returns (measured against the plugin API, not inferred): an alias object per field, and — the +// detail a hand-written fixture would never guess — `boundVariables: {}` on an UNBOUND paint, +// which must not turn into an empty field in the payload. +describe('serializer — variable bindings on paints / effects / grids', () => { + const ALIAS = { type: 'VARIABLE_ALIAS', id: 'VariableID:5157:5005' }; + const ALIAS2 = { type: 'VARIABLE_ALIAS', id: 'VariableID:5157:5006' }; + + it('carries a bound colour on a solid paint', () => { + const out = serializePaint({ + type: 'SOLID', + visible: true, + opacity: 1, + blendMode: 'NORMAL', + color: { r: 0.1, g: 0.2, b: 0.3 }, + boundVariables: { color: ALIAS }, + } as unknown as Paint); + expect(out).toEqual({ + type: 'SOLID', + visible: true, + opacity: 1, + color: { r: 0.1, g: 0.2, b: 0.3 }, + boundVariables: { color: 'VariableID:5157:5005' }, + }); + }); + + it('omits the field on an unbound paint, including Figma’s empty boundVariables', () => { + const empty = serializePaint({ + type: 'SOLID', + visible: true, + opacity: 1, + color: { r: 0, g: 1, b: 0 }, + boundVariables: {}, + } as unknown as Paint); + expect(empty).not.toHaveProperty('boundVariables'); + const absent = serializePaint({ + type: 'SOLID', + visible: true, + opacity: 1, + color: { r: 0, g: 1, b: 0 }, + } as unknown as Paint); + expect(absent).not.toHaveProperty('boundVariables'); + }); + + it('binds gradient stops independently of each other', () => { + const out = serializePaint({ + type: 'GRADIENT_LINEAR', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0], + [0, 1, 0], + ], + gradientStops: [ + { position: 0, color: { r: 0, g: 0, b: 0, a: 1 }, boundVariables: { color: ALIAS } }, + { position: 1, color: { r: 1, g: 1, b: 1, a: 1 } }, + ], + } as unknown as Paint) as { gradientStops: { boundVariables?: unknown }[] }; + expect(out.gradientStops[0]?.boundVariables).toEqual({ color: 'VariableID:5157:5005' }); + expect(out.gradientStops[1]).not.toHaveProperty('boundVariables'); + }); + + it('carries every bound field on a shadow', () => { + const out = serializeEffect({ + type: 'DROP_SHADOW', + visible: true, + radius: 24, + color: { r: 0.1, g: 0.2, b: 0.3, a: 1 }, + offset: { x: 0, y: 2 }, + spread: 0, + blendMode: 'NORMAL', + boundVariables: { color: ALIAS, radius: ALIAS2 }, + } as unknown as Effect); + expect(out.boundVariables).toEqual({ + color: 'VariableID:5157:5005', + radius: 'VariableID:5157:5006', + }); + // The resolved literal stays alongside the binding — it is the fallback, not a replacement. + expect(out.color).toEqual({ r: 0.1, g: 0.2, b: 0.3, a: 1 }); + }); + + it('carries a bound radius on a blur (the non-shadow branch)', () => { + const out = serializeEffect({ + type: 'LAYER_BLUR', + visible: true, + radius: 8, + boundVariables: { radius: ALIAS2 }, + } as unknown as Effect); + expect(out).toEqual({ + type: 'LAYER_BLUR', + visible: true, + radius: 8, + boundVariables: { radius: 'VariableID:5157:5006' }, + }); + }); + + it('carries bindings on both layout-grid branches', () => { + const columns = serializeLayoutGrid({ + pattern: 'COLUMNS', + visible: true, + count: 12, + gutterSize: 24, + alignment: 'STRETCH', + offset: 24, + boundVariables: { gutterSize: ALIAS2 }, + } as unknown as LayoutGrid); + expect(columns.boundVariables).toEqual({ gutterSize: 'VariableID:5157:5006' }); + const uniform = serializeLayoutGrid({ + pattern: 'GRID', + visible: true, + sectionSize: 8, + boundVariables: { sectionSize: ALIAS2 }, + } as unknown as LayoutGrid); + expect(uniform.boundVariables).toEqual({ sectionSize: 'VariableID:5157:5006' }); + }); + + it('ignores a malformed alias rather than emitting a broken id', () => { + const out = serializeEffect({ + type: 'LAYER_BLUR', + visible: true, + radius: 8, + // Three ways an alias can fail to be one: no id at all, a non-string id, and a null entry. + boundVariables: { + radius: { type: 'VARIABLE_ALIAS' }, + spread: { type: 'VARIABLE_ALIAS', id: 123 }, + color: null, + }, + } as unknown as Effect); + expect(out).not.toHaveProperty('boundVariables'); + }); + + it('keeps the well-formed bindings when a sibling field is malformed', () => { + const out = serializeEffect({ + type: 'LAYER_BLUR', + visible: true, + radius: 8, + boundVariables: { radius: ALIAS2, color: { type: 'VARIABLE_ALIAS', id: 123 } }, + } as unknown as Effect); + expect(out.boundVariables).toEqual({ radius: 'VariableID:5157:5006' }); + }); + + it('surfaces a bound fill through a node, alongside the node’s own coarse binding list', () => { + const out = serializeFlatSync( + fake({ + fills: [ + { + type: 'SOLID', + visible: true, + opacity: 1, + color: { r: 0.1, g: 0.2, b: 0.3 }, + boundVariables: { color: ALIAS }, + }, + ], + boundVariables: { fills: [ALIAS] }, + }), + ); + // The node-level list says *some* variable is bound; the paint says which field of which paint. + expect(out.boundVariables).toEqual({ fills: ['VariableID:5157:5005'] }); + expect(out.fills).toEqual([ + { + type: 'SOLID', + visible: true, + opacity: 1, + color: { r: 0.1, g: 0.2, b: 0.3 }, + boundVariables: { color: 'VariableID:5157:5005' }, + }, + ]); + }); +}); diff --git a/packages/shared/test/design-context-dedupe.test.ts b/packages/shared/test/design-context-dedupe.test.ts index 057103c..2ca0574 100644 --- a/packages/shared/test/design-context-dedupe.test.ts +++ b/packages/shared/test/design-context-dedupe.test.ts @@ -277,3 +277,59 @@ describe('computeMetrics', () => { expect(m.dedupedSizeKb).toBeLessThan(m.inlineSizeKb); }); }); + +// A deliberate boundary, pinned so it cannot flip by accident. +// +// Paints and effects carry per-object `boundVariables` since issue #164, but the globalVars bundles +// are the hot, budget-constrained path (get_design_context, PR #162) — and the information is not +// lost there: a node's own `boundVariables` already lists every variable its fills / strokes / +// effects reference (measured against a live file: it is populated both for a binding made on the +// node and for one inherited from a shared style), and `resolveTokens` turns those ids into names. +// What the bundle drops is only *which field of which paint* — precision the caller does not need +// to emit `var(--token)`, and which would otherwise be duplicated on every deduped bundle. +describe('dedupeStyles — variable bindings stay out of the bundles', () => { + const bound = (over: Record): DesignContextNode => + ({ id: 'n', name: 'n', type: 'FRAME', ...over }) as DesignContextNode; + + it('drops per-paint and per-effect bindings from the globalVars bundle', () => { + const { globalVars } = dedupeStyles([ + bound({ + fills: [{ ...solid(1, 0, 0), boundVariables: { color: 'VariableID:1' } }], + effects: [ + { + type: 'DROP_SHADOW', + visible: true, + radius: 4, + color: { r: 0, g: 0, b: 0, a: 0.2 }, + offset: { x: 0, y: 2 }, + spread: 0, + boundVariables: { color: 'VariableID:1' }, + }, + ], + boundVariables: { fills: ['VariableID:1'], effects: ['VariableID:1'] }, + }), + ]); + expect(JSON.stringify(globalVars.styles)).not.toContain('boundVariables'); + expect(JSON.stringify(globalVars.styles)).not.toContain('VariableID'); + }); + + it('keeps the node-level binding list, which is what names the token', () => { + const { nodes } = dedupeStyles([ + bound({ + fills: [{ ...solid(1, 0, 0), boundVariables: { color: 'VariableID:1' } }], + boundVariables: { fills: ['VariableID:1'] }, + }), + ]); + expect(nodes[0]?.boundVariables).toEqual({ fills: ['VariableID:1'] }); + }); + + it('does not let a binding split one shared bundle into two', () => { + // Two nodes with the same colour, one bound and one not, must still share a single bundle — + // otherwise adding bindings would quietly inflate globalVars on a file that uses variables. + const { globalVars } = dedupeStyles([ + bound({ id: 'a', fills: [{ ...solid(1, 0, 0), boundVariables: { color: 'VariableID:1' } }] }), + bound({ id: 'b', fills: [solid(1, 0, 0)] }), + ]); + expect(Object.keys(globalVars.styles)).toHaveLength(1); + }); +}); From 5e0ea143b8438df8dbe12d5bd4234249e3e2082a Mon Sep 17 00:00:00 2001 From: Roya Date: Sat, 22 Aug 2026 18:47:51 +0800 Subject: [PATCH 3/5] fix(get-styles): report a style's variable bindings and name what they point at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the reported half of the miss: an effect style whose shadow colour is bound to a variable came back as `color: {r,g,b,a}` with nothing saying it was a token, so a consumer could not tell a genuinely hard-coded shadow from one that merely serializes to its variable's current value. Paint / effect / grid styles get theirs from the serializer now. A text style needs its own read: its values are scalars, so `TextStyle.boundVariables` is the only place a bound fontSize / lineHeight / letterSpacing exists. The style-level `boundVariables` those three style kinds also expose is deliberately not read. Measured against a live file it is a flat `VariableAlias[]` of whatever is bound somewhere in the style's array — it names neither which paint/effect nor which field — so it is a strictly lossy summary of what the per-object bindings now carry exactly. Referenced ids resolve to `{ name, type, codeSyntax? }` in a `variables` table, mirroring get_design_context. It leads the result so a reader meets `VariableID:5:12` already knowing what it names, is omitted when the document binds nothing, and is assembled in walk order rather than as the parallel lookups settle — otherwise the same document could serialize two different byte sequences on two runs. --- packages/mcp/src/tools/get-styles.ts | 8 +- packages/mcp/test/e2e/read-tools.test.ts | 69 +++++ packages/plugin/src/handlers/get-styles.ts | 117 +++++++- .../plugin/test/handlers/get-styles.test.ts | 271 +++++++++++++++++- 4 files changed, 449 insertions(+), 16 deletions(-) diff --git a/packages/mcp/src/tools/get-styles.ts b/packages/mcp/src/tools/get-styles.ts index 03dcb80..f6abca5 100644 --- a/packages/mcp/src/tools/get-styles.ts +++ b/packages/mcp/src/tools/get-styles.ts @@ -9,7 +9,13 @@ export const getStylesTool: ToolSpec = { description: "Return the document's local styles grouped as { paints, texts, effects, grids }. " + 'Paint styles carry their paints; text styles carry fontName / fontSize / lineHeight / letterSpacing / textWrapStyle; ' + - 'effect styles carry their effects; grid styles carry their layout grids.', + 'effect styles carry their effects; grid styles carry their layout grids. ' + + 'Any value the designer bound to a variable carries a `boundVariables` map ({ field: variableId }) ' + + 'on the object that owns it — the individual paint, gradient stop, effect or layout grid, and the ' + + 'text style itself for typography. A bound value is a reference, not a literal: emit the token, ' + + 'not the resolved colour/number sitting next to it. `variables` maps every referenced id to its ' + + '{ name, type, codeSyntax? } (ids stay the key because variable names collide across ' + + 'collections); it is omitted when nothing in the document is bound.', inputSchema: z.object({}), kind: 'read', }; diff --git a/packages/mcp/test/e2e/read-tools.test.ts b/packages/mcp/test/e2e/read-tools.test.ts index 0069069..0841507 100644 --- a/packages/mcp/test/e2e/read-tools.test.ts +++ b/packages/mcp/test/e2e/read-tools.test.ts @@ -295,6 +295,75 @@ describe('e2e get_styles', () => { expect(result.paints[0]?.name).toBe('Primary'); expect(result).toEqual(response); }); + + // The bindings a style carries are nested string maps two and three levels down, plus a + // result-level lookup table — shapes the wire (msgpack over the relay) had never carried before + // issue #164. A unit test on the handler cannot say they survive the round trip. + it('carries per-object variable bindings and the variables table over the wire', async () => { + const h = await startLeader(); + harnesses.push(h); + const response: GetStylesResult = { + paints: [ + { + id: 'S:1', + name: 'Brand/Primary', + key: 'k1', + description: '', + paints: [ + { + type: 'SOLID', + visible: true, + opacity: 1, + color: { r: 0.1, g: 0.2, b: 0.3 }, + boundVariables: { color: 'VariableID:1' }, + }, + ], + }, + ], + texts: [], + effects: [ + { + id: 'S:3', + name: 'Elevation/focus', + key: 'k3', + description: '', + effects: [ + { + type: 'DROP_SHADOW', + visible: true, + radius: 24, + color: { r: 0.729, g: 0.839, b: 0.898, a: 1 }, + offset: { x: 0, y: 4 }, + spread: 4, + boundVariables: { color: 'VariableID:1', radius: 'VariableID:2' }, + }, + ], + }, + ], + grids: [], + variables: { + 'VariableID:1': { name: 'color/information', type: 'COLOR' }, + 'VariableID:2': { name: 'size/lg', type: 'FLOAT', codeSyntax: { WEB: '--size-lg' } }, + }, + }; + sockets.push( + await connectFakePlugin({ + port: h.port, + handlers: { [GET_STYLES_TOOL_NAME]: () => response }, + }), + ); + const result = (await dispatchTool( + { node: h.node, follower: h.follower }, + GET_STYLES_TOOL_NAME, + {}, + )) as GetStylesResult; + expect(result).toEqual(response); + expect(result.effects[0]?.effects[0]?.boundVariables).toEqual({ + color: 'VariableID:1', + radius: 'VariableID:2', + }); + expect(result.variables?.['VariableID:1']?.name).toBe('color/information'); + }); }); describe('e2e get_variable_defs', () => { diff --git a/packages/plugin/src/handlers/get-styles.ts b/packages/plugin/src/handlers/get-styles.ts index e5b888f..64ad47c 100644 --- a/packages/plugin/src/handlers/get-styles.ts +++ b/packages/plugin/src/handlers/get-styles.ts @@ -1,11 +1,89 @@ -import type { GetStylesResult, SerializedLineHeight } from '@figwright/shared'; +import type { + GetStylesResult, + ResolvedToken, + SerializedBindings, + SerializedLineHeight, + SerializedTextStyle, +} from '@figwright/shared'; import type { SandboxToolHandler } from '../dispatcher.js'; -import { serializeEffect, serializeLayoutGrid, serializePaint } from '../serializer.js'; +import { + collectBindings, + serializeCodeSyntax, + serializeEffect, + serializeLayoutGrid, + serializePaint, +} from '../serializer.js'; const serializeLineHeight = (lh: LineHeight): SerializedLineHeight => lh.unit === 'AUTO' ? { unit: 'AUTO' } : { unit: lh.unit, value: lh.value }; +/** + * Every variable id the serialized styles reference. Bindings ride on the individual paint / + * gradient stop / effect / layout grid (Figma keeps them there, not on the style), except for a + * text style, whose values are scalars and so binds on the style itself. + * + * A paint / effect / grid style ALSO exposes a style-level `boundVariables` (`{ paints | effects | + * layoutGrids: VariableAlias[] }`), which is deliberately not read: measured against a live file it + * is a flat list of whichever variables happen to be bound somewhere in the array — it says neither + * which paint/effect nor which field — so it is a strictly lossy summary of what the per-object + * bindings already carry exactly. + */ +const collectVariableIds = (result: GetStylesResult): Set => { + const ids = new Set(); + const add = (bindings: SerializedBindings | undefined): void => { + if (bindings !== undefined) for (const id of Object.values(bindings)) ids.add(id); + }; + for (const style of result.paints) { + for (const paint of style.paints) { + if (paint.type === 'SOLID') add(paint.boundVariables); + else if ('gradientStops' in paint) for (const s of paint.gradientStops) add(s.boundVariables); + } + } + for (const style of result.texts) add(style.boundVariables); + for (const style of result.effects) for (const e of style.effects) add(e.boundVariables); + for (const style of result.grids) for (const g of style.grids) add(g.boundVariables); + return ids; +}; + +/** + * Resolve variable ids → names, mirroring get_design_context's `variables` table so both grounding + * surfaces speak the same shape. An id that no longer resolves is skipped rather than fatal: the + * inline value stays as the fallback, exactly as it does today. + */ +const resolveVariables = async ( + figmaCtx: typeof figma, + ids: ReadonlySet, +): Promise | undefined> => { + const getVar = figmaCtx.variables?.getVariableByIdAsync; + if (ids.size === 0 || typeof getVar !== 'function') return undefined; + const ordered = [...ids]; + // Resolved in parallel but assembled in walk order: writing each id as its promise settles would + // key the table by whichever lookup finished first, so the same document could serialize two + // different byte sequences on two runs. + const resolved = await Promise.all( + ordered.map(async (id): Promise => { + try { + const v = await getVar.call(figmaCtx.variables, id); + if (v === null) return undefined; + const token: ResolvedToken = { name: v.name, type: v.resolvedType }; + const codeSyntax = serializeCodeSyntax((v as { codeSyntax?: unknown }).codeSyntax); + if (codeSyntax !== undefined) token.codeSyntax = codeSyntax; + return token; + } catch { + /* unresolved ref — skip, the inline value remains the fallback */ + return undefined; + } + }), + ); + const variables: Record = {}; + for (const [i, id] of ordered.entries()) { + const token = resolved[i]; + if (token !== undefined) variables[id] = token; + } + return Object.keys(variables).length > 0 ? variables : undefined; +}; + export const createGetStylesHandler = (figmaCtx: typeof figma): SandboxToolHandler => async () => { @@ -24,17 +102,24 @@ export const createGetStylesHandler = description: s.description, paints: s.paints.map(serializePaint), })), - texts: textStyles.map(s => ({ - id: s.id, - name: s.name, - key: s.key, - description: s.description, - fontName: { family: s.fontName.family, style: s.fontName.style }, - fontSize: s.fontSize, - lineHeight: serializeLineHeight(s.lineHeight), - letterSpacing: { unit: s.letterSpacing.unit, value: s.letterSpacing.value }, - textWrapStyle: s.textWrapStyle, - })), + texts: textStyles.map(s => { + const style: SerializedTextStyle = { + id: s.id, + name: s.name, + key: s.key, + description: s.description, + fontName: { family: s.fontName.family, style: s.fontName.style }, + fontSize: s.fontSize, + lineHeight: serializeLineHeight(s.lineHeight), + letterSpacing: { unit: s.letterSpacing.unit, value: s.letterSpacing.value }, + textWrapStyle: s.textWrapStyle, + }; + // Typography values are scalars, so a text style is the only place its bindings can live — + // unlike a paint / effect / grid style, where they sit on the individual object. + const bound = collectBindings(s.boundVariables); + if (bound !== undefined) style.boundVariables = bound; + return style; + }), effects: effectStyles.map(s => ({ id: s.id, name: s.name, @@ -50,5 +135,9 @@ export const createGetStylesHandler = grids: s.layoutGrids.map(serializeLayoutGrid), })), }; - return result; + // Ids are only worth resolving once the styles are serialized — that's what says which + // variables this document's styles actually reference. The table goes FIRST so a reader meets + // `VariableID:5:12` already knowing what it names, instead of after every style that cites it. + const variables = await resolveVariables(figmaCtx, collectVariableIds(result)); + return variables === undefined ? result : { variables, ...result }; }; diff --git a/packages/plugin/test/handlers/get-styles.test.ts b/packages/plugin/test/handlers/get-styles.test.ts index eee536d..b0d3f3a 100644 --- a/packages/plugin/test/handlers/get-styles.test.ts +++ b/packages/plugin/test/handlers/get-styles.test.ts @@ -3,14 +3,26 @@ import { describe, expect, it } from 'vitest'; import { createGetStylesHandler } from '../../src/handlers/get-styles.js'; -const fakeFigma = (over: Partial> = {}): typeof figma => +const fakeFigma = ( + over: Partial> = {}, + variables?: Record, +): typeof figma => ({ getLocalPaintStylesAsync: async () => over.paints ?? [], getLocalTextStylesAsync: async () => over.texts ?? [], getLocalEffectStylesAsync: async () => over.effects ?? [], getLocalGridStylesAsync: async () => over.grids ?? [], + ...(variables === undefined + ? {} + : { + variables: { + getVariableByIdAsync: async (id: string) => variables[id] ?? null, + }, + }), }) as unknown as typeof figma; +const alias = (id: string): unknown => ({ type: 'VARIABLE_ALIAS', id }); + describe('get_styles handler', () => { it('groups the four style categories with their payloads', async () => { const handler = createGetStylesHandler( @@ -118,3 +130,260 @@ describe('get_styles handler', () => { expect(result).toEqual({ paints: [], texts: [], effects: [], grids: [] }); }); }); + +// A style's variable bindings are the reason a designer's token survives into code. Figma keeps +// them per paint / effect / grid, and — for typography, whose values are scalars — on the text +// style itself; get_styles used to drop all of them, so a shadow colour bound to a variable was +// indistinguishable from a hard-coded RGBA (issue #164). +describe('get_styles handler — variable bindings', () => { + const boundStyles = { + paints: [ + { + id: 'S:1', + name: 'Brand/Primary', + key: 'k1', + description: '', + // Two paints, only the second bound — the case a flat style-level list cannot express. + paints: [ + { type: 'SOLID', visible: true, opacity: 1, color: { r: 0, g: 1, b: 0 } }, + { + type: 'SOLID', + visible: true, + opacity: 1, + color: { r: 0.1, g: 0.2, b: 0.3 }, + boundVariables: { color: alias('VariableID:paint') }, + }, + ], + boundVariables: { paints: [alias('VariableID:paint')] }, + }, + ], + texts: [ + { + id: 'S:2', + name: 'Body', + key: 'k2', + description: '', + fontName: { family: 'Inter', style: 'Regular' }, + fontSize: 24, + lineHeight: { unit: 'PIXELS', value: 32 }, + letterSpacing: { unit: 'PERCENT', value: 0 }, + textWrapStyle: 'AUTO', + boundVariables: { fontSize: alias('VariableID:text') }, + }, + ], + effects: [ + { + id: 'S:3', + name: 'Elevation/focus', + key: 'k3', + description: '', + effects: [ + { + type: 'DROP_SHADOW', + visible: true, + radius: 24, + color: { r: 0.729, g: 0.839, b: 0.898, a: 1 }, + offset: { x: 0, y: 2 }, + spread: 4, + blendMode: 'NORMAL', + boundVariables: { + color: alias('VariableID:effect-color'), + radius: alias('VariableID:effect-radius'), + }, + }, + ], + boundVariables: { + effects: [alias('VariableID:effect-color'), alias('VariableID:effect-radius')], + }, + }, + ], + grids: [ + { + id: 'S:4', + name: '12 Col', + key: 'k4', + description: '', + layoutGrids: [ + { + pattern: 'COLUMNS', + visible: true, + count: 12, + gutterSize: 24, + alignment: 'STRETCH', + boundVariables: { gutterSize: alias('VariableID:grid') }, + }, + ], + boundVariables: { layoutGrids: [alias('VariableID:grid')] }, + }, + ], + }; + // One variable per binding SITE: a table that reused ids across sites would still pass if + // collectVariableIds stopped walking one of the four style categories. + const varTable = { + 'VariableID:paint': { name: 'color/brand', resolvedType: 'COLOR' }, + 'VariableID:text': { name: 'size/body', resolvedType: 'FLOAT' }, + 'VariableID:effect-color': { name: 'color/information', resolvedType: 'COLOR' }, + 'VariableID:effect-radius': { + name: 'size/lg', + resolvedType: 'FLOAT', + codeSyntax: { WEB: '--size-lg' }, + }, + 'VariableID:grid': { name: 'size/gutter', resolvedType: 'FLOAT' }, + }; + + it('carries the binding on the object that owns it, per style category', async () => { + const result = (await createGetStylesHandler(fakeFigma(boundStyles, varTable))( + undefined, + )) as GetStylesResult; + + const paints = result.paints[0]?.paints ?? []; + expect(paints[0]).not.toHaveProperty('boundVariables'); + expect(paints[1]).toHaveProperty('boundVariables', { color: 'VariableID:paint' }); + expect(result.texts[0]?.boundVariables).toEqual({ fontSize: 'VariableID:text' }); + expect(result.effects[0]?.effects[0]?.boundVariables).toEqual({ + color: 'VariableID:effect-color', + radius: 'VariableID:effect-radius', + }); + expect(result.grids[0]?.grids[0]?.boundVariables).toEqual({ gutterSize: 'VariableID:grid' }); + }); + + it('puts the variables table before the styles that cite it', async () => { + const result = (await createGetStylesHandler(fakeFigma(boundStyles, varTable))( + undefined, + )) as GetStylesResult; + expect(Object.keys(result)[0]).toBe('variables'); + }); + + it('keys the variables table in a stable order, not in lookup-completion order', async () => { + // Resolution is parallel; a table assembled as promises settle would reorder run to run. + const slow = { + 'VariableID:paint': { name: 'color/brand', resolvedType: 'COLOR' }, + 'VariableID:text': { name: 'size/body', resolvedType: 'FLOAT' }, + 'VariableID:effect-color': { name: 'color/information', resolvedType: 'COLOR' }, + 'VariableID:effect-radius': { name: 'size/lg', resolvedType: 'FLOAT' }, + 'VariableID:grid': { name: 'size/gutter', resolvedType: 'FLOAT' }, + }; + const delays: Record = { + 'VariableID:paint': 8, + 'VariableID:text': 6, + 'VariableID:effect-color': 4, + 'VariableID:effect-radius': 2, + 'VariableID:grid': 0, + }; + const figmaCtx = { + getLocalPaintStylesAsync: async () => boundStyles.paints, + getLocalTextStylesAsync: async () => boundStyles.texts, + getLocalEffectStylesAsync: async () => boundStyles.effects, + getLocalGridStylesAsync: async () => boundStyles.grids, + variables: { + getVariableByIdAsync: async (id: string) => { + await new Promise(r => setTimeout(r, delays[id] ?? 0)); + return slow[id as keyof typeof slow] ?? null; + }, + }, + } as unknown as typeof figma; + const result = (await createGetStylesHandler(figmaCtx)(undefined)) as GetStylesResult; + // Walk order (paints → texts → effects → grids), which is the inverse of the resolve order. + expect(Object.keys(result.variables ?? {})).toEqual([ + 'VariableID:paint', + 'VariableID:text', + 'VariableID:effect-color', + 'VariableID:effect-radius', + 'VariableID:grid', + ]); + }); + + it('resolves every referenced id to a name, and only those', async () => { + const result = (await createGetStylesHandler(fakeFigma(boundStyles, varTable))( + undefined, + )) as GetStylesResult; + expect(result.variables).toEqual({ + 'VariableID:paint': { name: 'color/brand', type: 'COLOR' }, + 'VariableID:text': { name: 'size/body', type: 'FLOAT' }, + 'VariableID:effect-color': { name: 'color/information', type: 'COLOR' }, + 'VariableID:effect-radius': { + name: 'size/lg', + type: 'FLOAT', + codeSyntax: { WEB: '--size-lg' }, + }, + 'VariableID:grid': { name: 'size/gutter', type: 'FLOAT' }, + }); + }); + + it('resolves ids referenced only by a gradient stop', async () => { + const result = (await createGetStylesHandler( + fakeFigma( + { + paints: [ + { + id: 'S:1', + name: 'Fade', + key: 'k1', + description: '', + paints: [ + { + type: 'GRADIENT_LINEAR', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0], + [0, 1, 0], + ], + gradientStops: [ + { + position: 0, + color: { r: 0, g: 0, b: 0, a: 1 }, + boundVariables: { color: alias('VariableID:1') }, + }, + ], + }, + ], + }, + ], + }, + { 'VariableID:1': { name: 'color/brand-stop', resolvedType: 'COLOR' } }, + ), + )(undefined)) as GetStylesResult; + expect(result.variables).toEqual({ + 'VariableID:1': { name: 'color/brand-stop', type: 'COLOR' }, + }); + }); + + it('omits the variables table when nothing is bound', async () => { + const result = (await createGetStylesHandler( + fakeFigma( + { + texts: [ + { + id: 'S:2', + name: 'Body', + key: 'k2', + description: '', + fontName: { family: 'Inter', style: 'Regular' }, + fontSize: 16, + lineHeight: { unit: 'AUTO' }, + letterSpacing: { unit: 'PIXELS', value: 0 }, + textWrapStyle: 'AUTO', + boundVariables: {}, + }, + ], + }, + varTable, + ), + )(undefined)) as GetStylesResult; + expect(result).not.toHaveProperty('variables'); + expect(result.texts[0]).not.toHaveProperty('boundVariables'); + }); + + it('keeps the styles when a bound variable no longer resolves', async () => { + const result = (await createGetStylesHandler(fakeFigma(boundStyles, {}))( + undefined, + )) as GetStylesResult; + // The binding is still reported — the id is the honest answer; only the name is unavailable. + expect(result.effects[0]?.effects[0]?.boundVariables).toEqual({ + color: 'VariableID:effect-color', + radius: 'VariableID:effect-radius', + }); + expect(result).not.toHaveProperty('variables'); + }); +}); From 71d1a38145889421b6ed4427cc8375091fb323b4 Mon Sep 17 00:00:00 2001 From: Roya Date: Sat, 22 Aug 2026 18:47:51 +0800 Subject: [PATCH 4/5] test(plugin): ratchet variable-binding coverage against the typings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing one of these bindings is invisible: the payload still carries a perfectly good literal, it has just quietly stopped saying the value is a token. That is how the reported bug survived — and the serializer's field lists have no compile-time coupling to the typings, so nothing would catch the next one. `plugin-api.d.ts` names every bindable surface with a `VariableBindable*Field` alias, which makes that list the authoritative inventory. Recording it turns "Figma made a new kind of object bindable" into a CI failure on the typings bump, the one moment someone is looking. Fields inside an existing family need no entry — the serializer passes field names through, so a new one rides along. --- .../test/variable-binding-coverage.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 packages/plugin/test/variable-binding-coverage.test.ts diff --git a/packages/plugin/test/variable-binding-coverage.test.ts b/packages/plugin/test/variable-binding-coverage.test.ts new file mode 100644 index 0000000..314b49b --- /dev/null +++ b/packages/plugin/test/variable-binding-coverage.test.ts @@ -0,0 +1,78 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +// The drift ratchet for variable bindings. +// +// Figma does not keep a paint's / effect's / grid's variable binding in the owning node's +// `boundVariables` — it keeps it on the object itself, and the serializer has to read each one +// individually. Missing one is invisible: the payload still carries a perfectly good literal +// colour, it has just quietly stopped saying that the colour is a token (issue #164, where a shadow +// bound to a variable came back as a plain RGBA). +// +// `plugin-api.d.ts` names every bindable surface with a `VariableBindable*Field` alias, so that list +// is the authoritative inventory. Recording it here turns "Figma made a new kind of object +// bindable" from something nobody notices into a CI failure on the typings bump — the one moment +// someone is actually looking. Fields *inside* an existing family need no entry: the serializer +// passes field names through as Figma reports them, so a new one rides along on its own. + +const resolveFrom = createRequire(import.meta.url); + +/** Where the sandbox's own bindings are read. Kept next to the inventory it has to keep up with. */ +const HANDLED: Record = { + Node: "serializer.ts collectBoundVariables — a node's own boundVariables", + Text: 'serializer.ts collectBoundVariables (TEXT nodes) + get-styles.ts for a TextStyle', + Paint: 'serializer.ts serializePaint, SOLID branch', + ColorStop: 'serializer.ts serializePaint, gradient-stop branch', + Effect: 'serializer.ts serializeEffect, both branches', + LayoutGrid: 'serializer.ts serializeLayoutGrid, both branches', + ComponentProperty: 'serializer.ts collectComponentProperties reports the value, not its binding', + ComponentPropertyDefinition: + 'not serialized — get_component_api reports definitions, not bindings', + // The three style-level families are a flat `VariableAlias[]` of whatever is bound somewhere in + // the style's array: they name neither which paint/effect nor which field. Measured against a + // live file, every id in them also appears on the individual object, so reading them would only + // add a lossier copy of what get_styles already returns exactly. + PaintStyle: 'deliberately not read — lossy summary of the per-paint bindings (see get-styles.ts)', + EffectStyle: 'deliberately not read — lossy summary of the per-effect bindings', + GridStyle: 'deliberately not read — lossy summary of the per-grid bindings', +}; + +describe('variable-binding coverage vs @figma/plugin-typings', () => { + const pkg = dirname(resolveFrom.resolve('@figma/plugin-typings/package.json')); + const dts = join(pkg, 'plugin-api.d.ts'); + + it('finds the typings it audits', () => { + // A silently absent .d.ts would make every assertion below vacuous. + expect(existsSync(dts)).toBe(true); + }); + + it('accounts for every bindable surface the typings declare', () => { + const source = readFileSync(dts, 'utf8'); + const families = [...source.matchAll(/^type VariableBindable(\w+)Field =/gm)].map(m => m[1]!); + expect(families.length).toBeGreaterThan(5); + + const unhandled = families.filter(f => !Object.hasOwn(HANDLED, f)); + const stale = Object.keys(HANDLED).filter(f => !families.includes(f)); + // Guidance rides inside the compared value so CI prints it — oxlint's valid-expect forbids + // expect()'s message argument. + expect({ unhandled, stale }).toEqual({ + unhandled: [], + stale: [], + ...(unhandled.length > 0 + ? { + hint: + `Figma made a new kind of object variable-bindable (${unhandled.join(', ')}). ` + + 'Decide where its binding is read — the object that owns it, the way serializePaint / ' + + 'serializeEffect / serializeLayoutGrid do — then record it in HANDLED. Leaving it ' + + 'unread reproduces issue #164: a bound value comes back as a plain literal.', + } + : {}), + ...(stale.length > 0 + ? { hint: `Removed from the typings: ${stale.join(', ')} — drop it from HANDLED.` } + : {}), + }); + }); +}); From b197f2c4374c31cd39e463fc367a86a36d1949e3 Mon Sep 17 00:00:00 2001 From: Roya Date: Sat, 22 Aug 2026 18:47:51 +0800 Subject: [PATCH 5/5] docs(figma-build): don't overwrite a variable-bound style value with a literal get_styles now shows which of a style's values are references rather than literals, which makes the failure it enables worth naming: re-syncing a style ramp from code through update_* writes the resolved number or colour back over the binding, and the style silently stops tracking the token it was built on. Change the variable instead. --- skills/figma-build/references/author-design-system.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/skills/figma-build/references/author-design-system.md b/skills/figma-build/references/author-design-system.md index cdda3a2..6022517 100644 --- a/skills/figma-build/references/author-design-system.md +++ b/skills/figma-build/references/author-design-system.md @@ -57,6 +57,13 @@ an orphan collection behind. changed; omitted fields stay as-is) — **don't** `create_*` a second style with the same name, which leaves a duplicate. Re-syncing a style ramp from code is the common case; `get_styles` gives you the `styleId`s to target. +- **A value the style binds to a variable is a reference, not a literal.** `get_styles` reports those + bindings as `boundVariables` (`{ field: variableId }`) on the paint / effect / layout grid that + owns them — and on the text style itself for typography — with `variables` naming each id. Writing + that value back as a literal through `update_*` replaces the reference with a frozen copy, so the + style silently stops tracking the token it was built on. When code changes such a value, change the + **variable** (`set_variable_value`) instead; only rewrite the style's own paints/effects when the + field genuinely carries no binding. Apply a style to a node with `apply_style_to_node` (`field`: fill / stroke / effect / grid / text). Prefer a **variable** for a single colour/scalar token and a **style** for a reusable multi-property