Skip to content

Commit dfd740b

Browse files
committed
feat(mcp): bind a color variable to a fill/stroke paint
New bind_variable_to_paint tool closes the write-side gap that bind_variable_to_node can't cover: Figma keeps fill/stroke colour bindings on the paint, not the node, so node.setBoundVariable('fills', …) throws ("must be set on paints directly"). The new tool uses figma.variables.setBoundVariableForPaint and writes the returned paint back into a cloned array; supports fills/strokes, a paint index, and unbinding (variableId null). bind_variable_to_node now redirects fills/strokes to it instead of surfacing Figma's opaque error. This is the write half of token-driven colouring — the design- system build path needs it to colour nodes by token instead of hard-coding hex. Live-verified: bound a frame's fill to a White COLOR variable; get_node reads the binding back via node.boundVariables.fills (so the existing reader already round-trips it — no reader change needed).
1 parent e18cb14 commit dfd740b

7 files changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { z } from 'zod';
2+
3+
import type { ToolSpec } from './spec.js';
4+
5+
export const BIND_VARIABLE_TO_PAINT_TOOL_NAME = 'bind_variable_to_paint';
6+
7+
export const bindVariableToPaintTool: ToolSpec = {
8+
name: BIND_VARIABLE_TO_PAINT_TOOL_NAME,
9+
description:
10+
'Bind a COLOR variable to a SOLID fill or stroke paint (the design-token way to colour a node) ' +
11+
'— or unbind by passing variableId: null. Figma stores fill/stroke colour bindings on the paint, ' +
12+
'not the node, so this is separate from bind_variable_to_node (which covers scalar fields like ' +
13+
'width / padding / radius). target is fills (default) or strokes; index selects which paint ' +
14+
'(default 0). The paint at that index must be SOLID. Returns { ok, nodeId }.',
15+
inputShape: {
16+
nodeId: z.string().describe('Node whose fill/stroke paint to bind'),
17+
target: z
18+
.enum(['fills', 'strokes'])
19+
.optional()
20+
.describe('Which paint list to bind on (default fills)'),
21+
index: z
22+
.number()
23+
.int()
24+
.min(0)
25+
.optional()
26+
.describe('Index of the paint within the list (default 0)'),
27+
variableId: z
28+
.string()
29+
.nullable()
30+
.describe('COLOR variable id to bind, or null to remove the binding'),
31+
},
32+
kind: 'write',
33+
};

packages/mcp/src/tools/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { applyStyleToNodeTool } from './apply-style-to-node.js';
1010
import { batchRenameNodesTool } from './batch-rename-nodes.js';
1111
import { batchTool } from './batch.js';
1212
import { bindVariableToNodeTool } from './bind-variable-to-node.js';
13+
import { bindVariableToPaintTool } from './bind-variable-to-paint.js';
1314
import { cloneNodeTool } from './clone-node.js';
1415
import { combineAsVariantsTool } from './combine-as-variants.js';
1516
import { componentMapTool } from './component-map.js';
@@ -158,6 +159,7 @@ export const ALL_TOOL_SPECS: readonly ToolSpec[] = [
158159
createVariableTool,
159160
setVariableValueTool,
160161
bindVariableToNodeTool,
162+
bindVariableToPaintTool,
161163
renameVariableTool,
162164
deleteVariableTool,
163165
groupNodesTool,

packages/plugin/src/handlers/bind-variable-to-node.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export const createBindVariableToNodeHandler =
1313
if (p.variableId !== null && typeof p.variableId !== 'string') {
1414
throw new TypeError('bind_variable_to_node: variableId must be a string or null');
1515
}
16+
// Fill/stroke colour bindings live on the paint, not the node — setBoundVariable rejects them.
17+
// Point the caller at the right tool instead of surfacing Figma's opaque error.
18+
if (p.field === 'fills' || p.field === 'strokes') {
19+
throw new Error(
20+
`bind_variable_to_node: "${p.field}" is a paint-level colour binding — use bind_variable_to_paint instead`,
21+
);
22+
}
1623

1724
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
1825
if (node === null) throw new Error(`bind_variable_to_node: node ${p.nodeId} not found`);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { MutateResult } from '@figwright/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
/**
6+
* Bind (or unbind) a COLOR variable on a node's fill/stroke paint. Figma keeps fill/stroke colour
7+
* bindings on the paint — not in node.boundVariables — so `node.setBoundVariable('fills', …)`
8+
* throws; the only path is `figma.variables.setBoundVariableForPaint`, which returns a NEW paint
9+
* that must be written back into a cloned array (paints are read-only).
10+
*/
11+
export const createBindVariableToPaintHandler =
12+
(figmaCtx: typeof figma): SandboxToolHandler =>
13+
async params => {
14+
const p = (params ?? {}) as {
15+
nodeId?: unknown;
16+
target?: unknown;
17+
index?: unknown;
18+
variableId?: unknown;
19+
};
20+
if (typeof p.nodeId !== 'string')
21+
throw new TypeError('bind_variable_to_paint: nodeId must be a string');
22+
const target: 'fills' | 'strokes' = p.target === 'strokes' ? 'strokes' : 'fills';
23+
const index = typeof p.index === 'number' ? p.index : 0;
24+
if (p.variableId !== null && typeof p.variableId !== 'string') {
25+
throw new TypeError('bind_variable_to_paint: variableId must be a string or null');
26+
}
27+
28+
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
29+
if (node === null || !(target in node)) {
30+
throw new Error(`bind_variable_to_paint: node ${p.nodeId} not found or has no ${target}`);
31+
}
32+
const paints = (node as unknown as Record<string, unknown>)[target];
33+
if (!Array.isArray(paints)) {
34+
throw new Error(`bind_variable_to_paint: ${target} on ${p.nodeId} is mixed or unreadable`);
35+
}
36+
const paint = paints[index] as Paint | undefined;
37+
if (paint === undefined) {
38+
throw new Error(`bind_variable_to_paint: no paint at ${target}[${index}] on ${p.nodeId}`);
39+
}
40+
if (paint.type !== 'SOLID') {
41+
throw new Error(
42+
`bind_variable_to_paint: ${target}[${index}] is ${paint.type}; only SOLID paints bind a colour variable`,
43+
);
44+
}
45+
46+
let variable: Variable | null = null;
47+
if (typeof p.variableId === 'string') {
48+
variable = await figmaCtx.variables.getVariableByIdAsync(p.variableId);
49+
if (variable === null)
50+
throw new Error(`bind_variable_to_paint: variable ${p.variableId} not found`);
51+
}
52+
53+
const bound = figmaCtx.variables.setBoundVariableForPaint(paint, 'color', variable);
54+
const next = [...(paints as Paint[])];
55+
next[index] = bound;
56+
(node as unknown as Record<string, unknown>)[target] = next;
57+
58+
const result: MutateResult = { ok: true, nodeId: node.id };
59+
return result;
60+
};

packages/plugin/src/handlers/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createApplyStyleToNodeHandler } from './apply-style-to-node.js';
66
import { createBatchRenameNodesHandler } from './batch-rename-nodes.js';
77
import { createBatchHandler } from './batch.js';
88
import { createBindVariableToNodeHandler } from './bind-variable-to-node.js';
9+
import { createBindVariableToPaintHandler } from './bind-variable-to-paint.js';
910
import { createCloneNodeHandler } from './clone-node.js';
1011
import { createCombineAsVariantsHandler } from './combine-as-variants.js';
1112
import { createCreateComponentHandler } from './create-component.js';
@@ -129,6 +130,7 @@ export const createSandboxHandlers = (figmaCtx: typeof figma): SandboxHandlers =
129130
create_variable: createCreateVariableHandler(figmaCtx),
130131
set_variable_value: createSetVariableValueHandler(figmaCtx),
131132
bind_variable_to_node: createBindVariableToNodeHandler(figmaCtx),
133+
bind_variable_to_paint: createBindVariableToPaintHandler(figmaCtx),
132134
rename_variable: createRenameVariableHandler(figmaCtx),
133135
delete_variable: createDeleteVariableHandler(figmaCtx),
134136
// Structure + bulk text

packages/plugin/test/handlers/bind-variable-to-node.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ describe('bind_variable_to_node handler', () => {
4444
expect(result).toEqual({ ok: true, nodeId: '1:1' });
4545
});
4646

47+
it('redirects fills/strokes to bind_variable_to_paint (paint-level colour binding)', async () => {
48+
for (const field of ['fills', 'strokes']) {
49+
await expect(
50+
createBindVariableToNodeHandler(
51+
fakeFigma({ id: '1:1', setBoundVariable() {} }, { id: 'V:0' }),
52+
)({ nodeId: '1:1', field, variableId: 'V:0' }),
53+
).rejects.toThrow(/use bind_variable_to_paint instead/);
54+
}
55+
});
56+
4757
it('throws on missing node, missing variable, or non-bindable node', async () => {
4858
await expect(
4959
createBindVariableToNodeHandler(fakeFigma(null, { id: 'V:0' }))({
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { MutateResult } from '@figwright/shared';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { createBindVariableToPaintHandler } from '../../src/handlers/bind-variable-to-paint.js';
5+
6+
const solid = () => ({ type: 'SOLID', color: { r: 1, g: 1, b: 1 } });
7+
8+
// setBoundVariableForPaint returns a NEW paint (the binding marker) — the handler must write it back.
9+
const fakeFigma = (node: unknown, variable: unknown, bound: unknown) =>
10+
({
11+
getNodeByIdAsync: async () => node,
12+
variables: {
13+
getVariableByIdAsync: async () => variable,
14+
setBoundVariableForPaint: vi.fn<() => unknown>(() => bound),
15+
},
16+
}) as unknown as typeof figma & {
17+
variables: { setBoundVariableForPaint: ReturnType<typeof vi.fn> };
18+
};
19+
20+
describe('bind_variable_to_paint handler', () => {
21+
it('binds a colour variable to fills[0] and writes back a new paint array', async () => {
22+
const node = { id: '1:1', fills: [solid()] };
23+
const variable = { id: 'V:white' };
24+
const boundPaint = {
25+
type: 'SOLID',
26+
color: { r: 1, g: 1, b: 1 },
27+
boundVariables: { color: variable },
28+
};
29+
const ctx = fakeFigma(node, variable, boundPaint);
30+
31+
const result = (await createBindVariableToPaintHandler(ctx)({
32+
nodeId: '1:1',
33+
variableId: 'V:white',
34+
})) as MutateResult;
35+
36+
expect(ctx.variables.setBoundVariableForPaint).toHaveBeenCalledWith(
37+
expect.objectContaining({ type: 'SOLID' }),
38+
'color',
39+
variable,
40+
);
41+
// a NEW array with the returned paint at the index (not a mutation of the read-only original)
42+
expect(node.fills).toEqual([boundPaint]);
43+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
44+
});
45+
46+
it('targets strokes and the given index', async () => {
47+
const node = { id: '2:2', strokes: [solid(), solid()] };
48+
const variable = { id: 'V:grey' };
49+
const bound = { type: 'SOLID', color: { r: 0, g: 0, b: 0 } };
50+
const ctx = fakeFigma(node, variable, bound);
51+
52+
await createBindVariableToPaintHandler(ctx)({
53+
nodeId: '2:2',
54+
target: 'strokes',
55+
index: 1,
56+
variableId: 'V:grey',
57+
});
58+
59+
expect(node.strokes[1]).toBe(bound);
60+
expect(node.strokes[0]).toEqual(solid()); // untouched
61+
});
62+
63+
it('unbinds with variableId null, never looking up a variable', async () => {
64+
const getVariableByIdAsync = vi.fn<() => Promise<unknown>>();
65+
const node = { id: '3:3', fills: [solid()] };
66+
const bound = solid();
67+
const ctx = {
68+
getNodeByIdAsync: async () => node,
69+
variables: {
70+
getVariableByIdAsync,
71+
setBoundVariableForPaint: vi.fn<() => unknown>(() => bound),
72+
},
73+
} as unknown as typeof figma;
74+
75+
const result = (await createBindVariableToPaintHandler(ctx)({
76+
nodeId: '3:3',
77+
variableId: null,
78+
})) as MutateResult;
79+
80+
expect(getVariableByIdAsync).not.toHaveBeenCalled();
81+
expect(result).toEqual({ ok: true, nodeId: '3:3' });
82+
});
83+
84+
it('rejects a non-SOLID paint at the target index', async () => {
85+
const node = { id: '4:4', fills: [{ type: 'GRADIENT_LINEAR' }] };
86+
await expect(
87+
createBindVariableToPaintHandler(fakeFigma(node, { id: 'V:0' }, {}))({
88+
nodeId: '4:4',
89+
variableId: 'V:0',
90+
}),
91+
).rejects.toThrow(/only SOLID paints/);
92+
});
93+
94+
it('throws on missing node, missing paint index, or missing variable', async () => {
95+
await expect(
96+
createBindVariableToPaintHandler(fakeFigma(null, { id: 'V:0' }, {}))({
97+
nodeId: '9:9',
98+
variableId: 'V:0',
99+
}),
100+
).rejects.toThrow(/not found or has no fills/);
101+
102+
await expect(
103+
createBindVariableToPaintHandler(fakeFigma({ id: '1:1', fills: [] }, { id: 'V:0' }, {}))({
104+
nodeId: '1:1',
105+
index: 0,
106+
variableId: 'V:0',
107+
}),
108+
).rejects.toThrow(/no paint at fills\[0\]/);
109+
110+
await expect(
111+
createBindVariableToPaintHandler(fakeFigma({ id: '1:1', fills: [solid()] }, null, {}))({
112+
nodeId: '1:1',
113+
variableId: 'V:missing',
114+
}),
115+
).rejects.toThrow(/variable .* not found/);
116+
});
117+
});

0 commit comments

Comments
 (0)