Skip to content

Commit 652e855

Browse files
committed
feat(m2): add 8 style + 6 variable write tools (34/52)
Styles: set_effects, create/update_paint_style, create_text/effect/grid_style, apply_style_to_node (field=fill/stroke/effect/grid/text), delete_style. Variables: create_variable_collection, add_variable_mode, create_variable, set_variable_value (per-mode, alias/color), bind_variable_to_node, delete_variable. New handlers/convert.ts inverts the read-side serializer (toFigmaEffect / toFigmaLayoutGrid / toFigmaLineHeight / toFigmaVariableValue), pairing with the existing toFigmaPaint so write handlers stop hand-rolling Figma objects. shared/writes.ts gains StyleResult / CollectionResult / ModeResult / VariableResult. All wrapped with idempotent() and registered in WRITE_TOOLS. 365 tests pass (+29); typecheck / oxlint / plugin+server build all green. Styles/variables verified by unit tests only — real-Figma smoke deferred to the end-of-M2 one-shot pass. PLAN.md M2 section synced.
1 parent 9f96d7e commit 652e855

46 files changed

Lines changed: 1488 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/plugin/src/code.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,22 @@ import { createPluginContextEvent, SELECTION_DETAIL_LIMIT } from '@figma-mcp-rel
22

33
import { dispatchSandboxMessage, type SandboxHandlers } from './dispatcher.js';
44
import { createIdempotencyCache, idempotent } from './idempotency.js';
5+
import { createAddVariableModeHandler } from './handlers/add-variable-mode.js';
6+
import { createApplyStyleToNodeHandler } from './handlers/apply-style-to-node.js';
7+
import { createBindVariableToNodeHandler } from './handlers/bind-variable-to-node.js';
58
import { createCloneNodeHandler } from './handlers/clone-node.js';
9+
import { createCreateEffectStyleHandler } from './handlers/create-effect-style.js';
610
import { createCreateFrameHandler } from './handlers/create-frame.js';
11+
import { createCreateGridStyleHandler } from './handlers/create-grid-style.js';
12+
import { createCreatePaintStyleHandler } from './handlers/create-paint-style.js';
713
import { createCreateRectangleHandler } from './handlers/create-rectangle.js';
814
import { createCreateTextHandler } from './handlers/create-text.js';
15+
import { createCreateTextStyleHandler } from './handlers/create-text-style.js';
16+
import { createCreateVariableHandler } from './handlers/create-variable.js';
17+
import { createCreateVariableCollectionHandler } from './handlers/create-variable-collection.js';
918
import { createDeleteNodesHandler } from './handlers/delete-nodes.js';
19+
import { createDeleteStyleHandler } from './handlers/delete-style.js';
20+
import { createDeleteVariableHandler } from './handlers/delete-variable.js';
1021
import { createGetAnnotationsHandler } from './handlers/get-annotations.js';
1122
import { createGetDesignContextHandler } from './handlers/get-design-context.js';
1223
import { createGetDocumentHandler } from './handlers/get-document.js';
@@ -36,11 +47,14 @@ import { createSetAutoLayoutHandler } from './handlers/set-auto-layout.js';
3647
import { createSetBlendModeHandler } from './handlers/set-blend-mode.js';
3748
import { createSetConstraintsHandler } from './handlers/set-constraints.js';
3849
import { createSetCornerRadiusHandler } from './handlers/set-corner-radius.js';
50+
import { createSetEffectsHandler } from './handlers/set-effects.js';
3951
import { createSetFillsHandler } from './handlers/set-fills.js';
4052
import { createSetOpacityHandler } from './handlers/set-opacity.js';
4153
import { createSetStrokesHandler } from './handlers/set-strokes.js';
4254
import { createSetTextHandler } from './handlers/set-text.js';
55+
import { createSetVariableValueHandler } from './handlers/set-variable-value.js';
4356
import { createSetVisibleHandler } from './handlers/set-visible.js';
57+
import { createUpdatePaintStyleHandler } from './handlers/update-paint-style.js';
4458

4559
figma.showUI(__html__, { width: 320, height: 400, themeColors: true });
4660

@@ -114,6 +128,22 @@ const handlers: SandboxHandlers = {
114128
lock_nodes: idempotent(idempotencyCache, createSetLockedHandler(figma, true)),
115129
unlock_nodes: idempotent(idempotencyCache, createSetLockedHandler(figma, false)),
116130
clone_node: idempotent(idempotencyCache, createCloneNodeHandler(figma)),
131+
// Styles
132+
set_effects: idempotent(idempotencyCache, createSetEffectsHandler(figma)),
133+
create_paint_style: idempotent(idempotencyCache, createCreatePaintStyleHandler(figma)),
134+
create_text_style: idempotent(idempotencyCache, createCreateTextStyleHandler(figma)),
135+
create_effect_style: idempotent(idempotencyCache, createCreateEffectStyleHandler(figma)),
136+
create_grid_style: idempotent(idempotencyCache, createCreateGridStyleHandler(figma)),
137+
update_paint_style: idempotent(idempotencyCache, createUpdatePaintStyleHandler(figma)),
138+
apply_style_to_node: idempotent(idempotencyCache, createApplyStyleToNodeHandler(figma)),
139+
delete_style: idempotent(idempotencyCache, createDeleteStyleHandler(figma)),
140+
// Variables
141+
create_variable_collection: idempotent(idempotencyCache, createCreateVariableCollectionHandler(figma)),
142+
add_variable_mode: idempotent(idempotencyCache, createAddVariableModeHandler(figma)),
143+
create_variable: idempotent(idempotencyCache, createCreateVariableHandler(figma)),
144+
set_variable_value: idempotent(idempotencyCache, createSetVariableValueHandler(figma)),
145+
bind_variable_to_node: idempotent(idempotencyCache, createBindVariableToNodeHandler(figma)),
146+
delete_variable: idempotent(idempotencyCache, createDeleteVariableHandler(figma)),
117147
};
118148

119149
figma.ui.onmessage = (raw: unknown) => {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { ModeResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
export const createAddVariableModeHandler =
6+
(figmaCtx: typeof figma): SandboxToolHandler =>
7+
async params => {
8+
const p = (params ?? {}) as { collectionId?: unknown; name?: unknown };
9+
if (typeof p.collectionId !== 'string') {
10+
throw new TypeError('add_variable_mode: collectionId must be a string');
11+
}
12+
if (typeof p.name !== 'string') throw new TypeError('add_variable_mode: name must be a string');
13+
14+
const collection = await figmaCtx.variables.getVariableCollectionByIdAsync(p.collectionId);
15+
if (collection === null) {
16+
throw new Error(`add_variable_mode: collection ${p.collectionId} not found`);
17+
}
18+
const modeId = collection.addMode(p.name);
19+
20+
const result: ModeResult = { ok: true, modeId, name: p.name };
21+
return result;
22+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
const STYLE_FIELDS = ['fill', 'stroke', 'effect', 'grid', 'text'] as const;
6+
type StyleField = (typeof STYLE_FIELDS)[number];
7+
8+
// Each field maps to the node's async style-id setter (Figma deprecated the sync `*StyleId` setters).
9+
const SETTER_BY_FIELD: Record<StyleField, string> = {
10+
fill: 'setFillStyleIdAsync',
11+
stroke: 'setStrokeStyleIdAsync',
12+
effect: 'setEffectStyleIdAsync',
13+
grid: 'setGridStyleIdAsync',
14+
text: 'setTextStyleIdAsync',
15+
};
16+
17+
export const createApplyStyleToNodeHandler =
18+
(figmaCtx: typeof figma): SandboxToolHandler =>
19+
async params => {
20+
const p = (params ?? {}) as { nodeId?: unknown; styleId?: unknown; field?: unknown };
21+
if (typeof p.nodeId !== 'string') throw new TypeError('apply_style_to_node: nodeId must be a string');
22+
if (typeof p.styleId !== 'string') throw new TypeError('apply_style_to_node: styleId must be a string');
23+
if (!STYLE_FIELDS.includes(p.field as StyleField)) {
24+
throw new TypeError(`apply_style_to_node: field must be one of ${STYLE_FIELDS.join(' / ')}`);
25+
}
26+
27+
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
28+
if (node === null) throw new Error(`apply_style_to_node: node ${p.nodeId} not found`);
29+
30+
const setter = SETTER_BY_FIELD[p.field as StyleField];
31+
const fn = (node as unknown as Record<string, unknown>)[setter];
32+
if (typeof fn !== 'function') {
33+
throw new Error(`apply_style_to_node: node ${p.nodeId} cannot take a ${String(p.field)} style`);
34+
}
35+
await (fn as (id: string) => Promise<void>).call(node, p.styleId);
36+
37+
const result: MutateResult = { ok: true, nodeId: node.id };
38+
return result;
39+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
export const createBindVariableToNodeHandler =
6+
(figmaCtx: typeof figma): SandboxToolHandler =>
7+
async params => {
8+
const p = (params ?? {}) as { nodeId?: unknown; field?: unknown; variableId?: unknown };
9+
if (typeof p.nodeId !== 'string') throw new TypeError('bind_variable_to_node: nodeId must be a string');
10+
if (typeof p.field !== 'string') throw new TypeError('bind_variable_to_node: field must be a string');
11+
if (typeof p.variableId !== 'string') {
12+
throw new TypeError('bind_variable_to_node: variableId must be a string');
13+
}
14+
15+
const [node, variable] = await Promise.all([
16+
figmaCtx.getNodeByIdAsync(p.nodeId),
17+
figmaCtx.variables.getVariableByIdAsync(p.variableId),
18+
]);
19+
if (node === null) throw new Error(`bind_variable_to_node: node ${p.nodeId} not found`);
20+
if (variable === null) throw new Error(`bind_variable_to_node: variable ${p.variableId} not found`);
21+
if (typeof (node as { setBoundVariable?: unknown }).setBoundVariable !== 'function') {
22+
throw new Error(`bind_variable_to_node: node ${p.nodeId} cannot bind variables`);
23+
}
24+
(node as SceneNode).setBoundVariable(p.field as VariableBindableNodeField, variable);
25+
26+
const result: MutateResult = { ok: true, nodeId: node.id };
27+
return result;
28+
};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type {
2+
SerializedEffect,
3+
SerializedLayoutGrid,
4+
SerializedLineHeight,
5+
SerializedVariableValue,
6+
} from '@figma-mcp-relay/shared';
7+
8+
// Inverse of serializer.ts — turn the wire-format back into Figma API objects for write tools.
9+
// (serializer.ts owns the Figma → wire direction; these are the matching wire → Figma helpers.)
10+
11+
/** Wire effect → Figma Effect. Shadows need color + offset; blurs need radius. */
12+
export const toFigmaEffect = (e: SerializedEffect): Effect => {
13+
if (e.type === 'DROP_SHADOW' || e.type === 'INNER_SHADOW') {
14+
if (e.color === undefined || e.offset === undefined) {
15+
throw new TypeError(`${e.type} requires color and offset`);
16+
}
17+
return {
18+
type: e.type,
19+
visible: e.visible,
20+
radius: e.radius ?? 0,
21+
color: { r: e.color.r, g: e.color.g, b: e.color.b, a: e.color.a },
22+
offset: { x: e.offset.x, y: e.offset.y },
23+
spread: e.spread ?? 0,
24+
blendMode: 'NORMAL',
25+
} as Effect;
26+
}
27+
if (e.type === 'LAYER_BLUR' || e.type === 'BACKGROUND_BLUR') {
28+
return { type: e.type, visible: e.visible, radius: e.radius ?? 0 } as Effect;
29+
}
30+
throw new TypeError(`unsupported effect type: ${e.type}`);
31+
};
32+
33+
/** Wire layout grid → Figma LayoutGrid. GRID is uniform; ROWS/COLUMNS carry count + gutter. */
34+
export const toFigmaLayoutGrid = (g: SerializedLayoutGrid): LayoutGrid => {
35+
if (g.pattern === 'GRID') {
36+
return { pattern: 'GRID', visible: g.visible, sectionSize: g.sectionSize ?? 10 };
37+
}
38+
if (g.pattern === 'ROWS' || g.pattern === 'COLUMNS') {
39+
return {
40+
pattern: g.pattern,
41+
visible: g.visible,
42+
alignment: g.alignment ?? 'STRETCH',
43+
gutterSize: g.gutterSize ?? 0,
44+
count: g.count ?? 1,
45+
offset: 0,
46+
...(g.sectionSize === undefined ? {} : { sectionSize: g.sectionSize }),
47+
} as LayoutGrid;
48+
}
49+
throw new TypeError(`unsupported layout grid pattern: ${g.pattern}`);
50+
};
51+
52+
/** Wire line height → Figma LineHeight (AUTO omits value). */
53+
export const toFigmaLineHeight = (lh: SerializedLineHeight): LineHeight => {
54+
if (lh.unit === 'AUTO') return { unit: 'AUTO' };
55+
if (typeof lh.value !== 'number') {
56+
throw new TypeError(`lineHeight unit ${lh.unit} requires a numeric value`);
57+
}
58+
return { unit: lh.unit as 'PIXELS' | 'PERCENT', value: lh.value };
59+
};
60+
61+
/** Wire variable value → Figma VariableValue (alias / color / primitive). */
62+
export const toFigmaVariableValue = (value: SerializedVariableValue): VariableValue => {
63+
if (typeof value === 'object' && value !== null) {
64+
if ('r' in value) return { r: value.r, g: value.g, b: value.b, a: value.a };
65+
return { type: 'VARIABLE_ALIAS', id: value.id };
66+
}
67+
return value;
68+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { SerializedEffect, StyleResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
import { toFigmaEffect } from './convert.js';
5+
6+
export const createCreateEffectStyleHandler =
7+
(figmaCtx: typeof figma): SandboxToolHandler =>
8+
// eslint-disable-next-line @typescript-eslint/require-await
9+
async params => {
10+
const p = (params ?? {}) as { name?: unknown; effects?: unknown; description?: unknown };
11+
if (typeof p.name !== 'string') throw new TypeError('create_effect_style: name must be a string');
12+
if (!Array.isArray(p.effects)) throw new TypeError('create_effect_style: effects must be an array');
13+
14+
const style = figmaCtx.createEffectStyle();
15+
style.name = p.name;
16+
style.effects = (p.effects as SerializedEffect[]).map(toFigmaEffect);
17+
if (typeof p.description === 'string') style.description = p.description;
18+
19+
const result: StyleResult = { ok: true, styleId: style.id, name: style.name };
20+
return result;
21+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { SerializedLayoutGrid, StyleResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
import { toFigmaLayoutGrid } from './convert.js';
5+
6+
export const createCreateGridStyleHandler =
7+
(figmaCtx: typeof figma): SandboxToolHandler =>
8+
// eslint-disable-next-line @typescript-eslint/require-await
9+
async params => {
10+
const p = (params ?? {}) as { name?: unknown; grids?: unknown; description?: unknown };
11+
if (typeof p.name !== 'string') throw new TypeError('create_grid_style: name must be a string');
12+
if (!Array.isArray(p.grids)) throw new TypeError('create_grid_style: grids must be an array');
13+
14+
const style = figmaCtx.createGridStyle();
15+
style.name = p.name;
16+
style.layoutGrids = (p.grids as SerializedLayoutGrid[]).map(toFigmaLayoutGrid);
17+
if (typeof p.description === 'string') style.description = p.description;
18+
19+
const result: StyleResult = { ok: true, styleId: style.id, name: style.name };
20+
return result;
21+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { SerializedPaint, StyleResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
import { toFigmaPaint } from './set-fills.js';
5+
6+
export const createCreatePaintStyleHandler =
7+
(figmaCtx: typeof figma): SandboxToolHandler =>
8+
// eslint-disable-next-line @typescript-eslint/require-await
9+
async params => {
10+
const p = (params ?? {}) as { name?: unknown; paints?: unknown; description?: unknown };
11+
if (typeof p.name !== 'string') throw new TypeError('create_paint_style: name must be a string');
12+
if (!Array.isArray(p.paints)) throw new TypeError('create_paint_style: paints must be an array');
13+
14+
const style = figmaCtx.createPaintStyle();
15+
style.name = p.name;
16+
style.paints = (p.paints as SerializedPaint[]).map(toFigmaPaint);
17+
if (typeof p.description === 'string') style.description = p.description;
18+
19+
const result: StyleResult = { ok: true, styleId: style.id, name: style.name };
20+
return result;
21+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type {
2+
SerializedFontName,
3+
SerializedLetterSpacing,
4+
SerializedLineHeight,
5+
StyleResult,
6+
} from '@figma-mcp-relay/shared';
7+
8+
import type { SandboxToolHandler } from '../dispatcher.js';
9+
import { toFigmaLineHeight } from './convert.js';
10+
11+
export const createCreateTextStyleHandler =
12+
(figmaCtx: typeof figma): SandboxToolHandler =>
13+
async params => {
14+
const p = (params ?? {}) as {
15+
name?: unknown;
16+
fontName?: unknown;
17+
fontSize?: unknown;
18+
lineHeight?: unknown;
19+
letterSpacing?: unknown;
20+
description?: unknown;
21+
};
22+
if (typeof p.name !== 'string') throw new TypeError('create_text_style: name must be a string');
23+
24+
const style = figmaCtx.createTextStyle();
25+
style.name = p.name;
26+
if (p.fontName !== undefined) {
27+
const fn = p.fontName as SerializedFontName;
28+
await figmaCtx.loadFontAsync({ family: fn.family, style: fn.style });
29+
style.fontName = { family: fn.family, style: fn.style };
30+
}
31+
if (typeof p.fontSize === 'number') style.fontSize = p.fontSize;
32+
if (p.lineHeight !== undefined) style.lineHeight = toFigmaLineHeight(p.lineHeight as SerializedLineHeight);
33+
if (p.letterSpacing !== undefined) {
34+
const ls = p.letterSpacing as SerializedLetterSpacing;
35+
style.letterSpacing = { unit: ls.unit as 'PIXELS' | 'PERCENT', value: ls.value };
36+
}
37+
if (typeof p.description === 'string') style.description = p.description;
38+
39+
const result: StyleResult = { ok: true, styleId: style.id, name: style.name };
40+
return result;
41+
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { CollectionResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
export const createCreateVariableCollectionHandler =
6+
(figmaCtx: typeof figma): SandboxToolHandler =>
7+
// eslint-disable-next-line @typescript-eslint/require-await
8+
async params => {
9+
const p = (params ?? {}) as { name?: unknown };
10+
if (typeof p.name !== 'string') {
11+
throw new TypeError('create_variable_collection: name must be a string');
12+
}
13+
14+
const collection = figmaCtx.variables.createVariableCollection(p.name);
15+
16+
const result: CollectionResult = {
17+
ok: true,
18+
collectionId: collection.id,
19+
defaultModeId: collection.defaultModeId,
20+
name: collection.name,
21+
};
22+
return result;
23+
};

0 commit comments

Comments
 (0)