figma-console-mcp 1.45.0 → 1.46.0

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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Figma Shader tools (Plugin API, beta: Update 130+).
3
+ *
4
+ * Shaders are created in Figma (UI, or Figma's own MCP). The Plugin API can
5
+ * apply them by id and set their properties. Applied shaders carry
6
+ * `propertyMetadata` (author-defined names and types per property key), so
7
+ * these tools let callers set properties by name ("turbulence") instead of
8
+ * the opaque uniform keys ("3561834497:1861196824").
9
+ *
10
+ * Both tools build a Plugin API script and run it through executeCodeViaUI,
11
+ * so no Desktop Bridge plugin change (or re-import) is needed.
12
+ */
13
+ import { z } from "zod";
14
+ import { createChildLogger } from "./logger.js";
15
+ const logger = createChildLogger({ component: "shader-tools" });
16
+ const fileKeySchema = z
17
+ .string()
18
+ .optional()
19
+ .describe("Local Mode only. Run against this specific connected file instead of the active file. Get connected fileKeys from figma_list_open_files. Rejected in Cloud Mode, which pairs with a single plugin instance.");
20
+ export const SHADER_TARGETS = ["fills", "strokes", "effects"];
21
+ export const LIST_SHADERS_BODY = `
22
+ const results = new Map();
23
+ const limitUses = 20;
24
+ const describe = (meta) => Object.entries(meta || {}).map(([key, d]) => ({ key, name: d.name, type: d.type, defaultValue: d.defaultValue, description: d.description }));
25
+ const scan = (node, page) => {
26
+ for (const target of ['fills', 'strokes', 'effects']) {
27
+ const arr = node[target];
28
+ if (!Array.isArray(arr)) continue;
29
+ arr.forEach((p, index) => {
30
+ if (!p || p.type !== 'SHADER') return;
31
+ let entry = results.get(p.id);
32
+ if (!entry) {
33
+ entry = { id: p.id, kind: target === 'effects' ? 'effect' : 'fill', usageCount: 0, usedIn: [], properties: describe(p.propertyMetadata) };
34
+ results.set(p.id, entry);
35
+ }
36
+ entry.usageCount++;
37
+ if (entry.usedIn.length < limitUses) entry.usedIn.push({ nodeId: node.id, name: node.name, page: page.name, target, index });
38
+ });
39
+ }
40
+ };
41
+ let pages;
42
+ if (P.nodeId) {
43
+ const root = await figma.getNodeByIdAsync(P.nodeId);
44
+ if (!root) throw new Error('Node not found: ' + P.nodeId);
45
+ let page = root; while (page && page.type !== 'PAGE') page = page.parent;
46
+ scan(root, page || figma.currentPage);
47
+ if ('findAll' in root) for (const n of root.findAll(() => true)) scan(n, page || figma.currentPage);
48
+ } else {
49
+ pages = P.scope === 'file' ? figma.root.children : [figma.currentPage];
50
+ for (const page of pages) {
51
+ if (page !== figma.currentPage) await page.loadAsync();
52
+ for (const n of page.findAll(() => true)) scan(n, page);
53
+ }
54
+ }
55
+ let available = [];
56
+ let availableNote;
57
+ try {
58
+ const list = await Promise.race([figma.listAvailableShaders(), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))]);
59
+ available = list.map(s => ({ id: s.id, name: s.name, kind: s.type, imported: s.imported, inFile: results.has(s.id), properties: describe(s.propertyDefinitions) }));
60
+ } catch (e) {
61
+ availableNote = e && e.message === 'timeout'
62
+ ? "Figma didn't return the library shader list in time. This happens when the file's window isn't the focused one."
63
+ : 'Could not list library shaders: ' + (e && e.message ? e.message : e);
64
+ }
65
+ const out = { scope: P.nodeId ? 'node' : P.scope, inFile: Array.from(results.values()), available };
66
+ if (availableNote) out.availableNote = availableNote;
67
+ if (out.inFile.length > 0) out.note = 'Figma drops a shader from the file when nothing uses it and the file reloads. Keep at least one layer using each shader you want to reuse.';
68
+ return out;
69
+ `;
70
+ export const APPLY_SHADER_BODY = `
71
+ const node = await figma.getNodeByIdAsync(P.nodeId);
72
+ if (!node) throw new Error('Node not found: ' + P.nodeId);
73
+ const target = P.target;
74
+ if (!(target in node) || !Array.isArray(node[target])) throw new Error(node.type + ' nodes have no ' + target);
75
+ const original = node[target].map(p => JSON.parse(JSON.stringify(p)));
76
+ const clone = (v) => JSON.parse(JSON.stringify(v));
77
+
78
+ let index;
79
+ let paint;
80
+ if (P.index !== undefined) {
81
+ const existing = original[P.index];
82
+ if (!existing || existing.type !== 'SHADER') throw new Error(target + '[' + P.index + '] is not a shader. Omit index to add a new shader.');
83
+ index = P.index;
84
+ paint = clone(existing);
85
+ if (P.shaderId && P.shaderId !== existing.id) { paint = { type: 'SHADER', id: P.shaderId }; }
86
+ } else {
87
+ if (!P.shaderId) throw new Error('Pass shaderId to add a shader, or index to change one already applied.');
88
+ paint = { type: 'SHADER', id: P.shaderId };
89
+ index = P.replace ? 0 : original.length;
90
+ }
91
+ if (target === 'effects') paint.visible = P.visible !== undefined ? P.visible : (paint.visible !== undefined ? paint.visible : true);
92
+ else if (P.visible !== undefined) paint.visible = P.visible;
93
+ if (target !== 'effects') {
94
+ if (P.opacity !== undefined) paint.opacity = P.opacity;
95
+ if (P.blendMode !== undefined) paint.blendMode = P.blendMode;
96
+ }
97
+
98
+ const strip = (p) => { const c = clone(p); delete c.propertyMetadata; return c; };
99
+ const place = (p) => {
100
+ const next = P.replace && P.index === undefined ? [] : original.map(strip);
101
+ next[index] = strip(p);
102
+ return next;
103
+ };
104
+ const write = async (p) => {
105
+ try { node[target] = place(p); }
106
+ catch (e) {
107
+ const msg = e && e.message ? e.message : String(e);
108
+ if (/not imported/i.test(msg)) {
109
+ await figma.importShaderById(p.id);
110
+ node[target] = place(p);
111
+ return;
112
+ }
113
+ if (/Invalid shader id|Expected an id/i.test(msg)) {
114
+ throw new Error(msg + (target === 'effects'
115
+ ? ' Effect shaders and fill shaders are separate kinds: a fill shader goes on fills or strokes.'
116
+ : ' Use an id from figma_list_shaders. A shader nothing in the file uses is dropped when the file reloads.'));
117
+ }
118
+ throw e;
119
+ }
120
+ };
121
+
122
+ // Apply first (properties keep their current or default values) so Figma
123
+ // returns propertyMetadata, then map names to keys and write the changes.
124
+ await write(paint);
125
+ const applied = node[target][index];
126
+ const meta = applied.propertyMetadata || {};
127
+ const props = clone(applied.properties || {});
128
+ const byName = {};
129
+ for (const [key, d] of Object.entries(meta)) byName[d.name] = key;
130
+ const requested = P.properties || {};
131
+ const unknown = [];
132
+ for (const [name, raw] of Object.entries(requested)) {
133
+ const key = meta[name] ? name : byName[name];
134
+ if (!key) { unknown.push(name); continue; }
135
+ const type = meta[key].type;
136
+ let value = raw;
137
+ if (value && typeof value === 'object' && value.variableId) value = { type: 'VARIABLE_ALIAS', id: value.variableId };
138
+ else if (type === 'COLOR' && typeof value === 'string') value = figma.util.rgba(value);
139
+ else if (type === 'COLOR' && value && typeof value === 'object' && value.a === undefined && 'r' in value) value = { r: value.r, g: value.g, b: value.b, a: 1 };
140
+ props[key] = value;
141
+ }
142
+ if (unknown.length) {
143
+ node[target] = original.map(strip);
144
+ throw new Error('Unknown shader propert' + (unknown.length > 1 ? 'ies' : 'y') + ': ' + unknown.join(', ') + '. This shader has: ' + Object.values(meta).map(d => d.name + ' (' + d.type + ')').join(', ') + '. Nothing was changed.');
145
+ }
146
+ if (Object.keys(requested).length) {
147
+ try { await write(Object.assign(strip(applied), { properties: props })); }
148
+ catch (e) { node[target] = original.map(strip); throw new Error('Figma rejected the property values: ' + (e && e.message ? e.message : e) + ' Nothing was changed.'); }
149
+ }
150
+ const final = node[target][index];
151
+ const named = {};
152
+ for (const [key, v] of Object.entries(final.properties || {})) named[meta[key] ? meta[key].name : key] = v;
153
+ return {
154
+ nodeId: node.id, name: node.name, target, index, shaderId: final.id,
155
+ properties: named,
156
+ definitions: Object.entries(meta).map(([key, d]) => ({ key, name: d.name, type: d.type, defaultValue: d.defaultValue })),
157
+ };
158
+ `;
159
+ export function shaderScript(payload, body) {
160
+ return `const P = ${JSON.stringify(payload)};\n${body}`;
161
+ }
162
+ function ok(data) {
163
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
164
+ }
165
+ function fail(tool, error) {
166
+ logger.error({ error }, `${tool} failed`);
167
+ return {
168
+ content: [{ type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }) }],
169
+ isError: true,
170
+ };
171
+ }
172
+ async function run(getDesktopConnector, script, timeout, fileKey) {
173
+ const connector = await getDesktopConnector();
174
+ const result = await connector.executeCodeViaUI(script, timeout, fileKey);
175
+ if (!result?.success)
176
+ throw new Error(result?.error || "Shader script failed");
177
+ return result.result;
178
+ }
179
+ const colorObject = { r: z.number(), g: z.number(), b: z.number(), a: z.number().optional() };
180
+ const shaderValueSchema = z.union([
181
+ z.number(),
182
+ z.boolean(),
183
+ z.string(),
184
+ z
185
+ .object({
186
+ variableId: z.string().optional().describe("Bind the property to a variable instead of a literal"),
187
+ x: z.number().optional(),
188
+ y: z.number().optional(),
189
+ x2: z.number().optional(),
190
+ y2: z.number().optional(),
191
+ radius: z.number().optional(),
192
+ angle: z.number().optional(),
193
+ r: z.number().optional(),
194
+ g: z.number().optional(),
195
+ b: z.number().optional(),
196
+ a: z.number().optional(),
197
+ color: z.object(colorObject).optional(),
198
+ stops: z.array(z.object({ position: z.number(), color: z.object(colorObject) })).optional(),
199
+ })
200
+ .describe("Points {x,y}, lines {x,y,x2,y2}, circles {x,y,radius,angle?}, colors {r,g,b,a} (0–1), color points {x,y,color}, gradients {stops}, or {variableId}"),
201
+ ]);
202
+ export function registerShaderTools(server, getDesktopConnector) {
203
+ server.tool("figma_list_shaders", "List shaders: the ones applied in this file (fills, strokes and effects), with each property's name, type and default, and the layers using them; plus shaders from the user's libraries (figma.listAvailableShaders). Use the property names with figma_apply_shader. Shaders are created in Figma, not through the Plugin API. Requires Desktop Bridge.", {
204
+ scope: z.enum(["page", "file"]).optional().default("page").describe("Search the current page (default) or every page"),
205
+ nodeId: z.string().optional().describe("Only search this node and its descendants"),
206
+ fileKey: fileKeySchema,
207
+ }, async ({ scope, nodeId, fileKey }) => {
208
+ try {
209
+ return ok(await run(getDesktopConnector, shaderScript({ scope, nodeId }, LIST_SHADERS_BODY), scope === "file" && !nodeId ? 120000 : 30000, fileKey));
210
+ }
211
+ catch (error) {
212
+ return fail("figma_list_shaders", error);
213
+ }
214
+ });
215
+ server.tool("figma_apply_shader", "Apply a shader to a node's fills, strokes or effects, or change the properties of one already applied. Set properties by name (e.g. {turbulence: 40, cloudColor: '#E8F4FF'}); names, types and defaults come from figma_list_shaders. Colors take hex strings or {r,g,b,a}, and any property can bind a variable with {variableId}. Pass index to change a shader already in that list; without index the shader is added on top (or replaces the list with replace: true). Fill shaders go on fills or strokes; effect shaders go on effects. Unknown property names change nothing and list the valid ones. Requires Desktop Bridge.", {
216
+ nodeId: z.string(),
217
+ shaderId: z.string().optional().describe("Shader id from figma_list_shaders. Optional when index points at a shader already applied"),
218
+ target: z.enum(SHADER_TARGETS).optional().default("fills"),
219
+ index: z.number().int().min(0).optional().describe("Change the shader at this position in the target list instead of adding one"),
220
+ replace: z.boolean().optional().default(false).describe("Replace everything in the target list with this shader"),
221
+ properties: z.record(z.string(), shaderValueSchema).optional().describe("Property values by name (or key)"),
222
+ opacity: z.number().min(0).max(1).optional().describe("Fills and strokes only"),
223
+ blendMode: z
224
+ .enum(["NORMAL", "DARKEN", "MULTIPLY", "COLOR_BURN", "LIGHTEN", "SCREEN", "COLOR_DODGE", "OVERLAY", "SOFT_LIGHT", "HARD_LIGHT", "DIFFERENCE", "EXCLUSION", "HUE", "SATURATION", "COLOR", "LUMINOSITY"])
225
+ .optional()
226
+ .describe("Fills and strokes only"),
227
+ visible: z.boolean().optional(),
228
+ fileKey: fileKeySchema,
229
+ }, async ({ fileKey, ...payload }) => {
230
+ try {
231
+ return ok(await run(getDesktopConnector, shaderScript(payload, APPLY_SHADER_BODY), 30000, fileKey));
232
+ }
233
+ catch (error) {
234
+ return fail("figma_apply_shader", error);
235
+ }
236
+ });
237
+ }
238
+ //# sourceMappingURL=shader-tools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shader-tools.js","sourceRoot":"","sources":["../../src/core/shader-tools.ts"],"names":[],"mappings":"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"}
@@ -36,7 +36,7 @@ const logger = createChildLogger({ component: "tokens-tools" });
36
36
  * on every exported token document. Kept in sync with package.json by
37
37
  * scripts/release.sh — see step 3 of the release flow.
38
38
  */
39
- const MCP_VERSION = "1.45.0";
39
+ const MCP_VERSION = "1.46.0";
40
40
  const EXPORT_TOOL_DESCRIPTION = `Export Figma variables to design token files in your codebase. Bidirectional with figma_import_tokens — together they replace Style Dictionary and Tokens Studio's export pipeline for the popular styling methods.
41
41
 
42
42
  FULLY-IMPLEMENTED OUTPUT FORMATS:
@@ -1,13 +1,25 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  export declare const MOTION_VALUE_DOC = "TIMING values are seconds (a number like 0.3, or '300ms' / '0.3s'). EASING values are 'cubic-bezier(x1, y1, x2, y2)', a Figma easing name (EASE_IN, EASE_OUT, EASE_IN_AND_OUT, LINEAR, EASE_IN_BACK, EASE_OUT_BACK, EASE_IN_AND_OUT_BACK, GENTLE, QUICK, BOUNCY, SLOW, HOLD) or 'spring(bounce)' with bounce 0\u20131.";
3
+ /** A hex color the Desktop Bridge plugin's own fill/stroke handlers parse. */
4
+ export declare const HEX_COLOR_RE: RegExp;
5
+ export declare const CSS_COLOR_DOC = "Hex ('#FF0000', '#FF000080') or any CSS color: rgb(), hsl(), oklch(), oklab(), lab(), lch() or color(display-p3 \u2026). Fully opaque wide-gamut colors keep their source color space in Figma.";
6
+ /**
7
+ * Plugin script for fills/strokes given as CSS colors. Hex-only calls keep
8
+ * going through the plugin's SET_NODE_FILLS / SET_NODE_STROKES handlers;
9
+ * anything else is parsed by figma.util.solidPaint, which keeps OKLCH / P3
10
+ * source colors. Variable bindings work the same way as the hex path.
11
+ */
12
+ export declare const CSS_PAINT_BODY = "\nconst node = await figma.getNodeByIdAsync(P.nodeId);\nif (!node) throw new Error('Node not found: ' + P.nodeId);\nif (!(P.target in node)) throw new Error('Node type ' + node.type + ' does not support ' + P.target);\nconst paints = [];\nconst requested = [];\nfor (const item of P.paints) {\n let paint;\n try { paint = figma.util.solidPaint(item.color || '#000000'); requested.push(paint.color.sourceColor ? paint.color.sourceColor.space : 'SRGB'); }\n catch (e) { throw new Error('Could not read color ' + JSON.stringify(item.color) + '. Use hex or a CSS color such as oklch(60% 0.2 250) or color(display-p3 1 0 0).'); }\n if (paint.opacity === 1 && item.opacity !== undefined) paint = Object.assign({}, paint, { opacity: item.opacity });\n if (item.variableId) {\n const v = await figma.variables.getVariableByIdAsync(item.variableId);\n if (!v) throw new Error('Variable not found: \"' + item.variableId + '\". Pass a local variable id from figma_get_variables (e.g. \"VariableID:1:23\"). Library variables must be imported first via figma_import_library_variable.');\n paint = figma.variables.setBoundVariableForPaint(paint, 'color', v);\n }\n paints.push(paint);\n}\nnode[P.target] = paints;\nif (P.strokeWeight !== undefined) node.strokeWeight = P.strokeWeight;\nconst spaces = node[P.target].map(p => (p.color && p.color.sourceColor ? p.color.sourceColor.space : 'SRGB'));\nconst out = { node: { id: node.id, name: node.name }, colorSpaces: spaces };\nconst profile = figma.root.documentColorProfile;\nif (profile) out.documentColorProfile = profile;\nconst notes = [];\nconst dropped = requested.map((s, i) => (s !== 'SRGB' && spaces[i] === 'SRGB' && !P.paints[i].variableId ? i : -1)).filter(i => i >= 0);\nif (dropped.length) notes.push('Figma keeps an OKLCH or Display P3 source color only on fully opaque paints, so ' + P.target + ' ' + dropped.join(', ') + ' (below 100% opacity) ' + (dropped.length > 1 ? 'are' : 'is') + ' stored as sRGB.');\nif (profile === 'SRGB' && spaces.some(s => s !== 'SRGB')) notes.push('This file uses the sRGB color profile, so wide-gamut colors display clamped to sRGB. The source color is kept and shows in full in a Display P3 file.');\nif (notes.length) out.note = notes.join(' ');\nreturn out;\n";
13
+ /**
14
+ * Plugin script for the text options that need the Plugin API directly:
15
+ * text wrap style and variable-font axes, plus font size/family/style when
16
+ * figma_set_text is called without new text.
17
+ */
18
+ export declare const TEXT_STYLE_BODY = "\nconst node = await figma.getNodeByIdAsync(P.nodeId);\nif (!node) throw new Error('Node not found: ' + P.nodeId);\nif (node.type !== 'TEXT') throw new Error('Node ' + P.nodeId + ' is a ' + node.type + ', not a TEXT node');\nconst len = node.characters.length;\nfor (const f of (len ? node.getRangeAllFontNames(0, len) : [node.fontName])) { if (f !== figma.mixed) await figma.loadFontAsync(f); }\nconst current = node.fontName !== figma.mixed ? node.fontName : null;\nconst family = P.fontFamily || (current && current.family);\nconst style = P.fontStyle ? P.fontStyle.replace(/^(Semi|Extra|Ultra|Demi)(Bold|Light)$/i, '$1 $2') : undefined;\nif (P.fontVariations) {\n if (!family) throw new Error('This text uses several fonts. Pass fontFamily to set font variations.');\n const axes = figma.getFontFamilyVariationAxes(family);\n if (!axes) throw new Error(family + ' is not a variable font, so it has no variation axes.');\n const unknown = Object.keys(P.fontVariations).filter(a => !axes.includes(a));\n if (unknown.length) throw new Error(family + ' has no ' + unknown.join(', ') + ' axis. Its axes are: ' + axes.join(', ') + '.');\n const font = { family, variationSettings: P.fontVariations };\n if (style) font.style = style;\n await figma.loadFontAsync(font);\n node.fontName = font;\n} else if (P.fontFamily || style) {\n if (!family) throw new Error('This text uses several fonts. Pass fontFamily too.');\n const font = { family, style: style || (current && current.family === family ? current.style : 'Regular') };\n await figma.loadFontAsync(font);\n node.fontName = font;\n}\nif (P.fontSize !== undefined) node.fontSize = P.fontSize;\nif (P.textWrapStyle !== undefined) node.textWrapStyle = P.textWrapStyle;\nconst fn = node.fontName !== figma.mixed ? node.fontName : 'mixed';\nreturn { node: { id: node.id, name: node.name }, fontName: fn, fontSize: node.fontSize !== figma.mixed ? node.fontSize : 'mixed', textWrapStyle: node.textWrapStyle };\n";
3
19
  /**
4
20
  * Plugin-side converter for motion variable values (see MOTION_VALUE_DOC).
5
21
  * Non-motion types pass through unchanged. Inlined into variable scripts.
6
22
  */
7
23
  export declare const MOTION_VALUE_JS = "\nfunction motionValue(type, value) {\n if (type === 'TIMING') {\n if (typeof value === 'number') return value;\n const m = String(value).trim().match(/^(\\d*\\.?\\d+)\\s*(ms|s)?$/i);\n if (!m) throw new Error('TIMING value ' + JSON.stringify(value) + ' is not a duration (use seconds, \"300ms\" or \"0.3s\")');\n return m[2] && m[2].toLowerCase() === 'ms' ? Number(m[1]) / 1000 : Number(m[1]);\n }\n if (type === 'EASING') {\n if (value && typeof value === 'object') return value;\n const str = String(value).trim();\n const bez = str.match(/^cubic-bezier\\(([^)]*)\\)$/i);\n if (bez) {\n const n = bez[1].split(',').map(Number);\n if (n.length !== 4 || n.some(isNaN)) throw new Error('Invalid cubic-bezier: ' + str);\n return { type: 'CUSTOM_CUBIC_BEZIER', easingFunctionCubicBezier: { x1: n[0], y1: n[1], x2: n[2], y2: n[3] } };\n }\n const spring = str.match(/^spring\\(\\s*(\\d*\\.?\\d+)\\s*\\)$/i);\n if (spring) return { type: 'CUSTOM_SPRING', easingFunctionSpring: { bounce: Number(spring[1]) } };\n const named = ['EASE_IN','EASE_OUT','EASE_IN_AND_OUT','LINEAR','EASE_IN_BACK','EASE_OUT_BACK','EASE_IN_AND_OUT_BACK','GENTLE','QUICK','BOUNCY','SLOW','HOLD'];\n if (named.indexOf(str.toUpperCase()) !== -1) return { type: str.toUpperCase() };\n throw new Error('EASING value ' + JSON.stringify(value) + ' is not cubic-bezier(...), spring(bounce) or a Figma easing name');\n }\n return value;\n}\n";
8
- /**
9
- * Register write/manipulation tools that require a Desktop Bridge connector.
10
- * Used by both local mode (src/local.ts) and cloud mode (src/index.ts).
11
- */
12
24
  export declare function registerWriteTools(server: McpServer, getDesktopConnector: () => Promise<any>): void;
13
25
  //# sourceMappingURL=write-tools.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"write-tools.d.ts","sourceRoot":"","sources":["../../src/core/write-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAMpE,eAAO,MAAM,gBAAgB,2TACuR,CAAC;AAErT;;;GAGG;AACH,eAAO,MAAM,eAAe,87CAyB3B,CAAC;AAEF;;;GAGG;AACH,wBAAgB,kBAAkB,CACjC,MAAM,EAAE,SAAS,EACjB,mBAAmB,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,QA+mGvC"}
1
+ {"version":3,"file":"write-tools.d.ts","sourceRoot":"","sources":["../../src/core/write-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAMpE,eAAO,MAAM,gBAAgB,2TACuR,CAAC;AAErT,8EAA8E;AAC9E,eAAO,MAAM,YAAY,QAAiD,CAAC;AAE3E,eAAO,MAAM,aAAa,oMACmK,CAAC;AAE9L;;;;;GAKG;AACH,eAAO,MAAM,cAAc,ytEA8B1B,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,eAAe,w7DA6B3B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,87CAyB3B,CAAC;AAkBF,wBAAgB,kBAAkB,CACjC,MAAM,EAAE,SAAS,EACjB,mBAAmB,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,QA4oGvC"}
@@ -2,6 +2,81 @@ import { z } from "zod";
2
2
  import { createChildLogger } from "./logger.js";
3
3
  const logger = createChildLogger({ component: "write-tools" });
4
4
  export const MOTION_VALUE_DOC = "TIMING values are seconds (a number like 0.3, or '300ms' / '0.3s'). EASING values are 'cubic-bezier(x1, y1, x2, y2)', a Figma easing name (EASE_IN, EASE_OUT, EASE_IN_AND_OUT, LINEAR, EASE_IN_BACK, EASE_OUT_BACK, EASE_IN_AND_OUT_BACK, GENTLE, QUICK, BOUNCY, SLOW, HOLD) or 'spring(bounce)' with bounce 0–1.";
5
+ /** A hex color the Desktop Bridge plugin's own fill/stroke handlers parse. */
6
+ export const HEX_COLOR_RE = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
7
+ export const CSS_COLOR_DOC = "Hex ('#FF0000', '#FF000080') or any CSS color: rgb(), hsl(), oklch(), oklab(), lab(), lch() or color(display-p3 …). Fully opaque wide-gamut colors keep their source color space in Figma.";
8
+ /**
9
+ * Plugin script for fills/strokes given as CSS colors. Hex-only calls keep
10
+ * going through the plugin's SET_NODE_FILLS / SET_NODE_STROKES handlers;
11
+ * anything else is parsed by figma.util.solidPaint, which keeps OKLCH / P3
12
+ * source colors. Variable bindings work the same way as the hex path.
13
+ */
14
+ export const CSS_PAINT_BODY = `
15
+ const node = await figma.getNodeByIdAsync(P.nodeId);
16
+ if (!node) throw new Error('Node not found: ' + P.nodeId);
17
+ if (!(P.target in node)) throw new Error('Node type ' + node.type + ' does not support ' + P.target);
18
+ const paints = [];
19
+ const requested = [];
20
+ for (const item of P.paints) {
21
+ let paint;
22
+ try { paint = figma.util.solidPaint(item.color || '#000000'); requested.push(paint.color.sourceColor ? paint.color.sourceColor.space : 'SRGB'); }
23
+ catch (e) { throw new Error('Could not read color ' + JSON.stringify(item.color) + '. Use hex or a CSS color such as oklch(60% 0.2 250) or color(display-p3 1 0 0).'); }
24
+ if (paint.opacity === 1 && item.opacity !== undefined) paint = Object.assign({}, paint, { opacity: item.opacity });
25
+ if (item.variableId) {
26
+ const v = await figma.variables.getVariableByIdAsync(item.variableId);
27
+ if (!v) throw new Error('Variable not found: "' + item.variableId + '". Pass a local variable id from figma_get_variables (e.g. "VariableID:1:23"). Library variables must be imported first via figma_import_library_variable.');
28
+ paint = figma.variables.setBoundVariableForPaint(paint, 'color', v);
29
+ }
30
+ paints.push(paint);
31
+ }
32
+ node[P.target] = paints;
33
+ if (P.strokeWeight !== undefined) node.strokeWeight = P.strokeWeight;
34
+ const spaces = node[P.target].map(p => (p.color && p.color.sourceColor ? p.color.sourceColor.space : 'SRGB'));
35
+ const out = { node: { id: node.id, name: node.name }, colorSpaces: spaces };
36
+ const profile = figma.root.documentColorProfile;
37
+ if (profile) out.documentColorProfile = profile;
38
+ const notes = [];
39
+ const dropped = requested.map((s, i) => (s !== 'SRGB' && spaces[i] === 'SRGB' && !P.paints[i].variableId ? i : -1)).filter(i => i >= 0);
40
+ if (dropped.length) notes.push('Figma keeps an OKLCH or Display P3 source color only on fully opaque paints, so ' + P.target + ' ' + dropped.join(', ') + ' (below 100% opacity) ' + (dropped.length > 1 ? 'are' : 'is') + ' stored as sRGB.');
41
+ if (profile === 'SRGB' && spaces.some(s => s !== 'SRGB')) notes.push('This file uses the sRGB color profile, so wide-gamut colors display clamped to sRGB. The source color is kept and shows in full in a Display P3 file.');
42
+ if (notes.length) out.note = notes.join(' ');
43
+ return out;
44
+ `;
45
+ /**
46
+ * Plugin script for the text options that need the Plugin API directly:
47
+ * text wrap style and variable-font axes, plus font size/family/style when
48
+ * figma_set_text is called without new text.
49
+ */
50
+ export const TEXT_STYLE_BODY = `
51
+ const node = await figma.getNodeByIdAsync(P.nodeId);
52
+ if (!node) throw new Error('Node not found: ' + P.nodeId);
53
+ if (node.type !== 'TEXT') throw new Error('Node ' + P.nodeId + ' is a ' + node.type + ', not a TEXT node');
54
+ const len = node.characters.length;
55
+ for (const f of (len ? node.getRangeAllFontNames(0, len) : [node.fontName])) { if (f !== figma.mixed) await figma.loadFontAsync(f); }
56
+ const current = node.fontName !== figma.mixed ? node.fontName : null;
57
+ const family = P.fontFamily || (current && current.family);
58
+ const style = P.fontStyle ? P.fontStyle.replace(/^(Semi|Extra|Ultra|Demi)(Bold|Light)$/i, '$1 $2') : undefined;
59
+ if (P.fontVariations) {
60
+ if (!family) throw new Error('This text uses several fonts. Pass fontFamily to set font variations.');
61
+ const axes = figma.getFontFamilyVariationAxes(family);
62
+ if (!axes) throw new Error(family + ' is not a variable font, so it has no variation axes.');
63
+ const unknown = Object.keys(P.fontVariations).filter(a => !axes.includes(a));
64
+ if (unknown.length) throw new Error(family + ' has no ' + unknown.join(', ') + ' axis. Its axes are: ' + axes.join(', ') + '.');
65
+ const font = { family, variationSettings: P.fontVariations };
66
+ if (style) font.style = style;
67
+ await figma.loadFontAsync(font);
68
+ node.fontName = font;
69
+ } else if (P.fontFamily || style) {
70
+ if (!family) throw new Error('This text uses several fonts. Pass fontFamily too.');
71
+ const font = { family, style: style || (current && current.family === family ? current.style : 'Regular') };
72
+ await figma.loadFontAsync(font);
73
+ node.fontName = font;
74
+ }
75
+ if (P.fontSize !== undefined) node.fontSize = P.fontSize;
76
+ if (P.textWrapStyle !== undefined) node.textWrapStyle = P.textWrapStyle;
77
+ const fn = node.fontName !== figma.mixed ? node.fontName : 'mixed';
78
+ return { node: { id: node.id, name: node.name }, fontName: fn, fontSize: node.fontSize !== figma.mixed ? node.fontSize : 'mixed', textWrapStyle: node.textWrapStyle };
79
+ `;
5
80
  /**
6
81
  * Plugin-side converter for motion variable values (see MOTION_VALUE_DOC).
7
82
  * Non-motion types pass through unchanged. Inlined into variable scripts.
@@ -36,6 +111,14 @@ function motionValue(type, value) {
36
111
  * Register write/manipulation tools that require a Desktop Bridge connector.
37
112
  * Used by both local mode (src/local.ts) and cloud mode (src/index.ts).
38
113
  */
114
+ async function setCssPaints(connector, payload, message) {
115
+ const result = await connector.executeCodeViaUI(`const P = ${JSON.stringify(payload)};\n${CSS_PAINT_BODY}`, 15000);
116
+ if (!result?.success)
117
+ throw new Error(result?.error || `Failed to set ${payload.target}`);
118
+ return {
119
+ content: [{ type: "text", text: JSON.stringify({ success: true, message, ...result.result }) }],
120
+ };
121
+ }
39
122
  export function registerWriteTools(server, getDesktopConnector) {
40
123
  // ============================================================================
41
124
  // EXECUTION TOOL
@@ -1410,7 +1493,7 @@ After instantiating components, use figma_take_screenshot to verify the result l
1410
1493
  }
1411
1494
  });
1412
1495
  // Tool: Set Node Fills
1413
- server.tool("figma_set_fills", "Set the fill colors on a node. Accepts hex color strings (e.g., '#FF0000'). To bind a fill to a design token / color variable, pass that fill's variableId — the variable drives the color and this works on any Figma plan via the bridge (no raw figma_execute needed).", {
1496
+ server.tool("figma_set_fills", "Set the fill colors on a node. Accepts hex or CSS colors, including oklch() and color(display-p3 …). To bind a fill to a design token / color variable, pass that fill's variableId — the variable drives the color and this works on any Figma plan via the bridge (no raw figma_execute needed).", {
1414
1497
  nodeId: z.string().describe("The node ID to modify"),
1415
1498
  fills: z
1416
1499
  .array(z.object({
@@ -1420,7 +1503,7 @@ After instantiating components, use figma_take_screenshot to verify the result l
1420
1503
  color: z
1421
1504
  .string()
1422
1505
  .optional()
1423
- .describe("Hex color string (e.g., '#FF0000', '#FF000080' for transparency). Optional when variableId is provided."),
1506
+ .describe(`${CSS_COLOR_DOC} Optional when variableId is provided.`),
1424
1507
  opacity: z
1425
1508
  .number()
1426
1509
  .optional()
@@ -1434,6 +1517,9 @@ After instantiating components, use figma_take_screenshot to verify the result l
1434
1517
  }, async ({ nodeId, fills }) => {
1435
1518
  try {
1436
1519
  const connector = await getDesktopConnector();
1520
+ if (fills.some((f) => f.color !== undefined && !HEX_COLOR_RE.test(f.color.trim()))) {
1521
+ return await setCssPaints(connector, { nodeId, target: "fills", paints: fills }, "Fills updated");
1522
+ }
1437
1523
  const result = await connector.setNodeFills(nodeId, fills);
1438
1524
  if (!result.success) {
1439
1525
  throw new Error(result.error || "Failed to set fills");
@@ -1510,7 +1596,7 @@ After instantiating components, use figma_take_screenshot to verify the result l
1510
1596
  }
1511
1597
  });
1512
1598
  // Tool: Set Node Strokes
1513
- server.tool("figma_set_strokes", "Set the stroke (border) on a node. Accepts hex color strings and optional stroke weight. To bind a stroke to a design token / color variable, pass that stroke's variableId — works on any Figma plan via the bridge.", {
1599
+ server.tool("figma_set_strokes", "Set the stroke (border) on a node. Accepts hex or CSS colors (including oklch() and color(display-p3 …)) and an optional stroke weight. To bind a stroke to a design token / color variable, pass that stroke's variableId — works on any Figma plan via the bridge.", {
1514
1600
  nodeId: z.string().describe("The node ID to modify"),
1515
1601
  strokes: z
1516
1602
  .array(z.object({
@@ -1518,7 +1604,7 @@ After instantiating components, use figma_take_screenshot to verify the result l
1518
1604
  color: z
1519
1605
  .string()
1520
1606
  .optional()
1521
- .describe("Hex color string. Optional when variableId is provided."),
1607
+ .describe(`${CSS_COLOR_DOC} Optional when variableId is provided.`),
1522
1608
  opacity: z.number().optional().describe("Opacity 0-1"),
1523
1609
  variableId: z
1524
1610
  .string()
@@ -1533,6 +1619,9 @@ After instantiating components, use figma_take_screenshot to verify the result l
1533
1619
  }, async ({ nodeId, strokes, strokeWeight }) => {
1534
1620
  try {
1535
1621
  const connector = await getDesktopConnector();
1622
+ if (strokes.some((f) => f.color !== undefined && !HEX_COLOR_RE.test(f.color.trim()))) {
1623
+ return await setCssPaints(connector, { nodeId, target: "strokes", paints: strokes, strokeWeight }, "Strokes updated");
1624
+ }
1536
1625
  const result = await connector.setNodeStrokes(nodeId, strokes, strokeWeight);
1537
1626
  if (!result.success) {
1538
1627
  throw new Error(result.error || "Failed to set strokes");
@@ -1681,9 +1770,9 @@ After instantiating components, use figma_take_screenshot to verify the result l
1681
1770
  }
1682
1771
  });
1683
1772
  // Tool: Set Text Content
1684
- server.tool("figma_set_text", "Set the text content of a text node. Optionally adjust font size and the font family/style. Font style names are space-sensitive ('Semi Bold', not 'SemiBold'), but this tool auto-corrects common no-space variants and falls back gracefully — so you don't need raw figma_execute to change typography.", {
1773
+ server.tool("figma_set_text", "Set the text content of a text node. Optionally adjust font size and the font family/style, the text wrap style (BALANCE or PRETTY, like CSS text-wrap), and variable-font axes (fontVariations, e.g. {wght: 550}). Font style names are space-sensitive ('Semi Bold', not 'SemiBold'), but this tool auto-corrects common no-space variants and falls back gracefully — so you don't need raw figma_execute to change typography. text can be left out to change only the styling.", {
1685
1774
  nodeId: z.string().describe("The text node ID"),
1686
- text: z.string().describe("The new text content"),
1775
+ text: z.string().optional().describe("The new text content. Leave out to change only the styling"),
1687
1776
  fontSize: z.number().optional().describe("Optional font size to set"),
1688
1777
  fontFamily: z
1689
1778
  .string()
@@ -1693,14 +1782,44 @@ After instantiating components, use figma_take_screenshot to verify the result l
1693
1782
  .string()
1694
1783
  .optional()
1695
1784
  .describe("Optional font style/weight to apply (e.g., 'Bold', 'Semi Bold'). No-space variants like 'SemiBold' are auto-corrected."),
1696
- }, async ({ nodeId, text, fontSize, fontFamily, fontStyle }) => {
1785
+ textWrapStyle: z
1786
+ .enum(["AUTO", "BALANCE", "PRETTY"])
1787
+ .optional()
1788
+ .describe("How lines break: AUTO (default), BALANCE (even line lengths, for headings) or PRETTY (avoids a lone last word)"),
1789
+ fontVariations: z
1790
+ .record(z.string(), z.number())
1791
+ .optional()
1792
+ .describe("Variable-font axis values by OpenType tag, e.g. {wght: 550, slnt: -5}. Axes you leave out keep the named style's values"),
1793
+ }, async ({ nodeId, text, fontSize, fontFamily, fontStyle, textWrapStyle, fontVariations }) => {
1697
1794
  try {
1698
1795
  const connector = await getDesktopConnector();
1699
- const result = await connector.setTextContent(nodeId, text, fontSize || fontFamily || fontStyle
1700
- ? { fontSize, fontFamily, fontStyle }
1701
- : undefined);
1702
- if (!result.success) {
1703
- throw new Error(result.error || "Failed to set text");
1796
+ const styleOnly = text === undefined;
1797
+ if (styleOnly && fontSize === undefined && !fontFamily && !fontStyle && !textWrapStyle && !fontVariations) {
1798
+ throw new Error("Nothing to change: pass text, or a font or wrap option");
1799
+ }
1800
+ let node;
1801
+ if (!styleOnly) {
1802
+ // Variable-font axes are set by the follow-up script, so the
1803
+ // family and style go there too when fontVariations is given.
1804
+ const fontOpts = fontVariations
1805
+ ? fontSize !== undefined ? { fontSize } : undefined
1806
+ : fontSize || fontFamily || fontStyle ? { fontSize, fontFamily, fontStyle } : undefined;
1807
+ const result = await connector.setTextContent(nodeId, text, fontOpts);
1808
+ if (!result.success) {
1809
+ throw new Error(result.error || "Failed to set text");
1810
+ }
1811
+ node = result.node;
1812
+ }
1813
+ let styling;
1814
+ if (styleOnly || textWrapStyle || fontVariations) {
1815
+ const payload = styleOnly
1816
+ ? { nodeId, fontSize, fontFamily, fontStyle, textWrapStyle, fontVariations }
1817
+ : { nodeId, textWrapStyle, fontVariations, ...(fontVariations ? { fontFamily, fontStyle } : {}) };
1818
+ const res = await connector.executeCodeViaUI(`const P = ${JSON.stringify(payload)};\n${TEXT_STYLE_BODY}`, 15000);
1819
+ if (!res?.success)
1820
+ throw new Error(res?.error || "Failed to set text styling");
1821
+ styling = res.result;
1822
+ node = node ?? res.result.node;
1704
1823
  }
1705
1824
  return {
1706
1825
  content: [
@@ -1708,8 +1827,9 @@ After instantiating components, use figma_take_screenshot to verify the result l
1708
1827
  type: "text",
1709
1828
  text: JSON.stringify({
1710
1829
  success: true,
1711
- message: "Text content updated",
1712
- node: result.node,
1830
+ message: styleOnly ? "Text styling updated" : "Text content updated",
1831
+ node,
1832
+ ...(styling ? { fontName: styling.fontName, fontSize: styling.fontSize, textWrapStyle: styling.textWrapStyle } : {}),
1713
1833
  }),
1714
1834
  },
1715
1835
  ],