Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ If you want to know more about how it works, read the [How it works](#how-it-wor
| `set_text_properties` | Patch font, size, alignment, auto-resize, color, and bounds on a text node |
| `set_node_properties` | Patch common node properties: name, position, size, visibility, opacity, corner radius |
| `set_solid_fill` | Replace a node's fill or stroke with a single solid paint |
| `set_solid_fills` | Replace fills or strokes on many nodes in a single round-trip |
| `set_gradient_fill` | Replace a node's fill or stroke with a linear/radial/angular/diamond gradient |
| `set_effects` | Replace a node's effects list (drop/inner shadows, layer/background blurs) |
| `set_stroke_properties` | Patch stroke weight, align, dash pattern, cap, and join |
Expand Down Expand Up @@ -118,6 +119,7 @@ All tools accept an optional `fileKey` parameter when multiple Figma files are c

With the current write surface, an agent can build a basic slide deck in a new empty Figma file: create slide frames, style titles and body copy, lay out rectangles/ellipses/lines for cards and dividers, duplicate slide templates, reparent content into the right frame, and adjust common geometry/visual properties — including solid/gradient paints, shadows and blurs, stroke geometry, and auto-layout configuration.


The current version is intentionally limited — no components/instances, no variables/styles authoring, no per-segment text styling, and no vector boolean operations yet.

## Local development
Expand Down
63 changes: 63 additions & 0 deletions plugin/src/main/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type RequestType =
| "set_text_properties"
| "set_node_properties"
| "set_solid_fill"
| "set_solid_fills"
| "set_gradient_fill"
| "set_effects"
| "set_stroke_properties"
Expand Down Expand Up @@ -221,6 +222,13 @@ const setSolidFill = (
(node as GeometryMixin & { fills: ReadonlyArray<Paint> }).fills = [paint];
};








type GradientStopInput = { position: number; hex: string; opacity?: number };
type GradientPaintType =
| "GRADIENT_LINEAR"
Expand Down Expand Up @@ -350,6 +358,7 @@ const EDIT_REQUEST_TYPES = new Set<RequestType>([
"set_text_properties",
"set_node_properties",
"set_solid_fill",
"set_solid_fills",
"set_gradient_fill",
"set_effects",
"set_stroke_properties",
Expand Down Expand Up @@ -933,6 +942,60 @@ const handleRequest = async (
},
};
}
case "set_solid_fills": {
const rawItems = request.params?.items;
if (!Array.isArray(rawItems) || rawItems.length === 0) {
throw new Error("items is required for set_solid_fills");
}
const items = rawItems as Array<Record<string, unknown>>;
const results: Array<
| { nodeId: string; target: "fill" | "stroke" }
| { nodeId: string | null; error: string }
> = [];

// Sequential on purpose: these all mutate the document, and the
// round-trip being saved is the WebSocket one, not the local work.
for (const item of items) {
const nodeId = typeof item.nodeId === "string" ? item.nodeId : null;
try {
if (!nodeId) {
throw new Error("nodeId is required");
}
// fillHex/fillOpacity are accepted here too, so a caller can lift a
// set_solid_fill call into items without renaming its fields (#39).
const hex = typeof item.hex === "string" ? item.hex : item.fillHex;
if (typeof hex !== "string") {
throw new Error(
"hex is required (fillHex is accepted as an alias)"
);
}
const rawOpacity =
typeof item.opacity === "number"
? item.opacity
: item.fillOpacity;
const node = await getSceneNodeById(nodeId);
const target = item.target === "stroke" ? "stroke" : "fill";
setSolidFill(
node,
hex,
typeof rawOpacity === "number" ? rawOpacity : undefined,
target
);
results.push({ nodeId, target });
} catch (error) {
results.push({
nodeId,
error: error instanceof Error ? error.message : String(error),
});
}
}

return {
type: request.type,
requestId: request.requestId,
data: { results },
};
}
case "set_gradient_fill": {
const nodeId = request.nodeIds && request.nodeIds[0];
if (!nodeId) {
Expand Down
57 changes: 57 additions & 0 deletions server/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,47 @@ export const setSolidFillInput = setSolidFillShape.transform(
}
);

/**
* One node's worth of work for `set_solid_fills`. Mirrors `set_solid_fill`
* field for field, including the fillHex/fillOpacity aliases, so a caller can
* lift a single-node call straight into `items` without renaming anything.
*/
const createSolidFillItemSchema = () =>
z
.object({
nodeId: createFigmaNodeIdSchema().describe("The node ID to update"),
hex: createHexColorSchema()
.optional()
.describe("Solid color as hex (e.g. '#FFAA00')"),
fillHex: createHexColorSchema().optional().describe("Alias for hex"),
opacity: z
.number()
.min(0)
.max(1)
.optional()
.describe("Optional paint opacity from 0 to 1 (default 1)"),
fillOpacity: z
.number()
.min(0)
.max(1)
.optional()
.describe("Alias for opacity"),
target: solidFillTarget,
})
.refine((item) => item.hex !== undefined || item.fillHex !== undefined, {
message: "hex is required (fillHex is accepted as an alias)",
});

export const setSolidFillsInput = z.object({
items: z
.array(createSolidFillItemSchema())
.min(1)
.describe(
"Fill/stroke updates, one entry per node, applied in a single round-trip"
),
fileKey: fileKeyField,
});

const blendMode = z.enum([
"PASS_THROUGH",
"NORMAL",
Expand Down Expand Up @@ -720,6 +761,21 @@ export const toolInputSchemas = {

set_solid_fill: setSolidFillInput,

set_solid_fills: setSolidFillsInput,














set_effects: setEffectsInput,

set_stroke_properties: setStrokePropertiesInput.refine(
Expand Down Expand Up @@ -959,6 +1015,7 @@ const rpcToArgs: Record<
}),
set_gradient_fill: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }),
set_solid_fill: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }),
set_solid_fills: (_nodeIds, params) => ({ ...params }),
set_effects: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }),
set_stroke_properties: (nodeIds, params) => ({
...params,
Expand Down
14 changes: 14 additions & 0 deletions server/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,20 @@ export function registerTools(
}
);

server.tool(
"set_solid_fills",
"Replace the fill (or stroke) of many nodes with solid paints in a single round-trip. Each item takes the same fields as set_solid_fill. Items are independent: a bad nodeId fails only its own entry and the rest still apply. When multiple files are connected, specify fileKey.",
toolInputSchemas.set_solid_fills.shape,
async (args): Promise<ToolResult> => {
const parsed = parseToolInput(toolInputSchemas.set_solid_fills, args);
if (!parsed.success) return parsed.error;
const { items, fileKey } = parsed.data;
return renderResponse(() =>
node.sendWithParams("set_solid_fills", undefined, { items }, fileKey)
);
}
);

server.tool(
"set_gradient_fill",
"Replace a node's fill (or stroke) with a gradient paint. Provide ordered stops (position 0..1, hex color, optional alpha) and an optional 2x3 gradientTransform matching Figma's gradientTransform format. Useful for setting linear/radial/angular/diamond gradients programmatically.",
Expand Down