figma-relai 0.1.4 → 0.2.1
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.
- package/dist/chunk-2H7UOFLK.js +11 -0
- package/dist/chunk-2H7UOFLK.js.map +1 -0
- package/dist/chunk-EBP7POOP.js +3516 -0
- package/dist/chunk-EBP7POOP.js.map +1 -0
- package/dist/chunk-XBWGSCRZ.js +33 -0
- package/dist/chunk-XBWGSCRZ.js.map +1 -0
- package/dist/docs-S7VJ2PPR.js +60 -0
- package/dist/docs-S7VJ2PPR.js.map +1 -0
- package/dist/doctor-ZN2PKAPH.js +138 -0
- package/dist/doctor-ZN2PKAPH.js.map +1 -0
- package/dist/index.js +84 -3100
- package/dist/index.js.map +1 -1
- package/dist/manifest-CFCCUJEV.js +55 -0
- package/dist/manifest-CFCCUJEV.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,3516 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
__export
|
|
4
|
+
} from "./chunk-2H7UOFLK.js";
|
|
5
|
+
|
|
6
|
+
// src/server.ts
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
function createServer() {
|
|
9
|
+
return new McpServer(
|
|
10
|
+
{
|
|
11
|
+
name: "Relai",
|
|
12
|
+
version: "0.2.1"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
instructions: `
|
|
16
|
+
You control Figma through ~30 consolidated tools. Pairing with the Figma plugin is automatic \u2014 just call any tool; join_room is only needed if an error reports multiple plugins.
|
|
17
|
+
|
|
18
|
+
\u{1F4D6} UNDERSTAND (start here):
|
|
19
|
+
get_document_overview \u2014 structure, page/component/style/variable counts
|
|
20
|
+
get_selection_context \u2014 full context for what the designer selected
|
|
21
|
+
get_node_details \u2014 deep single-node inspection (CSS, token bindings)
|
|
22
|
+
search_nodes \u2014 find nodes by name/type \xB7 get_node_data \u2014 raw data/tree/css
|
|
23
|
+
get_design_tokens \u2014 variable collections, modes, styles
|
|
24
|
+
screenshot \u2014 see the canvas (use this to verify your work visually)
|
|
25
|
+
|
|
26
|
+
\u{1F50D} DIAGNOSE:
|
|
27
|
+
analyze_design \u2014 aspect: color/layout/components/accessibility, or
|
|
28
|
+
"overall" for a weighted 0-100 health score across all four
|
|
29
|
+
diff_nodes \u2014 compare two nodes, or checkpoint save/compare to audit
|
|
30
|
+
what changed on a node over an editing session
|
|
31
|
+
|
|
32
|
+
\u270F\uFE0F EDIT:
|
|
33
|
+
create_node \u2014 any node type (rectangle/frame/text/svg/image/\u2026)
|
|
34
|
+
set_properties \u2014 one call for geometry, fills, strokes, effects, text,
|
|
35
|
+
auto-layout, constraints, style/variable bindings, on one or many nodes
|
|
36
|
+
set_text \u2014 single, bulk, or character-range text edits
|
|
37
|
+
edit_structure \u2014 group/reparent/reorder/clone/flatten/boolean/delete
|
|
38
|
+
navigate \u2014 focus/select/viewport/switch_page \xB7 manage_pages
|
|
39
|
+
|
|
40
|
+
\u{1F9F1} DESIGN SYSTEM:
|
|
41
|
+
manage_components \u2014 create/variants/instantiate/overrides/props/detach
|
|
42
|
+
manage_variables \u2014 collections, modes, tokens, bind/unbind
|
|
43
|
+
manage_styles \u2014 paint/text/effect/grid styles
|
|
44
|
+
import_from_library \u2014 bring in library components/styles/variables
|
|
45
|
+
|
|
46
|
+
\u{1F4E6} ASSETS: export_asset (PNG/JPG/SVG/PDF) \xB7 add_image (URL or fill)
|
|
47
|
+
\u{1F4DD} ANNOTATIONS: annotate (Dev Mode annotations)
|
|
48
|
+
\u{1F4AC} COMMENTS: manage_comments \u2014 read/apply/reply to file comments
|
|
49
|
+
(needs FIGMA_TOKEN env; the tool explains setup when missing)
|
|
50
|
+
|
|
51
|
+
\u2705 VERIFY: verify_changes \xB7 validate_design_rules
|
|
52
|
+
|
|
53
|
+
\u26A1 ADVANCED:
|
|
54
|
+
batch_execute \u2014 run many plugin-level commands in one round-trip
|
|
55
|
+
execute_figma \u2014 run JavaScript against the Figma Plugin API directly
|
|
56
|
+
(the escape hatch when no tool fits; small incremental scripts,
|
|
57
|
+
screenshot between steps; the designer can disable it)
|
|
58
|
+
|
|
59
|
+
OPERATING PRINCIPLES:
|
|
60
|
+
- Inspect context BEFORE changing things; don't assume selection state
|
|
61
|
+
- After visual edits, verify: screenshot or verify_changes
|
|
62
|
+
- Colors are RGBA in 0-1 range (Figma format), not 0-255
|
|
63
|
+
- layoutSizing FILL requires the node's PARENT to have auto-layout;
|
|
64
|
+
set layoutMode on a frame before padding/alignment/spacing
|
|
65
|
+
- Responses include recommended_next tools \u2014 follow them
|
|
66
|
+
- Errors name the node, its type, and the fix \u2014 read them, don't retry blindly
|
|
67
|
+
`
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ../shared/src/types/commands.ts
|
|
73
|
+
var FIGMA_COMMANDS = [
|
|
74
|
+
// Document & Selection
|
|
75
|
+
"get_document_info",
|
|
76
|
+
"get_file_info",
|
|
77
|
+
"get_selection",
|
|
78
|
+
"read_my_design",
|
|
79
|
+
"get_node_info",
|
|
80
|
+
"get_node_tree",
|
|
81
|
+
"get_nodes_info",
|
|
82
|
+
"scan_nodes_by_types",
|
|
83
|
+
"get_css",
|
|
84
|
+
"find_nodes",
|
|
85
|
+
"get_bound_variables",
|
|
86
|
+
// Creation
|
|
87
|
+
"create_rectangle",
|
|
88
|
+
"create_frame",
|
|
89
|
+
"create_text",
|
|
90
|
+
"create_ellipse",
|
|
91
|
+
"create_polygon",
|
|
92
|
+
"create_star",
|
|
93
|
+
"create_line",
|
|
94
|
+
"create_component_instance",
|
|
95
|
+
"create_section",
|
|
96
|
+
"create_node_from_svg",
|
|
97
|
+
"create_connector",
|
|
98
|
+
"create_table",
|
|
99
|
+
"create_sticky",
|
|
100
|
+
"create_code_block",
|
|
101
|
+
"create_slice",
|
|
102
|
+
// Modification
|
|
103
|
+
"move_node",
|
|
104
|
+
"resize_node",
|
|
105
|
+
"clone_node",
|
|
106
|
+
"delete_node",
|
|
107
|
+
"delete_multiple_nodes",
|
|
108
|
+
"rename_node",
|
|
109
|
+
"set_opacity",
|
|
110
|
+
"reorder_node",
|
|
111
|
+
"set_visible",
|
|
112
|
+
"set_locked",
|
|
113
|
+
"reparent_node",
|
|
114
|
+
"set_rotation",
|
|
115
|
+
"set_relaunch_data",
|
|
116
|
+
"get_relaunch_data",
|
|
117
|
+
// Styling
|
|
118
|
+
"set_fill_color",
|
|
119
|
+
"set_fills",
|
|
120
|
+
"set_stroke_color",
|
|
121
|
+
"set_corner_radius",
|
|
122
|
+
"set_blend_mode",
|
|
123
|
+
"set_gradient_fill",
|
|
124
|
+
"set_clips_content",
|
|
125
|
+
"set_layout_grids",
|
|
126
|
+
"set_aspect_ratio",
|
|
127
|
+
// Effects
|
|
128
|
+
"add_drop_shadow",
|
|
129
|
+
"add_inner_shadow",
|
|
130
|
+
"add_blur",
|
|
131
|
+
"add_background_blur",
|
|
132
|
+
"remove_effects",
|
|
133
|
+
// Text
|
|
134
|
+
"scan_text_nodes",
|
|
135
|
+
"set_text_content",
|
|
136
|
+
"set_multiple_text_contents",
|
|
137
|
+
"set_text_style_range",
|
|
138
|
+
// Layout
|
|
139
|
+
"set_layout_mode",
|
|
140
|
+
"set_padding",
|
|
141
|
+
"set_axis_align",
|
|
142
|
+
"set_layout_sizing",
|
|
143
|
+
"set_item_spacing",
|
|
144
|
+
// Design system
|
|
145
|
+
"get_design_system",
|
|
146
|
+
"scan_token_drift",
|
|
147
|
+
"get_conventions",
|
|
148
|
+
"set_conventions",
|
|
149
|
+
// Components
|
|
150
|
+
"get_local_components",
|
|
151
|
+
"get_instance_overrides",
|
|
152
|
+
"set_instance_overrides",
|
|
153
|
+
"create_component",
|
|
154
|
+
"create_component_set",
|
|
155
|
+
"get_component_properties",
|
|
156
|
+
"set_component_properties",
|
|
157
|
+
"detach_instance",
|
|
158
|
+
"reset_instance",
|
|
159
|
+
"audit_colors",
|
|
160
|
+
"find_orphan_instances",
|
|
161
|
+
// Variables
|
|
162
|
+
"get_variable_collections",
|
|
163
|
+
"get_variables",
|
|
164
|
+
"create_variable_collection",
|
|
165
|
+
"create_variable",
|
|
166
|
+
"update_variable",
|
|
167
|
+
"delete_variable",
|
|
168
|
+
"delete_variable_collection",
|
|
169
|
+
"update_variable_collection",
|
|
170
|
+
"add_mode",
|
|
171
|
+
"remove_mode",
|
|
172
|
+
"rename_mode",
|
|
173
|
+
"set_variable_scopes",
|
|
174
|
+
"set_variable_code_syntax",
|
|
175
|
+
"remove_variable_code_syntax",
|
|
176
|
+
"create_variable_alias",
|
|
177
|
+
"bind_variable",
|
|
178
|
+
"unbind_variable",
|
|
179
|
+
"set_node_variable_mode",
|
|
180
|
+
"get_resolved_variable_modes",
|
|
181
|
+
// Styles
|
|
182
|
+
"get_styles",
|
|
183
|
+
"create_paint_style",
|
|
184
|
+
"create_text_style",
|
|
185
|
+
"create_effect_style",
|
|
186
|
+
"create_grid_style",
|
|
187
|
+
"update_style",
|
|
188
|
+
"delete_style",
|
|
189
|
+
"apply_style",
|
|
190
|
+
"reorder_style",
|
|
191
|
+
// Export
|
|
192
|
+
"export_node_as_image",
|
|
193
|
+
"get_screenshot",
|
|
194
|
+
"create_image_from_url",
|
|
195
|
+
// Page
|
|
196
|
+
"get_pages",
|
|
197
|
+
"create_page",
|
|
198
|
+
"rename_page",
|
|
199
|
+
"delete_page",
|
|
200
|
+
"switch_page",
|
|
201
|
+
"set_page_background",
|
|
202
|
+
"get_flow_starting_points",
|
|
203
|
+
// Structure
|
|
204
|
+
"group_nodes",
|
|
205
|
+
"ungroup_nodes",
|
|
206
|
+
"flatten_node",
|
|
207
|
+
"boolean_operation",
|
|
208
|
+
"create_component_from_node",
|
|
209
|
+
// Library
|
|
210
|
+
"import_component_by_key",
|
|
211
|
+
"import_style_by_key",
|
|
212
|
+
"import_variable_by_key",
|
|
213
|
+
// Document extras
|
|
214
|
+
"get_selection_colors",
|
|
215
|
+
"set_file_thumbnail",
|
|
216
|
+
"figma_notify",
|
|
217
|
+
// Constraints
|
|
218
|
+
"set_constraints",
|
|
219
|
+
"set_min_max_size",
|
|
220
|
+
// Annotations
|
|
221
|
+
"get_annotations",
|
|
222
|
+
"set_annotation",
|
|
223
|
+
"set_multiple_annotations",
|
|
224
|
+
// Prototype
|
|
225
|
+
"get_reactions",
|
|
226
|
+
"set_default_connector",
|
|
227
|
+
"create_connections",
|
|
228
|
+
// Viewport
|
|
229
|
+
"set_focus",
|
|
230
|
+
"set_selections",
|
|
231
|
+
"get_viewport",
|
|
232
|
+
"set_viewport",
|
|
233
|
+
// Plugin data
|
|
234
|
+
"set_plugin_data",
|
|
235
|
+
"get_plugin_data",
|
|
236
|
+
// Image fill
|
|
237
|
+
"set_image_fill",
|
|
238
|
+
// Batch
|
|
239
|
+
"batch_execute",
|
|
240
|
+
"execute_code",
|
|
241
|
+
// Room
|
|
242
|
+
"join"
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
// ../shared/src/pitfalls.ts
|
|
246
|
+
var PITFALLS = [
|
|
247
|
+
{
|
|
248
|
+
pattern: "unloaded font",
|
|
249
|
+
hint: "await figma.loadFontAsync(node.fontName) before editing text \u2014 new TextNodes default to Inter Regular; for mixed ranges load every font from getRangeAllFontNames().",
|
|
250
|
+
doc: "**Editing text without loading its font throws** (`unloaded font`). `await figma.loadFontAsync(textNode.fontName)` first; new TextNodes default to Inter Regular; when `fontName === figma.mixed`, load every font from `getRangeAllFontNames()`."
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
pattern: "getNodeById",
|
|
254
|
+
hint: "This plugin runs in dynamic-page mode \u2014 use await figma.getNodeByIdAsync(id) instead of getNodeById.",
|
|
255
|
+
doc: "**`getNodeById` throws in dynamic-page mode** \u2014 always `await figma.getNodeByIdAsync(id)`."
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
pattern: "explicitly loaded",
|
|
259
|
+
hint: "Non-current pages must be loaded before traversal: await page.loadAsync() before reading page.children or calling findAll.",
|
|
260
|
+
doc: "**Traversing a non-current page throws until you `await page.loadAsync()`** \u2014 `figma.currentPage` is always loaded, other pages are not."
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
pattern: "layoutSizing",
|
|
264
|
+
hint: "layoutSizing FILL/HUG needs auto-layout \u2014 on the parent for FILL, on the node itself for HUG. Set layoutMode on the frame first.",
|
|
265
|
+
doc: "**`layoutSizingHorizontal/Vertical` throw without auto-layout** \u2014 FILL requires the parent to have a `layoutMode`, HUG requires it on the node itself."
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
pattern: "Starter plan",
|
|
269
|
+
hint: "This file hit the free-plan page limit \u2014 reuse an existing page (find it via figma.root.children) instead of figma.createPage().",
|
|
270
|
+
doc: "**`createPage()` throws on the free plan once a file has 3 pages** \u2014 reuse an existing page instead."
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
pattern: "read.?only property '(width|height)'",
|
|
274
|
+
hint: "width/height are read-only \u2014 use node.resize(w, h) (and note resize on an auto-layout frame pins that axis to FIXED).",
|
|
275
|
+
doc: "**`width`/`height` are read-only** \u2014 use `node.resize(w, h)`."
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
pattern: "symbol",
|
|
279
|
+
hint: "You probably hit figma.mixed (a Symbol) \u2014 properties like fontSize/cornerRadius/fills return it when values differ across ranges or children. Compare with === figma.mixed before using the value.",
|
|
280
|
+
doc: "**`figma.mixed` is a Symbol** returned by `fontSize`, `cornerRadius`, `fills` etc. when values differ across ranges/children \u2014 check `=== figma.mixed` before use; never JSON-serialize it."
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
pattern: "of undefined",
|
|
284
|
+
hint: 'A lookup returned undefined \u2014 if you searched pages or nodes by exact name, names may have been changed by the designer. Locate by type/content (e.g. c.type === "COMPONENT_SET") instead.',
|
|
285
|
+
doc: "**Exact-name lookups are fragile** \u2014 designers rename pages and layers freely; locate nodes by type/content instead of `name ===`."
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
pattern: "does not exist",
|
|
289
|
+
hint: "The node id is stale \u2014 the node was likely deleted or lives on another page. Re-read the document to get current ids.",
|
|
290
|
+
doc: "**Stale node ids throw `does not exist`** \u2014 nodes get deleted while you work; re-read before editing and check `node.removed`."
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
pattern: "not extensible",
|
|
294
|
+
hint: "Figma nodes can't hold custom properties (they're non-extensible). Stash data with node.setPluginData(key, value), or return it from the script.",
|
|
295
|
+
doc: "**Nodes are non-extensible** \u2014 `node.myCustomProp = x` throws `object is not extensible`; use `setPluginData` or return the data instead."
|
|
296
|
+
},
|
|
297
|
+
// ── silent pitfalls: nothing throws, results are just wrong ──
|
|
298
|
+
{
|
|
299
|
+
pattern: null,
|
|
300
|
+
hint: "",
|
|
301
|
+
doc: "**Shadow `spread` renders only on shapes or frames with `clipsContent: true`** \u2014 a focus ring built from spread shadows is invisible on a non-clipping frame/component."
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
pattern: null,
|
|
305
|
+
hint: "",
|
|
306
|
+
doc: '**`resize()` on an auto-layout frame silently pins that axis to FIXED**, overriding `primaryAxisSizingMode: "AUTO"`. Append children first, then set `layoutSizingHorizontal = "HUG"`; use `resize` only for the fixed cross-axis.'
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
pattern: null,
|
|
310
|
+
hint: "",
|
|
311
|
+
doc: "**Per-corner radius** (`topLeftRadius` \u2026) only exists on RectangleCornerMixin nodes (rectangles, frames, components) \u2014 polygons, stars and lines throw."
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
pattern: null,
|
|
315
|
+
hint: "",
|
|
316
|
+
doc: "**Instance children can't be added or removed** \u2014 detach first, or edit the main component."
|
|
317
|
+
}
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
// ../shared/src/utils/color.ts
|
|
321
|
+
function rgbaToHex(color) {
|
|
322
|
+
const r = Math.round(color.r * 255);
|
|
323
|
+
const g = Math.round(color.g * 255);
|
|
324
|
+
const b = Math.round(color.b * 255);
|
|
325
|
+
const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
326
|
+
if (color.a !== void 0 && color.a < 1) {
|
|
327
|
+
const a = Math.round(color.a * 255);
|
|
328
|
+
return hex + a.toString(16).padStart(2, "0");
|
|
329
|
+
}
|
|
330
|
+
return hex;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ../shared/src/utils/schemas.ts
|
|
334
|
+
import { z } from "zod";
|
|
335
|
+
var colorSchema = z.object({
|
|
336
|
+
r: z.number().min(0).max(1).describe("Red channel (0-1)"),
|
|
337
|
+
g: z.number().min(0).max(1).describe("Green channel (0-1)"),
|
|
338
|
+
b: z.number().min(0).max(1).describe("Blue channel (0-1)"),
|
|
339
|
+
a: z.number().min(0).max(1).optional().describe("Alpha channel (0-1)")
|
|
340
|
+
});
|
|
341
|
+
var dimensionSchema = z.number().positive();
|
|
342
|
+
var layoutModeSchema = z.enum(["NONE", "HORIZONTAL", "VERTICAL"]);
|
|
343
|
+
var layoutWrapSchema = z.enum(["NO_WRAP", "WRAP"]);
|
|
344
|
+
var primaryAxisAlignSchema = z.enum([
|
|
345
|
+
"MIN",
|
|
346
|
+
"MAX",
|
|
347
|
+
"CENTER",
|
|
348
|
+
"SPACE_BETWEEN"
|
|
349
|
+
]);
|
|
350
|
+
var counterAxisAlignSchema = z.enum([
|
|
351
|
+
"MIN",
|
|
352
|
+
"MAX",
|
|
353
|
+
"CENTER",
|
|
354
|
+
"BASELINE"
|
|
355
|
+
]);
|
|
356
|
+
var sizingModeSchema = z.enum(["FIXED", "HUG", "FILL"]);
|
|
357
|
+
var exportFormatSchema = z.enum(["PNG", "JPG", "SVG", "PDF"]);
|
|
358
|
+
var blendModeSchema = z.enum([
|
|
359
|
+
"PASS_THROUGH",
|
|
360
|
+
"NORMAL",
|
|
361
|
+
"DARKEN",
|
|
362
|
+
"MULTIPLY",
|
|
363
|
+
"LINEAR_BURN",
|
|
364
|
+
"COLOR_BURN",
|
|
365
|
+
"LIGHTEN",
|
|
366
|
+
"SCREEN",
|
|
367
|
+
"LINEAR_DODGE",
|
|
368
|
+
"COLOR_DODGE",
|
|
369
|
+
"OVERLAY",
|
|
370
|
+
"SOFT_LIGHT",
|
|
371
|
+
"HARD_LIGHT",
|
|
372
|
+
"DIFFERENCE",
|
|
373
|
+
"EXCLUSION",
|
|
374
|
+
"HUE",
|
|
375
|
+
"SATURATION",
|
|
376
|
+
"COLOR",
|
|
377
|
+
"LUMINOSITY"
|
|
378
|
+
]);
|
|
379
|
+
var gradientTypeSchema = z.enum([
|
|
380
|
+
"GRADIENT_LINEAR",
|
|
381
|
+
"GRADIENT_RADIAL",
|
|
382
|
+
"GRADIENT_ANGULAR",
|
|
383
|
+
"GRADIENT_DIAMOND"
|
|
384
|
+
]);
|
|
385
|
+
var gradientStopSchema = z.object({
|
|
386
|
+
position: z.number().min(0).max(1).describe("Stop position (0-1)"),
|
|
387
|
+
color: colorSchema
|
|
388
|
+
});
|
|
389
|
+
var booleanOperationSchema = z.enum([
|
|
390
|
+
"UNION",
|
|
391
|
+
"SUBTRACT",
|
|
392
|
+
"INTERSECT",
|
|
393
|
+
"EXCLUDE"
|
|
394
|
+
]);
|
|
395
|
+
var constraintTypeSchema = z.enum([
|
|
396
|
+
"MIN",
|
|
397
|
+
"CENTER",
|
|
398
|
+
"MAX",
|
|
399
|
+
"STRETCH",
|
|
400
|
+
"SCALE"
|
|
401
|
+
]);
|
|
402
|
+
var reorderDirectionSchema = z.enum([
|
|
403
|
+
"FRONT",
|
|
404
|
+
"BACK",
|
|
405
|
+
"FORWARD",
|
|
406
|
+
"BACKWARD"
|
|
407
|
+
]);
|
|
408
|
+
|
|
409
|
+
// ../shared/src/relay-core.ts
|
|
410
|
+
var RelayCore = class {
|
|
411
|
+
rooms = /* @__PURE__ */ new Map();
|
|
412
|
+
lastActivity = /* @__PURE__ */ new Map();
|
|
413
|
+
staleTimer = null;
|
|
414
|
+
version;
|
|
415
|
+
log;
|
|
416
|
+
constructor(options = {}) {
|
|
417
|
+
this.version = options.version ?? "unknown";
|
|
418
|
+
this.log = options.log ?? (() => {
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
handleOpen(ws) {
|
|
422
|
+
this.lastActivity.set(ws, Date.now());
|
|
423
|
+
this.send(ws, { type: "system", message: "Please join a room to start" });
|
|
424
|
+
}
|
|
425
|
+
handleMessage(ws, raw) {
|
|
426
|
+
this.lastActivity.set(ws, Date.now());
|
|
427
|
+
let data;
|
|
428
|
+
try {
|
|
429
|
+
data = JSON.parse(raw);
|
|
430
|
+
} catch {
|
|
431
|
+
this.send(ws, { type: "error", message: "Invalid JSON" });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
switch (data.type) {
|
|
435
|
+
case "hello":
|
|
436
|
+
this.send(ws, { type: "hello", server: "figma-relai", version: this.version });
|
|
437
|
+
return;
|
|
438
|
+
case "join": {
|
|
439
|
+
const room = data.room;
|
|
440
|
+
if (!room || typeof room !== "string") {
|
|
441
|
+
this.send(ws, { type: "error", message: "Room name required" });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const role = data.role === "plugin" || data.role === "agent" ? data.role : "unknown";
|
|
445
|
+
const peers = this.roomPeers(room);
|
|
446
|
+
peers.set(ws, { role, meta: data.meta });
|
|
447
|
+
this.log(`Client joined "${room}" as ${role} (${peers.size} clients)`);
|
|
448
|
+
this.send(ws, { type: "system", message: `Joined room: ${room}`, room });
|
|
449
|
+
this.send(ws, {
|
|
450
|
+
type: "system",
|
|
451
|
+
message: { id: data.id, result: `Connected to room: ${room}` },
|
|
452
|
+
room
|
|
453
|
+
});
|
|
454
|
+
this.broadcastPresence(room);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
case "list_rooms": {
|
|
458
|
+
const rooms = [];
|
|
459
|
+
for (const [room, peers] of this.rooms) {
|
|
460
|
+
if (peers.size === 0) continue;
|
|
461
|
+
const pluginPeer = [...peers.values()].find((p) => p.role === "plugin");
|
|
462
|
+
const fileName = typeof pluginPeer?.meta?.fileName === "string" ? pluginPeer.meta.fileName : void 0;
|
|
463
|
+
rooms.push({
|
|
464
|
+
room,
|
|
465
|
+
hasPlugin: pluginPeer !== void 0,
|
|
466
|
+
agentCount: [...peers.values()].filter((p) => p.role === "agent").length,
|
|
467
|
+
...fileName ? { fileName } : {}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
this.send(ws, { type: "list_rooms_result", id: data.id, rooms });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
case "message": {
|
|
474
|
+
const room = data.room;
|
|
475
|
+
if (!room || typeof room !== "string") {
|
|
476
|
+
this.send(ws, { type: "error", message: "Room name required" });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (!this.rooms.get(room)?.has(ws)) {
|
|
480
|
+
this.send(ws, { type: "error", message: "Must join room first" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const payload = JSON.stringify({
|
|
484
|
+
type: "broadcast",
|
|
485
|
+
message: data.message,
|
|
486
|
+
sender: "peer",
|
|
487
|
+
room
|
|
488
|
+
});
|
|
489
|
+
const count = this.broadcast(ws, room, payload);
|
|
490
|
+
if (count === 0) this.log(`No peers in "${room}" to receive message`);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
case "progress_update": {
|
|
494
|
+
const room = data.room;
|
|
495
|
+
if (room && typeof room === "string" && this.rooms.get(room)?.has(ws)) {
|
|
496
|
+
this.broadcast(ws, room, raw);
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
handleClose(ws) {
|
|
503
|
+
this.lastActivity.delete(ws);
|
|
504
|
+
const affected = [];
|
|
505
|
+
for (const [room, peers] of this.rooms) {
|
|
506
|
+
if (peers.delete(ws)) affected.push(room);
|
|
507
|
+
if (peers.size === 0) this.rooms.delete(room);
|
|
508
|
+
}
|
|
509
|
+
for (const room of affected) this.broadcastPresence(room);
|
|
510
|
+
}
|
|
511
|
+
// Periodically close connections with no activity (heartbeat)
|
|
512
|
+
startStaleCleanup(staleTimeoutMs = 12e4, checkIntervalMs = 3e4) {
|
|
513
|
+
this.staleTimer = setInterval(() => {
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
for (const [ws, lastTime] of this.lastActivity) {
|
|
516
|
+
if (now - lastTime > staleTimeoutMs) {
|
|
517
|
+
this.log("Closing stale connection");
|
|
518
|
+
try {
|
|
519
|
+
ws.close();
|
|
520
|
+
} catch {
|
|
521
|
+
}
|
|
522
|
+
this.handleClose(ws);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}, checkIntervalMs);
|
|
526
|
+
}
|
|
527
|
+
stop() {
|
|
528
|
+
if (this.staleTimer) {
|
|
529
|
+
clearInterval(this.staleTimer);
|
|
530
|
+
this.staleTimer = null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
roomPeers(room) {
|
|
534
|
+
let peers = this.rooms.get(room);
|
|
535
|
+
if (!peers) {
|
|
536
|
+
peers = /* @__PURE__ */ new Map();
|
|
537
|
+
this.rooms.set(room, peers);
|
|
538
|
+
}
|
|
539
|
+
return peers;
|
|
540
|
+
}
|
|
541
|
+
// Presence goes to every member (including the newcomer) so both the plugin
|
|
542
|
+
// UI and the MCP server always know who is in the room
|
|
543
|
+
broadcastPresence(room) {
|
|
544
|
+
const peers = this.rooms.get(room);
|
|
545
|
+
if (!peers) return;
|
|
546
|
+
const payload = JSON.stringify({
|
|
547
|
+
type: "presence",
|
|
548
|
+
room,
|
|
549
|
+
peers: [...peers.values()].map((p) => ({ role: p.role, meta: p.meta }))
|
|
550
|
+
});
|
|
551
|
+
for (const ws of peers.keys()) {
|
|
552
|
+
try {
|
|
553
|
+
ws.send(payload);
|
|
554
|
+
} catch {
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
broadcast(sender, room, payload) {
|
|
559
|
+
const peers = this.rooms.get(room);
|
|
560
|
+
if (!peers) return 0;
|
|
561
|
+
let count = 0;
|
|
562
|
+
for (const ws of peers.keys()) {
|
|
563
|
+
if (ws === sender) continue;
|
|
564
|
+
try {
|
|
565
|
+
ws.send(payload);
|
|
566
|
+
count++;
|
|
567
|
+
} catch {
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return count;
|
|
571
|
+
}
|
|
572
|
+
send(ws, data) {
|
|
573
|
+
try {
|
|
574
|
+
ws.send(JSON.stringify(data));
|
|
575
|
+
} catch {
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// src/tools/index.ts
|
|
581
|
+
import { z as z22 } from "zod";
|
|
582
|
+
|
|
583
|
+
// src/tools/core/create.ts
|
|
584
|
+
var create_exports = {};
|
|
585
|
+
__export(create_exports, {
|
|
586
|
+
register: () => register
|
|
587
|
+
});
|
|
588
|
+
import { z as z2 } from "zod";
|
|
589
|
+
|
|
590
|
+
// src/tools/core/helpers.ts
|
|
591
|
+
function textResult(text) {
|
|
592
|
+
return { content: [{ type: "text", text }] };
|
|
593
|
+
}
|
|
594
|
+
function jsonResult(value) {
|
|
595
|
+
return textResult(typeof value === "string" ? value : JSON.stringify(value, null, 2));
|
|
596
|
+
}
|
|
597
|
+
function errorResult(error) {
|
|
598
|
+
return textResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// src/tools/core/create.ts
|
|
602
|
+
var NODE_KINDS = [
|
|
603
|
+
"rectangle",
|
|
604
|
+
"frame",
|
|
605
|
+
"text",
|
|
606
|
+
"ellipse",
|
|
607
|
+
"polygon",
|
|
608
|
+
"star",
|
|
609
|
+
"line",
|
|
610
|
+
"section",
|
|
611
|
+
"slice",
|
|
612
|
+
"svg",
|
|
613
|
+
"image",
|
|
614
|
+
"connector",
|
|
615
|
+
"table",
|
|
616
|
+
"sticky",
|
|
617
|
+
"code_block"
|
|
618
|
+
];
|
|
619
|
+
var KIND_COMMAND = {
|
|
620
|
+
rectangle: "create_rectangle",
|
|
621
|
+
frame: "create_frame",
|
|
622
|
+
text: "create_text",
|
|
623
|
+
ellipse: "create_ellipse",
|
|
624
|
+
polygon: "create_polygon",
|
|
625
|
+
star: "create_star",
|
|
626
|
+
line: "create_line",
|
|
627
|
+
section: "create_section",
|
|
628
|
+
slice: "create_slice",
|
|
629
|
+
svg: "create_node_from_svg",
|
|
630
|
+
image: "create_image_from_url",
|
|
631
|
+
connector: "create_connector",
|
|
632
|
+
table: "create_table",
|
|
633
|
+
sticky: "create_sticky",
|
|
634
|
+
code_block: "create_code_block"
|
|
635
|
+
};
|
|
636
|
+
function register(server, sendCommand) {
|
|
637
|
+
server.tool(
|
|
638
|
+
"create_node",
|
|
639
|
+
"Create a node in Figma: rectangle, frame, text, ellipse, polygon, star, line, section, slice, svg (from markup), image (from URL), or FigJam connector/table/sticky/code_block. Returns the new node's id \u2014 style it further with set_properties. For component instances use manage_components.",
|
|
640
|
+
{
|
|
641
|
+
type: z2.enum(NODE_KINDS).describe("What to create"),
|
|
642
|
+
x: z2.number().optional().describe("X position (default 0)"),
|
|
643
|
+
y: z2.number().optional().describe("Y position (default 0)"),
|
|
644
|
+
width: dimensionSchema.optional(),
|
|
645
|
+
height: dimensionSchema.optional(),
|
|
646
|
+
name: z2.string().optional(),
|
|
647
|
+
parentId: z2.string().optional().describe("Parent node id (default: current page)"),
|
|
648
|
+
text: z2.string().optional().describe("Text content (text/sticky)"),
|
|
649
|
+
fontSize: z2.number().positive().optional().describe("text only"),
|
|
650
|
+
fontWeight: z2.number().optional().describe("text only (e.g. 400, 700)"),
|
|
651
|
+
fontColor: colorSchema.optional().describe("text only"),
|
|
652
|
+
fillColor: colorSchema.optional().describe("frame/rectangle fill"),
|
|
653
|
+
pointCount: z2.number().int().min(3).optional().describe("polygon/star points"),
|
|
654
|
+
innerRadius: z2.number().min(0).max(1).optional().describe("star inner radius ratio"),
|
|
655
|
+
length: z2.number().positive().optional().describe("line length"),
|
|
656
|
+
rotation: z2.number().optional().describe("line rotation in degrees"),
|
|
657
|
+
strokeWeight: z2.number().min(0).optional().describe("line stroke weight"),
|
|
658
|
+
svg: z2.string().optional().describe("SVG markup (type=svg)"),
|
|
659
|
+
url: z2.string().optional().describe("Image URL (type=image)"),
|
|
660
|
+
scaleMode: z2.enum(["FILL", "FIT", "CROP", "TILE"]).optional().describe("image only"),
|
|
661
|
+
rows: z2.number().int().positive().optional().describe("table only"),
|
|
662
|
+
cols: z2.number().int().positive().optional().describe("table only"),
|
|
663
|
+
startNodeId: z2.string().optional().describe("connector only"),
|
|
664
|
+
endNodeId: z2.string().optional().describe("connector only"),
|
|
665
|
+
lineType: z2.enum(["ELBOWED", "STRAIGHT"]).optional().describe("connector only")
|
|
666
|
+
},
|
|
667
|
+
async ({ type, ...params }) => {
|
|
668
|
+
try {
|
|
669
|
+
const clean = Object.fromEntries(
|
|
670
|
+
Object.entries(params).filter(([, v]) => v !== void 0)
|
|
671
|
+
);
|
|
672
|
+
const result = await sendCommand(KIND_COMMAND[type], clean);
|
|
673
|
+
return jsonResult(result);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
return errorResult(error);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/tools/core/properties.ts
|
|
682
|
+
var properties_exports = {};
|
|
683
|
+
__export(properties_exports, {
|
|
684
|
+
mapPropertiesToCommands: () => mapPropertiesToCommands,
|
|
685
|
+
propertiesSchema: () => propertiesSchema,
|
|
686
|
+
register: () => register2
|
|
687
|
+
});
|
|
688
|
+
import { z as z3 } from "zod";
|
|
689
|
+
var effectSchema = z3.object({
|
|
690
|
+
type: z3.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]),
|
|
691
|
+
color: colorSchema.optional().describe("Shadow color (shadows only)"),
|
|
692
|
+
offsetX: z3.number().optional(),
|
|
693
|
+
offsetY: z3.number().optional(),
|
|
694
|
+
radius: z3.number().min(0).optional().describe("Blur radius"),
|
|
695
|
+
spread: z3.number().optional()
|
|
696
|
+
});
|
|
697
|
+
var propertiesSchema = z3.object({
|
|
698
|
+
// Geometry
|
|
699
|
+
x: z3.number().optional(),
|
|
700
|
+
y: z3.number().optional(),
|
|
701
|
+
width: dimensionSchema.optional(),
|
|
702
|
+
height: dimensionSchema.optional(),
|
|
703
|
+
rotation: z3.number().optional().describe("Degrees"),
|
|
704
|
+
// Identity / visibility
|
|
705
|
+
name: z3.string().optional(),
|
|
706
|
+
visible: z3.boolean().optional(),
|
|
707
|
+
locked: z3.boolean().optional(),
|
|
708
|
+
opacity: z3.number().min(0).max(1).optional(),
|
|
709
|
+
blendMode: blendModeSchema.optional(),
|
|
710
|
+
// Paint
|
|
711
|
+
fillColor: colorSchema.optional(),
|
|
712
|
+
fills: z3.array(z3.record(z3.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole fill list; [] clears all fills"),
|
|
713
|
+
strokes: z3.array(z3.record(z3.unknown())).optional().describe("Raw Paint[] \u2014 replaces the whole stroke list; [] clears all strokes"),
|
|
714
|
+
strokeColor: colorSchema.optional(),
|
|
715
|
+
strokeWeight: z3.number().min(0).optional(),
|
|
716
|
+
cornerRadius: z3.number().min(0).optional(),
|
|
717
|
+
corners: z3.array(z3.boolean()).length(4).optional().describe("With cornerRadius: apply per corner [TL, TR, BR, BL]"),
|
|
718
|
+
gradient: z3.object({ gradientType: gradientTypeSchema, stops: z3.array(gradientStopSchema).min(2) }).optional(),
|
|
719
|
+
// Effects (replaces existing effects list entry of same type)
|
|
720
|
+
effects: z3.array(effectSchema).optional(),
|
|
721
|
+
removeEffects: z3.boolean().optional(),
|
|
722
|
+
// Text
|
|
723
|
+
text: z3.string().optional().describe("Text content (TEXT nodes)"),
|
|
724
|
+
// Auto-layout (frame itself)
|
|
725
|
+
layoutMode: layoutModeSchema.optional(),
|
|
726
|
+
layoutWrap: layoutWrapSchema.optional().describe("WRAP requires layoutMode HORIZONTAL"),
|
|
727
|
+
paddingTop: z3.number().min(0).optional(),
|
|
728
|
+
paddingRight: z3.number().min(0).optional(),
|
|
729
|
+
paddingBottom: z3.number().min(0).optional(),
|
|
730
|
+
paddingLeft: z3.number().min(0).optional(),
|
|
731
|
+
primaryAxisAlignItems: primaryAxisAlignSchema.optional(),
|
|
732
|
+
counterAxisAlignItems: counterAxisAlignSchema.optional(),
|
|
733
|
+
itemSpacing: z3.number().optional(),
|
|
734
|
+
counterAxisSpacing: z3.number().optional(),
|
|
735
|
+
// Auto-layout (as a child; FILL requires the PARENT to have auto-layout)
|
|
736
|
+
layoutSizingHorizontal: sizingModeSchema.optional(),
|
|
737
|
+
layoutSizingVertical: sizingModeSchema.optional(),
|
|
738
|
+
// Constraints & sizing limits
|
|
739
|
+
constraintsHorizontal: constraintTypeSchema.optional(),
|
|
740
|
+
constraintsVertical: constraintTypeSchema.optional(),
|
|
741
|
+
minWidth: z3.number().min(0).optional(),
|
|
742
|
+
maxWidth: z3.number().min(0).optional(),
|
|
743
|
+
minHeight: z3.number().min(0).optional(),
|
|
744
|
+
maxHeight: z3.number().min(0).optional(),
|
|
745
|
+
aspectRatioLocked: z3.boolean().optional(),
|
|
746
|
+
clipsContent: z3.boolean().optional(),
|
|
747
|
+
// Design-system bindings
|
|
748
|
+
styleId: z3.string().optional().describe("Apply a paint/text/effect/grid style by id"),
|
|
749
|
+
styleType: z3.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional(),
|
|
750
|
+
boundVariables: z3.record(z3.string()).optional().describe('Bind variables: {"fills": "VariableID:...", "width": "..."}')
|
|
751
|
+
}).strict();
|
|
752
|
+
function defined(obj) {
|
|
753
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
754
|
+
}
|
|
755
|
+
function mapPropertiesToCommands(nodeId, p) {
|
|
756
|
+
const calls = [];
|
|
757
|
+
const add = (command, params) => calls.push({ command, params: { nodeId, ...defined(params) } });
|
|
758
|
+
if (p.name !== void 0) add("rename_node", { name: p.name });
|
|
759
|
+
if (p.visible !== void 0) add("set_visible", { visible: p.visible });
|
|
760
|
+
if (p.locked !== void 0) add("set_locked", { locked: p.locked });
|
|
761
|
+
if (p.x !== void 0 || p.y !== void 0) add("move_node", { x: p.x, y: p.y });
|
|
762
|
+
if (p.width !== void 0 || p.height !== void 0)
|
|
763
|
+
add("resize_node", { width: p.width, height: p.height });
|
|
764
|
+
if (p.rotation !== void 0) add("set_rotation", { rotation: p.rotation });
|
|
765
|
+
if (p.layoutMode !== void 0)
|
|
766
|
+
add("set_layout_mode", { layoutMode: p.layoutMode, layoutWrap: p.layoutWrap });
|
|
767
|
+
if (p.paddingTop !== void 0 || p.paddingRight !== void 0 || p.paddingBottom !== void 0 || p.paddingLeft !== void 0)
|
|
768
|
+
add("set_padding", {
|
|
769
|
+
paddingTop: p.paddingTop,
|
|
770
|
+
paddingRight: p.paddingRight,
|
|
771
|
+
paddingBottom: p.paddingBottom,
|
|
772
|
+
paddingLeft: p.paddingLeft
|
|
773
|
+
});
|
|
774
|
+
if (p.primaryAxisAlignItems !== void 0 || p.counterAxisAlignItems !== void 0)
|
|
775
|
+
add("set_axis_align", {
|
|
776
|
+
primaryAxisAlignItems: p.primaryAxisAlignItems,
|
|
777
|
+
counterAxisAlignItems: p.counterAxisAlignItems
|
|
778
|
+
});
|
|
779
|
+
if (p.itemSpacing !== void 0 || p.counterAxisSpacing !== void 0)
|
|
780
|
+
add("set_item_spacing", {
|
|
781
|
+
itemSpacing: p.itemSpacing,
|
|
782
|
+
counterAxisSpacing: p.counterAxisSpacing
|
|
783
|
+
});
|
|
784
|
+
if (p.layoutSizingHorizontal !== void 0 || p.layoutSizingVertical !== void 0)
|
|
785
|
+
add("set_layout_sizing", {
|
|
786
|
+
layoutSizingHorizontal: p.layoutSizingHorizontal,
|
|
787
|
+
layoutSizingVertical: p.layoutSizingVertical
|
|
788
|
+
});
|
|
789
|
+
if (p.fills !== void 0 || p.strokes !== void 0)
|
|
790
|
+
add("set_fills", {
|
|
791
|
+
...p.fills !== void 0 ? { fills: p.fills } : {},
|
|
792
|
+
...p.strokes !== void 0 ? { strokes: p.strokes } : {}
|
|
793
|
+
});
|
|
794
|
+
if (p.fillColor !== void 0) add("set_fill_color", { color: p.fillColor });
|
|
795
|
+
if (p.strokeColor !== void 0 || p.strokeWeight !== void 0)
|
|
796
|
+
add("set_stroke_color", {
|
|
797
|
+
color: p.strokeColor ?? { r: 0, g: 0, b: 0, a: 1 },
|
|
798
|
+
weight: p.strokeWeight
|
|
799
|
+
});
|
|
800
|
+
if (p.gradient !== void 0)
|
|
801
|
+
add("set_gradient_fill", { gradientType: p.gradient.gradientType, stops: p.gradient.stops });
|
|
802
|
+
if (p.cornerRadius !== void 0)
|
|
803
|
+
add("set_corner_radius", { radius: p.cornerRadius, corners: p.corners });
|
|
804
|
+
if (p.opacity !== void 0) add("set_opacity", { opacity: p.opacity });
|
|
805
|
+
if (p.blendMode !== void 0) add("set_blend_mode", { blendMode: p.blendMode });
|
|
806
|
+
if (p.removeEffects) add("remove_effects", {});
|
|
807
|
+
for (const effect of p.effects ?? []) {
|
|
808
|
+
const common = {
|
|
809
|
+
color: effect.color,
|
|
810
|
+
offsetX: effect.offsetX,
|
|
811
|
+
offsetY: effect.offsetY,
|
|
812
|
+
radius: effect.radius,
|
|
813
|
+
spread: effect.spread
|
|
814
|
+
};
|
|
815
|
+
if (effect.type === "DROP_SHADOW") add("add_drop_shadow", common);
|
|
816
|
+
else if (effect.type === "INNER_SHADOW") add("add_inner_shadow", common);
|
|
817
|
+
else if (effect.type === "LAYER_BLUR") add("add_blur", { radius: effect.radius });
|
|
818
|
+
else add("add_background_blur", { radius: effect.radius });
|
|
819
|
+
}
|
|
820
|
+
if (p.text !== void 0) add("set_text_content", { text: p.text });
|
|
821
|
+
if (p.constraintsHorizontal !== void 0 || p.constraintsVertical !== void 0)
|
|
822
|
+
add("set_constraints", {
|
|
823
|
+
horizontal: p.constraintsHorizontal,
|
|
824
|
+
vertical: p.constraintsVertical
|
|
825
|
+
});
|
|
826
|
+
if (p.minWidth !== void 0 || p.maxWidth !== void 0 || p.minHeight !== void 0 || p.maxHeight !== void 0)
|
|
827
|
+
add("set_min_max_size", {
|
|
828
|
+
minWidth: p.minWidth,
|
|
829
|
+
maxWidth: p.maxWidth,
|
|
830
|
+
minHeight: p.minHeight,
|
|
831
|
+
maxHeight: p.maxHeight
|
|
832
|
+
});
|
|
833
|
+
if (p.aspectRatioLocked !== void 0) add("set_aspect_ratio", { lock: p.aspectRatioLocked });
|
|
834
|
+
if (p.clipsContent !== void 0) add("set_clips_content", { clipsContent: p.clipsContent });
|
|
835
|
+
if (p.styleId !== void 0)
|
|
836
|
+
add("apply_style", { styleId: p.styleId, styleType: p.styleType });
|
|
837
|
+
for (const [property, variableId] of Object.entries(p.boundVariables ?? {})) {
|
|
838
|
+
add("bind_variable", { property, variableId });
|
|
839
|
+
}
|
|
840
|
+
return calls;
|
|
841
|
+
}
|
|
842
|
+
function register2(server, sendCommand) {
|
|
843
|
+
server.tool(
|
|
844
|
+
"set_properties",
|
|
845
|
+
"Set any visual/layout/text properties on one or more nodes in a single call: geometry (x/y/width/height/rotation), fills/strokes/gradients/cornerRadius/opacity/effects, text content, auto-layout (layoutMode, padding, alignment, spacing, layoutSizing \u2014 FILL requires the parent to have auto-layout), constraints, style application, and variable bindings. Groups map to focused operations with per-operation success/error reporting. dryRun:true previews the mapped command list without touching the canvas.",
|
|
846
|
+
{
|
|
847
|
+
nodeIds: z3.array(z3.string()).min(1).describe("Node IDs to update"),
|
|
848
|
+
properties: propertiesSchema.describe("Properties to apply to every listed node"),
|
|
849
|
+
dryRun: z3.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
|
|
850
|
+
},
|
|
851
|
+
{ idempotentHint: true },
|
|
852
|
+
async ({ nodeIds, properties, dryRun }) => {
|
|
853
|
+
try {
|
|
854
|
+
const commands = nodeIds.flatMap(
|
|
855
|
+
(nodeId) => mapPropertiesToCommands(nodeId, properties)
|
|
856
|
+
);
|
|
857
|
+
if (commands.length === 0) return textResult("No properties given \u2014 nothing to do.");
|
|
858
|
+
if (dryRun) {
|
|
859
|
+
return jsonResult({
|
|
860
|
+
dryRun: true,
|
|
861
|
+
commandCount: commands.length,
|
|
862
|
+
commands,
|
|
863
|
+
note: "Nothing was executed. Re-run without dryRun to apply."
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (commands.length === 1) {
|
|
867
|
+
const result = await sendCommand(commands[0].command, commands[0].params);
|
|
868
|
+
return jsonResult(result);
|
|
869
|
+
}
|
|
870
|
+
const results = await sendCommand(
|
|
871
|
+
"batch_execute",
|
|
872
|
+
{ commands },
|
|
873
|
+
Math.max(3e4, commands.length * 5e3)
|
|
874
|
+
);
|
|
875
|
+
return jsonResult(results);
|
|
876
|
+
} catch (error) {
|
|
877
|
+
return errorResult(error);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// src/tools/core/structure.ts
|
|
884
|
+
var structure_exports = {};
|
|
885
|
+
__export(structure_exports, {
|
|
886
|
+
register: () => register3
|
|
887
|
+
});
|
|
888
|
+
import { z as z4 } from "zod";
|
|
889
|
+
function register3(server, sendCommand) {
|
|
890
|
+
server.tool(
|
|
891
|
+
"edit_structure",
|
|
892
|
+
"Structural operations on nodes: group/ungroup, reparent (move into another parent), reorder (z-order), clone, flatten to vector, boolean operations (union/subtract/intersect/exclude), delete. group/boolean/delete take nodeIds; the rest take nodeId.",
|
|
893
|
+
{
|
|
894
|
+
operation: z4.enum([
|
|
895
|
+
"group",
|
|
896
|
+
"ungroup",
|
|
897
|
+
"reparent",
|
|
898
|
+
"reorder",
|
|
899
|
+
"clone",
|
|
900
|
+
"flatten",
|
|
901
|
+
"boolean",
|
|
902
|
+
"delete"
|
|
903
|
+
]),
|
|
904
|
+
nodeId: z4.string().optional().describe("Target node (ungroup/reparent/reorder/clone/flatten)"),
|
|
905
|
+
nodeIds: z4.array(z4.string()).optional().describe("Target nodes (group/boolean/delete)"),
|
|
906
|
+
parentId: z4.string().optional().describe("reparent: new parent"),
|
|
907
|
+
index: z4.number().int().min(0).optional().describe("reparent: insertion index"),
|
|
908
|
+
direction: reorderDirectionSchema.optional().describe("reorder direction"),
|
|
909
|
+
booleanOperation: booleanOperationSchema.optional().describe("boolean: which operation"),
|
|
910
|
+
x: z4.number().optional().describe("clone: position of the copy"),
|
|
911
|
+
y: z4.number().optional()
|
|
912
|
+
},
|
|
913
|
+
async ({ operation, nodeId, nodeIds, parentId, index, direction, booleanOperation, x, y }) => {
|
|
914
|
+
try {
|
|
915
|
+
const need = (value, what) => {
|
|
916
|
+
if (value === void 0) throw new Error(`"${operation}" requires ${what}`);
|
|
917
|
+
};
|
|
918
|
+
let result;
|
|
919
|
+
switch (operation) {
|
|
920
|
+
case "group":
|
|
921
|
+
need(nodeIds, "nodeIds");
|
|
922
|
+
result = await sendCommand("group_nodes", { nodeIds });
|
|
923
|
+
break;
|
|
924
|
+
case "ungroup":
|
|
925
|
+
need(nodeId, "nodeId");
|
|
926
|
+
result = await sendCommand("ungroup_nodes", { nodeId });
|
|
927
|
+
break;
|
|
928
|
+
case "reparent":
|
|
929
|
+
need(nodeId, "nodeId");
|
|
930
|
+
need(parentId, "parentId");
|
|
931
|
+
result = await sendCommand("reparent_node", { nodeId, parentId, index });
|
|
932
|
+
break;
|
|
933
|
+
case "reorder":
|
|
934
|
+
need(nodeId, "nodeId");
|
|
935
|
+
need(direction, "direction");
|
|
936
|
+
result = await sendCommand("reorder_node", { nodeId, direction });
|
|
937
|
+
break;
|
|
938
|
+
case "clone":
|
|
939
|
+
need(nodeId, "nodeId");
|
|
940
|
+
result = await sendCommand("clone_node", { nodeId, x, y });
|
|
941
|
+
break;
|
|
942
|
+
case "flatten":
|
|
943
|
+
need(nodeId, "nodeId");
|
|
944
|
+
result = await sendCommand("flatten_node", { nodeId });
|
|
945
|
+
break;
|
|
946
|
+
case "boolean":
|
|
947
|
+
need(nodeIds, "nodeIds");
|
|
948
|
+
need(booleanOperation, "booleanOperation");
|
|
949
|
+
result = await sendCommand("boolean_operation", {
|
|
950
|
+
nodeIds,
|
|
951
|
+
operation: booleanOperation
|
|
952
|
+
});
|
|
953
|
+
break;
|
|
954
|
+
case "delete":
|
|
955
|
+
if (nodeIds) result = await sendCommand("delete_multiple_nodes", { nodeIds });
|
|
956
|
+
else {
|
|
957
|
+
need(nodeId, "nodeId or nodeIds");
|
|
958
|
+
result = await sendCommand("delete_node", { nodeId });
|
|
959
|
+
}
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
return jsonResult(result);
|
|
963
|
+
} catch (error) {
|
|
964
|
+
return errorResult(error);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// src/tools/core/text.ts
|
|
971
|
+
var text_exports = {};
|
|
972
|
+
__export(text_exports, {
|
|
973
|
+
register: () => register4
|
|
974
|
+
});
|
|
975
|
+
import { z as z5 } from "zod";
|
|
976
|
+
function register4(server, sendCommand) {
|
|
977
|
+
server.tool(
|
|
978
|
+
"set_text",
|
|
979
|
+
"Edit text nodes. Modes: single (nodeId + text), bulk (items \u2014 efficient for many nodes, e.g. translations), or character-range styling (nodeId + range with fontSize/fontWeight/letterSpacing/lineHeight). Fonts load automatically with fallbacks. To find text nodes first, use search_nodes or get_selection_context.",
|
|
980
|
+
{
|
|
981
|
+
nodeId: z5.string().optional().describe("Target text node (single / range mode)"),
|
|
982
|
+
text: z5.string().optional().describe("New content (single mode)"),
|
|
983
|
+
items: z5.array(z5.object({ nodeId: z5.string(), text: z5.string() })).optional().describe("Bulk replacements"),
|
|
984
|
+
range: z5.object({
|
|
985
|
+
start: z5.number().int().min(0),
|
|
986
|
+
end: z5.number().int().positive(),
|
|
987
|
+
fontSize: z5.number().positive().optional(),
|
|
988
|
+
fontWeight: z5.number().optional(),
|
|
989
|
+
letterSpacing: z5.number().optional(),
|
|
990
|
+
lineHeight: z5.number().optional()
|
|
991
|
+
}).refine((r) => r.end > r.start, { message: "end must be greater than start" }).optional().describe("Style a character range [start, end)")
|
|
992
|
+
},
|
|
993
|
+
async ({ nodeId, text, items, range }) => {
|
|
994
|
+
try {
|
|
995
|
+
if (items?.length) {
|
|
996
|
+
const result = await sendCommand(
|
|
997
|
+
"set_multiple_text_contents",
|
|
998
|
+
{ text: items },
|
|
999
|
+
Math.max(3e4, items.length * 2e3)
|
|
1000
|
+
);
|
|
1001
|
+
return jsonResult(result);
|
|
1002
|
+
}
|
|
1003
|
+
if (nodeId && range) {
|
|
1004
|
+
const result = await sendCommand("set_text_style_range", { nodeId, ...range });
|
|
1005
|
+
return jsonResult(result);
|
|
1006
|
+
}
|
|
1007
|
+
if (nodeId && text !== void 0) {
|
|
1008
|
+
const result = await sendCommand("set_text_content", { nodeId, text });
|
|
1009
|
+
return jsonResult(result);
|
|
1010
|
+
}
|
|
1011
|
+
return textResult(
|
|
1012
|
+
"Provide either items (bulk), nodeId + text (single), or nodeId + range (styling)."
|
|
1013
|
+
);
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
return errorResult(error);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// src/tools/core/components.ts
|
|
1022
|
+
var components_exports = {};
|
|
1023
|
+
__export(components_exports, {
|
|
1024
|
+
register: () => register5
|
|
1025
|
+
});
|
|
1026
|
+
import { z as z6 } from "zod";
|
|
1027
|
+
function register5(server, sendCommand) {
|
|
1028
|
+
server.tool(
|
|
1029
|
+
"manage_components",
|
|
1030
|
+
"Component workflow: list local components, create a component from a node, create_set (combine components as variants), instantiate (place an instance by componentKey \u2014 imports from the team library if needed), get_props / set_props (component properties on an instance), get_overrides / set_overrides (copy overrides from a source instance to targets), reset_instance (clear ALL overrides so the instance re-inherits its main component \u2014 returns property snapshots before/after so you can re-apply what mattered), detach.",
|
|
1031
|
+
{
|
|
1032
|
+
action: z6.enum([
|
|
1033
|
+
"list",
|
|
1034
|
+
"create",
|
|
1035
|
+
"create_set",
|
|
1036
|
+
"instantiate",
|
|
1037
|
+
"get_props",
|
|
1038
|
+
"set_props",
|
|
1039
|
+
"get_overrides",
|
|
1040
|
+
"set_overrides",
|
|
1041
|
+
"reset_instance",
|
|
1042
|
+
"detach"
|
|
1043
|
+
]),
|
|
1044
|
+
nodeId: z6.string().optional().describe("Target node (create/get_props/set_props/reset_instance/detach)"),
|
|
1045
|
+
componentIds: z6.array(z6.string()).optional().describe("create_set: components to combine"),
|
|
1046
|
+
componentKey: z6.string().optional().describe("instantiate: component key (or node id)"),
|
|
1047
|
+
x: z6.number().optional().describe("instantiate: position"),
|
|
1048
|
+
y: z6.number().optional(),
|
|
1049
|
+
properties: z6.record(z6.union([z6.string(), z6.boolean()])).optional().describe("set_props: property name \u2192 value (variant/text/boolean/swap)"),
|
|
1050
|
+
sourceInstanceId: z6.string().optional().describe("set_overrides: copy from this instance"),
|
|
1051
|
+
targetNodeIds: z6.array(z6.string()).optional().describe("set_overrides: apply to these"),
|
|
1052
|
+
instanceNodeId: z6.string().optional().describe("get_overrides: instance to inspect")
|
|
1053
|
+
},
|
|
1054
|
+
async (args) => {
|
|
1055
|
+
try {
|
|
1056
|
+
let result;
|
|
1057
|
+
switch (args.action) {
|
|
1058
|
+
case "list":
|
|
1059
|
+
result = await sendCommand("get_local_components", {}, 6e4);
|
|
1060
|
+
break;
|
|
1061
|
+
case "create":
|
|
1062
|
+
result = await sendCommand("create_component_from_node", { nodeId: args.nodeId });
|
|
1063
|
+
break;
|
|
1064
|
+
case "create_set":
|
|
1065
|
+
result = await sendCommand("create_component_set", { componentIds: args.componentIds });
|
|
1066
|
+
break;
|
|
1067
|
+
case "instantiate":
|
|
1068
|
+
result = await sendCommand("create_component_instance", {
|
|
1069
|
+
componentKey: args.componentKey,
|
|
1070
|
+
x: args.x,
|
|
1071
|
+
y: args.y
|
|
1072
|
+
});
|
|
1073
|
+
break;
|
|
1074
|
+
case "get_props":
|
|
1075
|
+
result = await sendCommand("get_component_properties", { nodeId: args.nodeId });
|
|
1076
|
+
break;
|
|
1077
|
+
case "set_props":
|
|
1078
|
+
result = await sendCommand("set_component_properties", {
|
|
1079
|
+
nodeId: args.nodeId,
|
|
1080
|
+
properties: args.properties
|
|
1081
|
+
});
|
|
1082
|
+
break;
|
|
1083
|
+
case "get_overrides":
|
|
1084
|
+
result = await sendCommand("get_instance_overrides", {
|
|
1085
|
+
instanceNodeId: args.instanceNodeId ?? args.nodeId
|
|
1086
|
+
});
|
|
1087
|
+
break;
|
|
1088
|
+
case "set_overrides":
|
|
1089
|
+
result = await sendCommand("set_instance_overrides", {
|
|
1090
|
+
sourceInstanceId: args.sourceInstanceId,
|
|
1091
|
+
targetNodeIds: args.targetNodeIds
|
|
1092
|
+
});
|
|
1093
|
+
break;
|
|
1094
|
+
case "reset_instance":
|
|
1095
|
+
result = await sendCommand("reset_instance", { nodeId: args.nodeId });
|
|
1096
|
+
break;
|
|
1097
|
+
case "detach":
|
|
1098
|
+
result = await sendCommand("detach_instance", { nodeId: args.nodeId });
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
return jsonResult(result);
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
return errorResult(error);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// src/tools/core/variables.ts
|
|
1110
|
+
var variables_exports = {};
|
|
1111
|
+
__export(variables_exports, {
|
|
1112
|
+
register: () => register6
|
|
1113
|
+
});
|
|
1114
|
+
import { z as z7 } from "zod";
|
|
1115
|
+
var ACTIONS = {
|
|
1116
|
+
list_collections: ["get_variable_collections", []],
|
|
1117
|
+
list: ["get_variables", ["collectionId"]],
|
|
1118
|
+
create_collection: ["create_variable_collection", ["name", "modes"]],
|
|
1119
|
+
update_collection: ["update_variable_collection", ["collectionId", "name", "hiddenFromPublishing"]],
|
|
1120
|
+
delete_collection: ["delete_variable_collection", ["collectionId"]],
|
|
1121
|
+
create: ["create_variable", ["collectionId", "name", "resolvedType", "value"]],
|
|
1122
|
+
update: ["update_variable", ["variableId", "modeId", "value", "name", "description", "hiddenFromPublishing"]],
|
|
1123
|
+
delete: ["delete_variable", ["variableId"]],
|
|
1124
|
+
add_mode: ["add_mode", ["collectionId", "name"]],
|
|
1125
|
+
remove_mode: ["remove_mode", ["collectionId", "modeId"]],
|
|
1126
|
+
rename_mode: ["rename_mode", ["collectionId", "modeId", "name"]],
|
|
1127
|
+
set_scopes: ["set_variable_scopes", ["variableId", "scopes"]],
|
|
1128
|
+
set_code_syntax: ["set_variable_code_syntax", ["variableId", "platform", "value"]],
|
|
1129
|
+
remove_code_syntax: ["remove_variable_code_syntax", ["variableId", "platform"]],
|
|
1130
|
+
create_alias: ["create_variable_alias", ["variableId", "targetVariableId", "modeId"]],
|
|
1131
|
+
bind: ["bind_variable", ["nodeId", "variableId", "property"]],
|
|
1132
|
+
unbind: ["unbind_variable", ["nodeId", "property"]],
|
|
1133
|
+
set_node_mode: ["set_node_variable_mode", ["nodeId", "collectionId", "modeId"]],
|
|
1134
|
+
get_node_modes: ["get_resolved_variable_modes", ["nodeId"]],
|
|
1135
|
+
tokenize: ["scan_token_drift", ["nodeId", "fix", "tolerance"]]
|
|
1136
|
+
};
|
|
1137
|
+
function register6(server, sendCommand) {
|
|
1138
|
+
server.tool(
|
|
1139
|
+
"manage_variables",
|
|
1140
|
+
"Design-token variables: list_collections / list (variables in a collection) / create_collection / update_collection / delete_collection / create / update / delete / add_mode / remove_mode / rename_mode / set_scopes / set_code_syntax / remove_code_syntax / create_alias / bind (variable\u2192node property) / unbind / set_node_mode / get_node_modes / tokenize (find hardcoded colors & numbers that match existing variables and bind them \u2014 fix:false to preview, fix:true to apply; scope with nodeId, default current page). Pass only the fields the action needs.",
|
|
1141
|
+
{
|
|
1142
|
+
action: z7.enum(Object.keys(ACTIONS)),
|
|
1143
|
+
collectionId: z7.string().optional(),
|
|
1144
|
+
variableId: z7.string().optional(),
|
|
1145
|
+
targetVariableId: z7.string().optional().describe("create_alias: variable to alias to"),
|
|
1146
|
+
nodeId: z7.string().optional().describe("bind/unbind/set_node_mode/get_node_modes"),
|
|
1147
|
+
name: z7.string().optional(),
|
|
1148
|
+
description: z7.string().optional(),
|
|
1149
|
+
modes: z7.array(z7.string()).optional().describe("create_collection: mode names"),
|
|
1150
|
+
modeId: z7.string().optional(),
|
|
1151
|
+
resolvedType: z7.enum(["COLOR", "FLOAT", "STRING", "BOOLEAN"]).optional(),
|
|
1152
|
+
value: z7.unknown().optional().describe("Variable value (color object, number, string, bool)"),
|
|
1153
|
+
property: z7.string().optional().describe('bind/unbind: node property (e.g. "fills", "width")'),
|
|
1154
|
+
scopes: z7.array(z7.string()).optional(),
|
|
1155
|
+
platform: z7.enum(["WEB", "ANDROID", "iOS"]).optional(),
|
|
1156
|
+
hiddenFromPublishing: z7.boolean().optional(),
|
|
1157
|
+
fix: z7.boolean().optional().describe("tokenize: apply the bindings (false = report only)"),
|
|
1158
|
+
tolerance: z7.number().optional().describe("tokenize: OKLab \u0394E for color matches (default 0.02 \u2248 visually identical)")
|
|
1159
|
+
},
|
|
1160
|
+
async (args) => {
|
|
1161
|
+
try {
|
|
1162
|
+
const [command, fields] = ACTIONS[args.action];
|
|
1163
|
+
const params = Object.fromEntries(
|
|
1164
|
+
fields.map((f) => [f, args[f]]).filter(([, v]) => v !== void 0)
|
|
1165
|
+
);
|
|
1166
|
+
const result = await sendCommand(
|
|
1167
|
+
command,
|
|
1168
|
+
params,
|
|
1169
|
+
args.action === "tokenize" ? 12e4 : void 0
|
|
1170
|
+
);
|
|
1171
|
+
return jsonResult(result);
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
return errorResult(error);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// src/tools/core/styles.ts
|
|
1180
|
+
var styles_exports = {};
|
|
1181
|
+
__export(styles_exports, {
|
|
1182
|
+
register: () => register7
|
|
1183
|
+
});
|
|
1184
|
+
import { z as z8 } from "zod";
|
|
1185
|
+
function register7(server, sendCommand) {
|
|
1186
|
+
server.tool(
|
|
1187
|
+
"manage_styles",
|
|
1188
|
+
"Shared styles: list all local styles; create_paint / create_text / create_effect / create_grid; update (name/properties); delete; apply (style to a node); reorder. To bind variables instead of styles, use manage_variables.",
|
|
1189
|
+
{
|
|
1190
|
+
action: z8.enum([
|
|
1191
|
+
"list",
|
|
1192
|
+
"create_paint",
|
|
1193
|
+
"create_text",
|
|
1194
|
+
"create_effect",
|
|
1195
|
+
"create_grid",
|
|
1196
|
+
"update",
|
|
1197
|
+
"delete",
|
|
1198
|
+
"apply",
|
|
1199
|
+
"reorder"
|
|
1200
|
+
]),
|
|
1201
|
+
name: z8.string().optional().describe("Style name (create_*/update)"),
|
|
1202
|
+
styleId: z8.string().optional().describe("Target style (update/delete/apply/reorder)"),
|
|
1203
|
+
styleType: z8.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional().describe("apply/reorder"),
|
|
1204
|
+
nodeId: z8.string().optional().describe("apply: target node"),
|
|
1205
|
+
color: colorSchema.optional().describe("create_paint/create_effect"),
|
|
1206
|
+
gradientType: gradientTypeSchema.optional(),
|
|
1207
|
+
gradientStops: z8.array(gradientStopSchema).optional(),
|
|
1208
|
+
fontFamily: z8.string().optional().describe("create_text"),
|
|
1209
|
+
fontSize: z8.number().positive().optional(),
|
|
1210
|
+
fontWeight: z8.number().optional(),
|
|
1211
|
+
lineHeight: z8.number().optional(),
|
|
1212
|
+
letterSpacing: z8.number().optional(),
|
|
1213
|
+
effectType: z8.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]).optional().describe("create_effect"),
|
|
1214
|
+
offsetX: z8.number().optional(),
|
|
1215
|
+
offsetY: z8.number().optional(),
|
|
1216
|
+
radius: z8.number().min(0).optional(),
|
|
1217
|
+
spread: z8.number().optional(),
|
|
1218
|
+
grids: z8.array(z8.record(z8.unknown())).optional().describe("create_grid: layout grid objects"),
|
|
1219
|
+
properties: z8.record(z8.unknown()).optional().describe("update: properties to change"),
|
|
1220
|
+
afterStyleId: z8.string().optional().describe("reorder: place after this style")
|
|
1221
|
+
},
|
|
1222
|
+
async (args) => {
|
|
1223
|
+
try {
|
|
1224
|
+
let result;
|
|
1225
|
+
switch (args.action) {
|
|
1226
|
+
case "list":
|
|
1227
|
+
result = await sendCommand("get_styles", {});
|
|
1228
|
+
break;
|
|
1229
|
+
case "create_paint":
|
|
1230
|
+
result = await sendCommand("create_paint_style", {
|
|
1231
|
+
name: args.name,
|
|
1232
|
+
color: args.color,
|
|
1233
|
+
gradientType: args.gradientType,
|
|
1234
|
+
gradientStops: args.gradientStops
|
|
1235
|
+
});
|
|
1236
|
+
break;
|
|
1237
|
+
case "create_text":
|
|
1238
|
+
result = await sendCommand("create_text_style", {
|
|
1239
|
+
name: args.name,
|
|
1240
|
+
fontFamily: args.fontFamily,
|
|
1241
|
+
fontSize: args.fontSize,
|
|
1242
|
+
fontWeight: args.fontWeight,
|
|
1243
|
+
lineHeight: args.lineHeight,
|
|
1244
|
+
letterSpacing: args.letterSpacing
|
|
1245
|
+
});
|
|
1246
|
+
break;
|
|
1247
|
+
case "create_effect":
|
|
1248
|
+
result = await sendCommand("create_effect_style", {
|
|
1249
|
+
name: args.name,
|
|
1250
|
+
effectType: args.effectType,
|
|
1251
|
+
color: args.color,
|
|
1252
|
+
offsetX: args.offsetX,
|
|
1253
|
+
offsetY: args.offsetY,
|
|
1254
|
+
radius: args.radius,
|
|
1255
|
+
spread: args.spread
|
|
1256
|
+
});
|
|
1257
|
+
break;
|
|
1258
|
+
case "create_grid":
|
|
1259
|
+
result = await sendCommand("create_grid_style", { name: args.name, grids: args.grids });
|
|
1260
|
+
break;
|
|
1261
|
+
case "update":
|
|
1262
|
+
result = await sendCommand("update_style", {
|
|
1263
|
+
styleId: args.styleId,
|
|
1264
|
+
name: args.name,
|
|
1265
|
+
properties: args.properties
|
|
1266
|
+
});
|
|
1267
|
+
break;
|
|
1268
|
+
case "delete":
|
|
1269
|
+
result = await sendCommand("delete_style", { styleId: args.styleId });
|
|
1270
|
+
break;
|
|
1271
|
+
case "apply":
|
|
1272
|
+
result = await sendCommand("apply_style", {
|
|
1273
|
+
nodeId: args.nodeId,
|
|
1274
|
+
styleId: args.styleId,
|
|
1275
|
+
styleType: args.styleType
|
|
1276
|
+
});
|
|
1277
|
+
break;
|
|
1278
|
+
case "reorder":
|
|
1279
|
+
result = await sendCommand("reorder_style", {
|
|
1280
|
+
styleId: args.styleId,
|
|
1281
|
+
afterStyleId: args.afterStyleId,
|
|
1282
|
+
styleType: args.styleType
|
|
1283
|
+
});
|
|
1284
|
+
break;
|
|
1285
|
+
}
|
|
1286
|
+
return jsonResult(result);
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
return errorResult(error);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// src/tools/core/pages.ts
|
|
1295
|
+
var pages_exports = {};
|
|
1296
|
+
__export(pages_exports, {
|
|
1297
|
+
register: () => register8
|
|
1298
|
+
});
|
|
1299
|
+
import { z as z9 } from "zod";
|
|
1300
|
+
function register8(server, sendCommand) {
|
|
1301
|
+
server.tool(
|
|
1302
|
+
"manage_pages",
|
|
1303
|
+
"Page operations: list all pages, create/rename/delete a page, or set a page's background color. To make a page current, use navigate with switch_page.",
|
|
1304
|
+
{
|
|
1305
|
+
action: z9.enum(["list", "create", "rename", "delete", "set_background"]),
|
|
1306
|
+
pageId: z9.string().optional().describe("Target page (rename/delete/set_background)"),
|
|
1307
|
+
name: z9.string().optional().describe("Page name (create/rename)"),
|
|
1308
|
+
color: colorSchema.optional().describe("Background color (set_background)")
|
|
1309
|
+
},
|
|
1310
|
+
async ({ action, pageId, name, color }) => {
|
|
1311
|
+
try {
|
|
1312
|
+
let result;
|
|
1313
|
+
switch (action) {
|
|
1314
|
+
case "list":
|
|
1315
|
+
result = await sendCommand("get_pages", {});
|
|
1316
|
+
break;
|
|
1317
|
+
case "create":
|
|
1318
|
+
result = await sendCommand("create_page", { name });
|
|
1319
|
+
break;
|
|
1320
|
+
case "rename":
|
|
1321
|
+
result = await sendCommand("rename_page", { pageId, name });
|
|
1322
|
+
break;
|
|
1323
|
+
case "delete":
|
|
1324
|
+
result = await sendCommand("delete_page", { pageId });
|
|
1325
|
+
break;
|
|
1326
|
+
case "set_background":
|
|
1327
|
+
result = await sendCommand("set_page_background", { pageId, color });
|
|
1328
|
+
break;
|
|
1329
|
+
}
|
|
1330
|
+
return jsonResult(result);
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
return errorResult(error);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// src/tools/core/navigate.ts
|
|
1339
|
+
var navigate_exports = {};
|
|
1340
|
+
__export(navigate_exports, {
|
|
1341
|
+
register: () => register9
|
|
1342
|
+
});
|
|
1343
|
+
import { z as z10 } from "zod";
|
|
1344
|
+
function register9(server, sendCommand) {
|
|
1345
|
+
server.tool(
|
|
1346
|
+
"navigate",
|
|
1347
|
+
"Control the designer's view: focus (scroll+zoom to a node), select (set the selection), get_viewport / set_viewport (x/y/zoom), or switch_page.",
|
|
1348
|
+
{
|
|
1349
|
+
action: z10.enum(["focus", "select", "get_viewport", "set_viewport", "switch_page"]),
|
|
1350
|
+
nodeId: z10.string().optional().describe("focus target"),
|
|
1351
|
+
nodeIds: z10.array(z10.string()).optional().describe("select targets"),
|
|
1352
|
+
x: z10.number().optional().describe("set_viewport center x"),
|
|
1353
|
+
y: z10.number().optional(),
|
|
1354
|
+
zoom: z10.number().positive().optional(),
|
|
1355
|
+
pageId: z10.string().optional().describe("switch_page target")
|
|
1356
|
+
},
|
|
1357
|
+
async ({ action, nodeId, nodeIds, x, y, zoom, pageId }) => {
|
|
1358
|
+
try {
|
|
1359
|
+
let result;
|
|
1360
|
+
switch (action) {
|
|
1361
|
+
case "focus":
|
|
1362
|
+
result = await sendCommand("set_focus", { nodeId });
|
|
1363
|
+
break;
|
|
1364
|
+
case "select":
|
|
1365
|
+
result = await sendCommand("set_selections", { nodeIds });
|
|
1366
|
+
break;
|
|
1367
|
+
case "get_viewport":
|
|
1368
|
+
result = await sendCommand("get_viewport", {});
|
|
1369
|
+
break;
|
|
1370
|
+
case "set_viewport":
|
|
1371
|
+
result = await sendCommand("set_viewport", { x, y, zoom });
|
|
1372
|
+
break;
|
|
1373
|
+
case "switch_page":
|
|
1374
|
+
result = await sendCommand("switch_page", { pageId });
|
|
1375
|
+
break;
|
|
1376
|
+
}
|
|
1377
|
+
return jsonResult(result);
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
return errorResult(error);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// src/tools/core/assets.ts
|
|
1386
|
+
var assets_exports = {};
|
|
1387
|
+
__export(assets_exports, {
|
|
1388
|
+
register: () => register10
|
|
1389
|
+
});
|
|
1390
|
+
import { z as z11 } from "zod";
|
|
1391
|
+
function register10(server, sendCommand) {
|
|
1392
|
+
server.tool(
|
|
1393
|
+
"export_asset",
|
|
1394
|
+
"Export a node as PNG/JPG/SVG/PDF. Returns base64 data for saving to disk. For a quick visual check of your work, prefer the screenshot tool (returns a viewable image).",
|
|
1395
|
+
{
|
|
1396
|
+
nodeId: z11.string().describe("Node to export"),
|
|
1397
|
+
format: exportFormatSchema.optional().describe("Default PNG"),
|
|
1398
|
+
scale: z11.number().positive().optional().describe("Export scale (default 1)")
|
|
1399
|
+
},
|
|
1400
|
+
{ readOnlyHint: true },
|
|
1401
|
+
async ({ nodeId, format, scale }) => {
|
|
1402
|
+
try {
|
|
1403
|
+
const result = await sendCommand("export_node_as_image", { nodeId, format, scale }, 6e4);
|
|
1404
|
+
return jsonResult(result);
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
return errorResult(error);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
);
|
|
1410
|
+
server.tool(
|
|
1411
|
+
"add_image",
|
|
1412
|
+
"Place an image: from a URL (creates a new image node) or as a fill on an existing node (base64 imageData). scaleMode controls how the image fits.",
|
|
1413
|
+
{
|
|
1414
|
+
url: z11.string().optional().describe("Image URL \u2014 creates a new node"),
|
|
1415
|
+
nodeId: z11.string().optional().describe("Existing node \u2014 sets its fill from imageData"),
|
|
1416
|
+
imageData: z11.string().optional().describe("Base64 image data (with nodeId)"),
|
|
1417
|
+
x: z11.number().optional(),
|
|
1418
|
+
y: z11.number().optional(),
|
|
1419
|
+
width: z11.number().positive().optional(),
|
|
1420
|
+
height: z11.number().positive().optional(),
|
|
1421
|
+
name: z11.string().optional(),
|
|
1422
|
+
scaleMode: z11.enum(["FILL", "FIT", "CROP", "TILE"]).optional()
|
|
1423
|
+
},
|
|
1424
|
+
async ({ url, nodeId, imageData, x, y, width, height, name, scaleMode }) => {
|
|
1425
|
+
try {
|
|
1426
|
+
if (url) {
|
|
1427
|
+
const result = await sendCommand(
|
|
1428
|
+
"create_image_from_url",
|
|
1429
|
+
{ url, x, y, width, height, name, scaleMode },
|
|
1430
|
+
6e4
|
|
1431
|
+
);
|
|
1432
|
+
return jsonResult(result);
|
|
1433
|
+
}
|
|
1434
|
+
if (nodeId && imageData) {
|
|
1435
|
+
const result = await sendCommand("set_image_fill", { nodeId, imageData, scaleMode }, 6e4);
|
|
1436
|
+
return jsonResult(result);
|
|
1437
|
+
}
|
|
1438
|
+
return textResult("Provide either url (new image node) or nodeId + imageData (image fill).");
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
return errorResult(error);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// src/tools/core/library.ts
|
|
1447
|
+
var library_exports = {};
|
|
1448
|
+
__export(library_exports, {
|
|
1449
|
+
register: () => register11
|
|
1450
|
+
});
|
|
1451
|
+
import { z as z12 } from "zod";
|
|
1452
|
+
function register11(server, sendCommand) {
|
|
1453
|
+
server.tool(
|
|
1454
|
+
"import_from_library",
|
|
1455
|
+
"Import a component, style, or variable from an enabled team library by its key. Component keys come from the design system or get_local_components; imported components can then be instantiated with manage_components.",
|
|
1456
|
+
{
|
|
1457
|
+
kind: z12.enum(["component", "style", "variable"]),
|
|
1458
|
+
key: z12.string().describe("Library key")
|
|
1459
|
+
},
|
|
1460
|
+
async ({ kind, key }) => {
|
|
1461
|
+
try {
|
|
1462
|
+
const command = kind === "component" ? "import_component_by_key" : kind === "style" ? "import_style_by_key" : "import_variable_by_key";
|
|
1463
|
+
const result = await sendCommand(command, { key }, 6e4);
|
|
1464
|
+
return jsonResult(result);
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
return errorResult(error);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// src/tools/core/annotate.ts
|
|
1473
|
+
var annotate_exports = {};
|
|
1474
|
+
__export(annotate_exports, {
|
|
1475
|
+
register: () => register12
|
|
1476
|
+
});
|
|
1477
|
+
import { z as z13 } from "zod";
|
|
1478
|
+
function register12(server, sendCommand) {
|
|
1479
|
+
server.tool(
|
|
1480
|
+
"annotate",
|
|
1481
|
+
"Read or write Dev Mode annotations. get: read annotations on a node. set: add/update one annotation (labelMarkdown). set_multiple: batch annotations.",
|
|
1482
|
+
{
|
|
1483
|
+
action: z13.enum(["get", "set", "set_multiple"]),
|
|
1484
|
+
nodeId: z13.string().optional().describe("Target node (get/set)"),
|
|
1485
|
+
labelMarkdown: z13.string().optional().describe("Annotation text (set)"),
|
|
1486
|
+
categoryId: z13.string().optional(),
|
|
1487
|
+
annotations: z13.array(
|
|
1488
|
+
z13.object({
|
|
1489
|
+
nodeId: z13.string(),
|
|
1490
|
+
labelMarkdown: z13.string(),
|
|
1491
|
+
categoryId: z13.string().optional()
|
|
1492
|
+
})
|
|
1493
|
+
).optional().describe("Batch annotations (set_multiple)")
|
|
1494
|
+
},
|
|
1495
|
+
async ({ action, nodeId, labelMarkdown, categoryId, annotations }) => {
|
|
1496
|
+
try {
|
|
1497
|
+
if (action === "get") {
|
|
1498
|
+
const result2 = await sendCommand("get_annotations", { nodeId });
|
|
1499
|
+
return jsonResult(result2);
|
|
1500
|
+
}
|
|
1501
|
+
if (action === "set") {
|
|
1502
|
+
if (!nodeId || labelMarkdown === void 0)
|
|
1503
|
+
return textResult("set requires nodeId and labelMarkdown.");
|
|
1504
|
+
const result2 = await sendCommand("set_annotation", { nodeId, labelMarkdown, categoryId });
|
|
1505
|
+
return jsonResult(result2);
|
|
1506
|
+
}
|
|
1507
|
+
if (!annotations?.length) return textResult("set_multiple requires annotations.");
|
|
1508
|
+
const result = await sendCommand("set_multiple_annotations", { annotations });
|
|
1509
|
+
return jsonResult(result);
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
return errorResult(error);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// src/tools/core/read.ts
|
|
1518
|
+
var read_exports = {};
|
|
1519
|
+
__export(read_exports, {
|
|
1520
|
+
register: () => register13
|
|
1521
|
+
});
|
|
1522
|
+
import { z as z14 } from "zod";
|
|
1523
|
+
function register13(server, sendCommand) {
|
|
1524
|
+
server.tool(
|
|
1525
|
+
"get_node_data",
|
|
1526
|
+
"Raw node data at a chosen detail level: summary (shallow), tree (sparse structure for orientation), full (deeper serialization), css (CSS representation), variables (bound variable ids). For human-oriented summaries prefer get_node_details / get_selection_context.",
|
|
1527
|
+
{
|
|
1528
|
+
nodeIds: z14.array(z14.string()).min(1).describe("One or more node ids"),
|
|
1529
|
+
detail: z14.enum(["summary", "tree", "full", "css", "variables"]).optional().describe("Default summary"),
|
|
1530
|
+
depth: z14.number().int().min(0).optional().describe("Override serialization depth"),
|
|
1531
|
+
maxNodes: z14.number().int().positive().optional().describe("Override node budget")
|
|
1532
|
+
},
|
|
1533
|
+
{ readOnlyHint: true },
|
|
1534
|
+
async ({ nodeIds, detail = "summary", depth, maxNodes }) => {
|
|
1535
|
+
try {
|
|
1536
|
+
if (detail === "css" || detail === "variables") {
|
|
1537
|
+
const command = detail === "css" ? "get_css" : "get_bound_variables";
|
|
1538
|
+
const results = [];
|
|
1539
|
+
for (const nodeId of nodeIds) {
|
|
1540
|
+
results.push({ nodeId, data: await sendCommand(command, { nodeId }) });
|
|
1541
|
+
}
|
|
1542
|
+
return jsonResult(nodeIds.length === 1 ? results[0].data : results);
|
|
1543
|
+
}
|
|
1544
|
+
if (detail === "tree") {
|
|
1545
|
+
const results = [];
|
|
1546
|
+
for (const nodeId of nodeIds) {
|
|
1547
|
+
results.push(await sendCommand("get_node_tree", { nodeId, depth, maxNodes }));
|
|
1548
|
+
}
|
|
1549
|
+
return jsonResult(nodeIds.length === 1 ? results[0] : results);
|
|
1550
|
+
}
|
|
1551
|
+
if (nodeIds.length > 1) {
|
|
1552
|
+
const result2 = await sendCommand("get_nodes_info", { nodeIds });
|
|
1553
|
+
return jsonResult(result2);
|
|
1554
|
+
}
|
|
1555
|
+
const result = await sendCommand("get_node_info", {
|
|
1556
|
+
nodeId: nodeIds[0],
|
|
1557
|
+
depth: depth ?? (detail === "full" ? 4 : 1),
|
|
1558
|
+
maxNodes
|
|
1559
|
+
});
|
|
1560
|
+
return jsonResult(result);
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
return errorResult(error);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// src/tools/core/execute.ts
|
|
1569
|
+
var execute_exports = {};
|
|
1570
|
+
__export(execute_exports, {
|
|
1571
|
+
register: () => register14
|
|
1572
|
+
});
|
|
1573
|
+
import { z as z15 } from "zod";
|
|
1574
|
+
function register14(server, sendCommand) {
|
|
1575
|
+
server.tool(
|
|
1576
|
+
"execute_figma",
|
|
1577
|
+
"Run JavaScript against the Figma Plugin API inside the plugin sandbox \u2014 the escape hatch for anything the other tools don't cover. The code runs in an async function with `figma` AND `relai` in scope. relai helpers avoid the classic pitfalls: relai.text(parent, chars, {font,size,color}) loads fonts first; relai.autoLayout(direction, props) makes a hugging auto-layout frame; relai.set(node, props) applies layoutMode first and routes width/height through resize; relai.hug(node); relai.focusRing(node); await relai.page(p => ...) finds pages by content, not name; relai.query('FRAME[name^=Card] > TEXT') is a CSS-like search (types, name matchers, descendant/child, comma); relai.placeholder(node) shows a construction veil \u2014 remove with (node, false) when done. Errors carry a Hint with the fix; scripts are NOT atomic \u2014 on error, partial changes persist, so keep scripts small and idempotent. Nodes created via relai plus any node ids you RETURN are linted for silent mistakes (e.g. spread shadows without clipsContent) and come back as `warnings`. Return ALL created/mutated node ids. Work incrementally; verify with screenshot. When BUILDING UI: call get_design_system first and instantiate the file's own components / bind its variables instead of drawing raw shapes \u2014 hand-drawn near-copies of existing components read as drift to the design team. The designer can disable this tool via the plugin's 'Allow code execution' toggle.",
|
|
1578
|
+
{
|
|
1579
|
+
code: z15.string().describe("JavaScript source. May use await. Return a JSON-serializable value."),
|
|
1580
|
+
description: z15.string().optional().describe("One line shown in the plugin's activity feed describing what this does"),
|
|
1581
|
+
timeoutMs: z15.number().int().min(1e3).max(3e5).optional().describe("Execution timeout in ms (default 60000) \u2014 raise for scripts creating hundreds of nodes")
|
|
1582
|
+
},
|
|
1583
|
+
async ({ code, description, timeoutMs }) => {
|
|
1584
|
+
try {
|
|
1585
|
+
const result = await sendCommand("execute_code", { code, description }, timeoutMs ?? 6e4);
|
|
1586
|
+
return jsonResult(result);
|
|
1587
|
+
} catch (error) {
|
|
1588
|
+
return errorResult(error);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// src/tools/core/comments.ts
|
|
1595
|
+
var comments_exports = {};
|
|
1596
|
+
__export(comments_exports, {
|
|
1597
|
+
parseFileKey: () => parseFileKey,
|
|
1598
|
+
register: () => register15
|
|
1599
|
+
});
|
|
1600
|
+
import { z as z16 } from "zod";
|
|
1601
|
+
var API = "https://api.figma.com/v1";
|
|
1602
|
+
function parseFileKey(input) {
|
|
1603
|
+
const url = input.match(/figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/);
|
|
1604
|
+
if (url) return url[1];
|
|
1605
|
+
if (/^[A-Za-z0-9]{15,}$/.test(input)) return input;
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
function compactComment(c) {
|
|
1609
|
+
return {
|
|
1610
|
+
id: c.id,
|
|
1611
|
+
message: c.message,
|
|
1612
|
+
author: c.user?.handle,
|
|
1613
|
+
created_at: c.created_at,
|
|
1614
|
+
...c.resolved_at ? { resolved: true } : {},
|
|
1615
|
+
...c.parent_id ? { replyTo: c.parent_id } : {},
|
|
1616
|
+
...c.client_meta?.node_id ? { nodeId: c.client_meta.node_id } : {}
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
function register15(server, sendCommand) {
|
|
1620
|
+
server.tool(
|
|
1621
|
+
"manage_comments",
|
|
1622
|
+
"Read and write comments on the Figma file: list (with node anchors \u2014 great for 'apply the feedback in the comments' or polling for designer requests; filter with unresolved:true and since:<ISO date>), add (optionally pinned to a node), reply, delete. Comment-driven workflow: designers leave asks as comments in Figma; poll list (unresolved + since last check), do the work, then reply on the thread with what was done. Requires a FIGMA_TOKEN env var (personal access token with comment scopes, generated at figma.com Settings \u2192 Security); the canvas tools work without it. The file is auto-detected from the open plugin when possible \u2014 otherwise pass fileUrl.",
|
|
1623
|
+
{
|
|
1624
|
+
action: z16.enum(["list", "add", "reply", "delete"]),
|
|
1625
|
+
fileUrl: z16.string().optional().describe("Figma file URL or key (auto-detected when omitted)"),
|
|
1626
|
+
message: z16.string().optional().describe("Comment text (add/reply)"),
|
|
1627
|
+
commentId: z16.string().optional().describe("Target comment (reply/delete)"),
|
|
1628
|
+
nodeId: z16.string().optional().describe("add: pin the comment to this node"),
|
|
1629
|
+
x: z16.number().optional().describe("add: canvas position (with y, when not pinning to a node)"),
|
|
1630
|
+
y: z16.number().optional(),
|
|
1631
|
+
since: z16.string().optional().describe("list: only comments created after this ISO 8601 timestamp"),
|
|
1632
|
+
unresolved: z16.boolean().optional().describe("list: only unresolved threads")
|
|
1633
|
+
},
|
|
1634
|
+
async ({ action, fileUrl, message, commentId, nodeId, x, y, since, unresolved }) => {
|
|
1635
|
+
const token = process.env.FIGMA_TOKEN;
|
|
1636
|
+
if (!token) {
|
|
1637
|
+
return textResult(
|
|
1638
|
+
'Comments need a Figma personal access token. Generate one at figma.com \u2192 Settings \u2192 Security \u2192 Personal access tokens (enable comment scopes), then add it to the MCP config: "env": { "FIGMA_TOKEN": "figd_..." } and restart. Everything else works without it.'
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
try {
|
|
1642
|
+
let fileKey = fileUrl ? parseFileKey(fileUrl) : null;
|
|
1643
|
+
if (fileUrl && !fileKey) {
|
|
1644
|
+
return textResult(`Could not extract a file key from "${fileUrl}" \u2014 pass a figma.com/design/... URL.`);
|
|
1645
|
+
}
|
|
1646
|
+
if (!fileKey) {
|
|
1647
|
+
const info = await sendCommand("get_file_info", {});
|
|
1648
|
+
fileKey = info?.fileKey ?? null;
|
|
1649
|
+
}
|
|
1650
|
+
if (!fileKey) {
|
|
1651
|
+
return textResult(
|
|
1652
|
+
"Figma doesn't expose this file's key to the plugin. Pass fileUrl with the file's figma.com URL (copy it from the browser or Share dialog)."
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
const request = async (method, path, body) => {
|
|
1656
|
+
const res = await fetch(`${API}${path}`, {
|
|
1657
|
+
method,
|
|
1658
|
+
headers: {
|
|
1659
|
+
"X-Figma-Token": token,
|
|
1660
|
+
...body ? { "Content-Type": "application/json" } : {}
|
|
1661
|
+
},
|
|
1662
|
+
...body ? { body: JSON.stringify(body) } : {}
|
|
1663
|
+
});
|
|
1664
|
+
const json = await res.json().catch(() => ({}));
|
|
1665
|
+
if (!res.ok) {
|
|
1666
|
+
throw new Error(
|
|
1667
|
+
`Figma API ${res.status}: ${json.err ?? json.message ?? "request failed"}` + (res.status === 403 ? " \u2014 check the token's comment scopes and file access" : "")
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
return json;
|
|
1671
|
+
};
|
|
1672
|
+
switch (action) {
|
|
1673
|
+
case "list": {
|
|
1674
|
+
const data = await request("GET", `/files/${fileKey}/comments`);
|
|
1675
|
+
let raw = data.comments ?? [];
|
|
1676
|
+
if (unresolved) raw = raw.filter((c) => !c.resolved_at);
|
|
1677
|
+
if (since) {
|
|
1678
|
+
const cutoff = Date.parse(since);
|
|
1679
|
+
if (Number.isNaN(cutoff)) {
|
|
1680
|
+
return textResult(`since "${since}" is not a parseable ISO 8601 timestamp.`);
|
|
1681
|
+
}
|
|
1682
|
+
raw = raw.filter((c) => c.created_at && Date.parse(c.created_at) > cutoff);
|
|
1683
|
+
}
|
|
1684
|
+
const comments = raw.slice(0, 100).map(compactComment);
|
|
1685
|
+
return jsonResult({ count: comments.length, comments, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1686
|
+
}
|
|
1687
|
+
case "add": {
|
|
1688
|
+
if (!message) return textResult("add requires message.");
|
|
1689
|
+
const client_meta = nodeId ? { node_id: nodeId, node_offset: { x: x ?? 0, y: y ?? 0 } } : x !== void 0 && y !== void 0 ? { x, y } : void 0;
|
|
1690
|
+
const data = await request("POST", `/files/${fileKey}/comments`, {
|
|
1691
|
+
message,
|
|
1692
|
+
...client_meta ? { client_meta } : {}
|
|
1693
|
+
});
|
|
1694
|
+
return jsonResult(compactComment(data));
|
|
1695
|
+
}
|
|
1696
|
+
case "reply": {
|
|
1697
|
+
if (!message || !commentId) return textResult("reply requires commentId and message.");
|
|
1698
|
+
const data = await request("POST", `/files/${fileKey}/comments`, {
|
|
1699
|
+
message,
|
|
1700
|
+
comment_id: commentId
|
|
1701
|
+
});
|
|
1702
|
+
return jsonResult(compactComment(data));
|
|
1703
|
+
}
|
|
1704
|
+
case "delete": {
|
|
1705
|
+
if (!commentId) return textResult("delete requires commentId.");
|
|
1706
|
+
await request("DELETE", `/files/${fileKey}/comments/${commentId}`);
|
|
1707
|
+
return textResult(`Comment ${commentId} deleted.`);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
return errorResult(error);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// src/tools/core/design-system.ts
|
|
1718
|
+
var design_system_exports = {};
|
|
1719
|
+
__export(design_system_exports, {
|
|
1720
|
+
register: () => register16
|
|
1721
|
+
});
|
|
1722
|
+
import { z as z17 } from "zod";
|
|
1723
|
+
function register16(server, sendCommand) {
|
|
1724
|
+
server.tool(
|
|
1725
|
+
"get_design_system",
|
|
1726
|
+
"Inventory the design system available to this file \u2014 call this BEFORE building UI, then prefer instantiating existing components (manage_components action:instantiate takes local and library keys) and binding existing variables over drawing raw shapes. Reports: local components/styles/variable collections with usage counts, remote components/styles the file already uses, and enabled libraries' variable collections. To list a library's full component catalog, pass libraryFileUrl (requires FIGMA_TOKEN). Results are cached per session \u2014 pass refresh:true after big library changes.",
|
|
1727
|
+
{
|
|
1728
|
+
refresh: z17.boolean().optional().describe("Rescan instead of using the session cache"),
|
|
1729
|
+
libraryFileUrl: z17.string().optional().describe(
|
|
1730
|
+
"figma.com URL or key of a LIBRARY file to catalog via REST (needs FIGMA_TOKEN)"
|
|
1731
|
+
)
|
|
1732
|
+
},
|
|
1733
|
+
{ readOnlyHint: true },
|
|
1734
|
+
async ({ refresh, libraryFileUrl }) => {
|
|
1735
|
+
try {
|
|
1736
|
+
const data = await sendCommand(
|
|
1737
|
+
"get_design_system",
|
|
1738
|
+
{ refresh: refresh ?? false },
|
|
1739
|
+
12e4
|
|
1740
|
+
);
|
|
1741
|
+
if (libraryFileUrl) {
|
|
1742
|
+
data.libraryCatalog = await fetchLibraryCatalog(libraryFileUrl);
|
|
1743
|
+
}
|
|
1744
|
+
return jsonResult(data);
|
|
1745
|
+
} catch (error) {
|
|
1746
|
+
return errorResult(error);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
);
|
|
1750
|
+
server.tool(
|
|
1751
|
+
"manage_conventions",
|
|
1752
|
+
"File-level design conventions \u2014 a CLAUDE.md that lives INSIDE this Figma file (shared plugin data, travels with the file). action:get reads it, action:set overwrites it with markdown. get_document_overview auto-includes it, and whatever it says (naming rules, spacing habits, do-not-touch areas, library preferences) should be FOLLOWED like user instructions. When the designer states a durable preference ('always use our green', 'never touch the Archive page'), offer to record it here so every future session \u2014 from any AI client \u2014 inherits it.",
|
|
1753
|
+
{
|
|
1754
|
+
action: z17.enum(["get", "set"]),
|
|
1755
|
+
content: z17.string().optional().describe("set: the full markdown doc (overwrites; max 20k chars)")
|
|
1756
|
+
},
|
|
1757
|
+
async ({ action, content }) => {
|
|
1758
|
+
try {
|
|
1759
|
+
if (action === "set") {
|
|
1760
|
+
return jsonResult(await sendCommand("set_conventions", { content: content ?? "" }));
|
|
1761
|
+
}
|
|
1762
|
+
return jsonResult(await sendCommand("get_conventions", {}));
|
|
1763
|
+
} catch (error) {
|
|
1764
|
+
return errorResult(error);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
async function fetchLibraryCatalog(libraryFileUrl) {
|
|
1770
|
+
const token = process.env.FIGMA_TOKEN;
|
|
1771
|
+
if (!token) {
|
|
1772
|
+
return {
|
|
1773
|
+
note: "Full library catalogs need a FIGMA_TOKEN env var (personal access token, file read scope). Without it, components the file already uses are still listed above \u2014 or keep a 'DS palette' page with one instance of each key component so they show up there."
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
const fileKey = parseFileKey(libraryFileUrl);
|
|
1777
|
+
if (!fileKey) {
|
|
1778
|
+
return { note: `Could not extract a file key from "${libraryFileUrl}".` };
|
|
1779
|
+
}
|
|
1780
|
+
const get = async (path) => {
|
|
1781
|
+
const res = await fetch(`https://api.figma.com/v1${path}`, {
|
|
1782
|
+
headers: { "X-Figma-Token": token }
|
|
1783
|
+
});
|
|
1784
|
+
const json = await res.json().catch(() => ({}));
|
|
1785
|
+
if (!res.ok) {
|
|
1786
|
+
throw new Error(
|
|
1787
|
+
`Figma API ${res.status} for ${path}: ${json.err ?? "request failed"}`
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
return json;
|
|
1791
|
+
};
|
|
1792
|
+
const [components, styles] = await Promise.all([
|
|
1793
|
+
get(`/files/${fileKey}/components`),
|
|
1794
|
+
get(`/files/${fileKey}/styles`)
|
|
1795
|
+
]);
|
|
1796
|
+
const meta = (r) => r.meta ?? {};
|
|
1797
|
+
return {
|
|
1798
|
+
components: (meta(components).components ?? []).map(
|
|
1799
|
+
(c) => ({ key: c.key, name: c.name, description: c.description || void 0 })
|
|
1800
|
+
),
|
|
1801
|
+
styles: (meta(styles).styles ?? []).map((s) => ({
|
|
1802
|
+
key: s.key,
|
|
1803
|
+
name: s.name,
|
|
1804
|
+
type: s.style_type
|
|
1805
|
+
}))
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// src/tools/batch.ts
|
|
1810
|
+
var batch_exports = {};
|
|
1811
|
+
__export(batch_exports, {
|
|
1812
|
+
register: () => register17
|
|
1813
|
+
});
|
|
1814
|
+
import { z as z18 } from "zod";
|
|
1815
|
+
function textResult2(text) {
|
|
1816
|
+
return { content: [{ type: "text", text }] };
|
|
1817
|
+
}
|
|
1818
|
+
function register17(server, sendCommand) {
|
|
1819
|
+
server.tool(
|
|
1820
|
+
"batch_execute",
|
|
1821
|
+
"Execute multiple commands in a single round-trip. Use when performing 3+ similar operations (e.g., styling multiple nodes, creating many elements). Commands run sequentially. Pass dryRun:true to preview the exact command list without touching the canvas \u2014 useful for showing the designer a plan before a large batch.",
|
|
1822
|
+
{
|
|
1823
|
+
commands: z18.array(
|
|
1824
|
+
z18.object({
|
|
1825
|
+
command: z18.string().describe("Command name"),
|
|
1826
|
+
params: z18.record(z18.string(), z18.unknown()).optional().describe("Command parameters")
|
|
1827
|
+
})
|
|
1828
|
+
).describe("Array of commands to execute sequentially"),
|
|
1829
|
+
dryRun: z18.boolean().optional().describe("Preview only \u2014 return the plan, execute nothing")
|
|
1830
|
+
},
|
|
1831
|
+
async ({ commands, dryRun }) => {
|
|
1832
|
+
try {
|
|
1833
|
+
if (dryRun) {
|
|
1834
|
+
return textResult2(
|
|
1835
|
+
JSON.stringify({
|
|
1836
|
+
dryRun: true,
|
|
1837
|
+
commandCount: commands.length,
|
|
1838
|
+
commands,
|
|
1839
|
+
note: "Nothing was executed. Re-run without dryRun to apply."
|
|
1840
|
+
})
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
const result = await sendCommand(
|
|
1844
|
+
"batch_execute",
|
|
1845
|
+
{ commands },
|
|
1846
|
+
commands.length * 3e4
|
|
1847
|
+
// Scale timeout by command count
|
|
1848
|
+
);
|
|
1849
|
+
return textResult2(JSON.stringify(result));
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
return textResult2(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// src/tools/v2/context.ts
|
|
1858
|
+
var context_exports = {};
|
|
1859
|
+
__export(context_exports, {
|
|
1860
|
+
register: () => register18,
|
|
1861
|
+
styleCountsFrom: () => styleCountsFrom
|
|
1862
|
+
});
|
|
1863
|
+
import { z as z19 } from "zod";
|
|
1864
|
+
|
|
1865
|
+
// src/semantic/response.ts
|
|
1866
|
+
function standardResult(opts) {
|
|
1867
|
+
const response = {
|
|
1868
|
+
summary: opts.summary,
|
|
1869
|
+
data: opts.data
|
|
1870
|
+
};
|
|
1871
|
+
if (opts.warnings?.length) response.warnings = opts.warnings;
|
|
1872
|
+
if (opts.recommended_next?.length) response.recommended_next = opts.recommended_next;
|
|
1873
|
+
if (opts.partial_failures?.length) response.partial_failures = opts.partial_failures;
|
|
1874
|
+
return {
|
|
1875
|
+
content: [{ type: "text", text: JSON.stringify(response) }]
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// src/semantic/errors.ts
|
|
1880
|
+
function errorResult2(category, message, recovery, extra) {
|
|
1881
|
+
const error = {
|
|
1882
|
+
category,
|
|
1883
|
+
message,
|
|
1884
|
+
recovery
|
|
1885
|
+
};
|
|
1886
|
+
if (extra?.preconditions) error.preconditions = extra.preconditions;
|
|
1887
|
+
if (extra?.partial_result !== void 0) error.partial_result = extra.partial_result;
|
|
1888
|
+
return {
|
|
1889
|
+
content: [{ type: "text", text: JSON.stringify({ error }, null, 2) }]
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
function noSelectionError() {
|
|
1893
|
+
return errorResult2(
|
|
1894
|
+
"precondition_failed",
|
|
1895
|
+
"No nodes selected in Figma",
|
|
1896
|
+
{
|
|
1897
|
+
suggestion: "Select nodes in Figma, or use search_nodes to find nodes by name/type",
|
|
1898
|
+
tool: "search_nodes"
|
|
1899
|
+
},
|
|
1900
|
+
{ preconditions: { required: "At least 1 node selected", current_state: "Selection is empty" } }
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// src/semantic/normalize.ts
|
|
1905
|
+
function normalizeNode(raw) {
|
|
1906
|
+
if (!raw) return null;
|
|
1907
|
+
const summary = {
|
|
1908
|
+
id: raw.id,
|
|
1909
|
+
name: raw.name,
|
|
1910
|
+
type: raw.type,
|
|
1911
|
+
size: {
|
|
1912
|
+
width: raw.width ?? raw.absoluteBoundingBox?.width ?? 0,
|
|
1913
|
+
height: raw.height ?? raw.absoluteBoundingBox?.height ?? 0
|
|
1914
|
+
},
|
|
1915
|
+
position: {
|
|
1916
|
+
x: raw.x ?? raw.absoluteBoundingBox?.x ?? 0,
|
|
1917
|
+
y: raw.y ?? raw.absoluteBoundingBox?.y ?? 0
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
const fill = extractFillColor(raw);
|
|
1921
|
+
if (fill) summary.fill = fill;
|
|
1922
|
+
const stroke = extractStrokeColor(raw);
|
|
1923
|
+
if (stroke) summary.stroke = stroke;
|
|
1924
|
+
if (raw.cornerRadius !== void 0) {
|
|
1925
|
+
summary.cornerRadius = raw.cornerRadius === "mixed" ? "mixed" : raw.cornerRadius;
|
|
1926
|
+
}
|
|
1927
|
+
if (raw.opacity !== void 0 && raw.opacity < 1) {
|
|
1928
|
+
summary.opacity = raw.opacity;
|
|
1929
|
+
}
|
|
1930
|
+
summary.layout = extractLayout(raw);
|
|
1931
|
+
if (raw.children?.length > 0) {
|
|
1932
|
+
summary.childSummary = summarizeChildren(raw.children);
|
|
1933
|
+
}
|
|
1934
|
+
summary.componentStatus = extractComponentStatus(raw);
|
|
1935
|
+
return summary;
|
|
1936
|
+
}
|
|
1937
|
+
function normalizeNodes(rawNodes) {
|
|
1938
|
+
return rawNodes.map(normalizeNode).filter((n) => n !== null);
|
|
1939
|
+
}
|
|
1940
|
+
function extractFillColor(node) {
|
|
1941
|
+
if (!node.fills?.length) return void 0;
|
|
1942
|
+
const solidFill = node.fills.find((f) => f.type === "SOLID" && f.visible !== false);
|
|
1943
|
+
if (!solidFill?.color) return void 0;
|
|
1944
|
+
if (typeof solidFill.color === "string" && solidFill.color.startsWith("#")) {
|
|
1945
|
+
return solidFill.color;
|
|
1946
|
+
}
|
|
1947
|
+
return rgbaToHex({
|
|
1948
|
+
r: solidFill.color.r,
|
|
1949
|
+
g: solidFill.color.g,
|
|
1950
|
+
b: solidFill.color.b,
|
|
1951
|
+
a: solidFill.opacity ?? solidFill.color.a ?? 1
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
function extractStrokeColor(node) {
|
|
1955
|
+
if (!node.strokes?.length) return void 0;
|
|
1956
|
+
const stroke = node.strokes.find((s) => s.type === "SOLID" && s.visible !== false);
|
|
1957
|
+
if (!stroke?.color) return void 0;
|
|
1958
|
+
if (typeof stroke.color === "string" && stroke.color.startsWith("#")) {
|
|
1959
|
+
return stroke.color;
|
|
1960
|
+
}
|
|
1961
|
+
return rgbaToHex({
|
|
1962
|
+
r: stroke.color.r,
|
|
1963
|
+
g: stroke.color.g,
|
|
1964
|
+
b: stroke.color.b,
|
|
1965
|
+
a: stroke.opacity ?? stroke.color.a ?? 1
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
function extractLayout(node) {
|
|
1969
|
+
if (!node.layoutMode || node.layoutMode === "NONE") return null;
|
|
1970
|
+
const pt = node.paddingTop ?? 0;
|
|
1971
|
+
const pr = node.paddingRight ?? 0;
|
|
1972
|
+
const pb = node.paddingBottom ?? 0;
|
|
1973
|
+
const pl = node.paddingLeft ?? 0;
|
|
1974
|
+
let padding;
|
|
1975
|
+
if (pt === pr && pr === pb && pb === pl) {
|
|
1976
|
+
padding = `${pt}px all`;
|
|
1977
|
+
} else if (pt === pb && pl === pr) {
|
|
1978
|
+
padding = `${pt}/${pl}`;
|
|
1979
|
+
} else {
|
|
1980
|
+
padding = `${pt}/${pr}/${pb}/${pl}`;
|
|
1981
|
+
}
|
|
1982
|
+
return {
|
|
1983
|
+
mode: node.layoutMode,
|
|
1984
|
+
padding,
|
|
1985
|
+
gap: node.itemSpacing ?? 0,
|
|
1986
|
+
sizing: {
|
|
1987
|
+
horizontal: node.primaryAxisSizingMode ?? node.layoutSizingHorizontal ?? "FIXED",
|
|
1988
|
+
vertical: node.counterAxisSizingMode ?? node.layoutSizingVertical ?? "FIXED"
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
function summarizeChildren(children) {
|
|
1993
|
+
const typeCounts = /* @__PURE__ */ new Map();
|
|
1994
|
+
for (const child of children) {
|
|
1995
|
+
const type = child.type || "UNKNOWN";
|
|
1996
|
+
typeCounts.set(type, (typeCounts.get(type) || 0) + 1);
|
|
1997
|
+
}
|
|
1998
|
+
const parts = Array.from(typeCounts.entries()).map(([type, count]) => `${count} ${type}`).join(", ");
|
|
1999
|
+
return `${children.length} children: ${parts}`;
|
|
2000
|
+
}
|
|
2001
|
+
function extractComponentStatus(node) {
|
|
2002
|
+
if (node.type === "COMPONENT") return "component";
|
|
2003
|
+
if (node.type === "COMPONENT_SET") return "component_set";
|
|
2004
|
+
if (node.type === "INSTANCE") {
|
|
2005
|
+
const mainName = node.mainComponent?.name || node.componentName;
|
|
2006
|
+
return mainName ? `instance of '${mainName}'` : "instance";
|
|
2007
|
+
}
|
|
2008
|
+
return null;
|
|
2009
|
+
}
|
|
2010
|
+
function calculateTokenCoverage(node, boundVariables) {
|
|
2011
|
+
let totalProps = 0;
|
|
2012
|
+
let boundProps = 0;
|
|
2013
|
+
if (node.fills?.length > 0) {
|
|
2014
|
+
totalProps++;
|
|
2015
|
+
if (boundVariables && hasBinding(boundVariables, "fills")) boundProps++;
|
|
2016
|
+
}
|
|
2017
|
+
if (node.strokes?.length > 0) {
|
|
2018
|
+
totalProps++;
|
|
2019
|
+
if (boundVariables && hasBinding(boundVariables, "strokes")) boundProps++;
|
|
2020
|
+
}
|
|
2021
|
+
for (const prop of ["itemSpacing", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]) {
|
|
2022
|
+
if (node[prop] !== void 0 && node[prop] > 0) {
|
|
2023
|
+
totalProps++;
|
|
2024
|
+
if (boundVariables && hasBinding(boundVariables, prop)) boundProps++;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
if (node.cornerRadius !== void 0 && node.cornerRadius > 0) {
|
|
2028
|
+
totalProps++;
|
|
2029
|
+
if (boundVariables && hasBinding(boundVariables, "cornerRadius")) boundProps++;
|
|
2030
|
+
}
|
|
2031
|
+
if (totalProps === 0) return 1;
|
|
2032
|
+
return Math.round(boundProps / totalProps * 100) / 100;
|
|
2033
|
+
}
|
|
2034
|
+
function hasBinding(boundVars, prefix) {
|
|
2035
|
+
return Object.keys(boundVars).some((key) => key.startsWith(prefix) || key === prefix);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// src/tools/v2/context.ts
|
|
2039
|
+
function styleCountsFrom(styles) {
|
|
2040
|
+
const counts = { paint: 0, text: 0, effect: 0, grid: 0 };
|
|
2041
|
+
if (Array.isArray(styles)) {
|
|
2042
|
+
for (const s of styles) {
|
|
2043
|
+
const t = (s.type || "").toLowerCase();
|
|
2044
|
+
if (t in counts) counts[t]++;
|
|
2045
|
+
}
|
|
2046
|
+
} else if (styles && typeof styles === "object") {
|
|
2047
|
+
const o = styles;
|
|
2048
|
+
counts.paint = Array.isArray(o.paintStyles) ? o.paintStyles.length : 0;
|
|
2049
|
+
counts.text = Array.isArray(o.textStyles) ? o.textStyles.length : 0;
|
|
2050
|
+
counts.effect = Array.isArray(o.effectStyles) ? o.effectStyles.length : 0;
|
|
2051
|
+
counts.grid = Array.isArray(o.gridStyles) ? o.gridStyles.length : 0;
|
|
2052
|
+
}
|
|
2053
|
+
return { ...counts, total: counts.paint + counts.text + counts.effect + counts.grid };
|
|
2054
|
+
}
|
|
2055
|
+
function register18(server, sendCommand) {
|
|
2056
|
+
server.tool(
|
|
2057
|
+
"get_document_overview",
|
|
2058
|
+
"Get the full document structure: pages, component/style/variable counts. Always call this first to understand the file. Follow with get_selection_context or search_nodes.",
|
|
2059
|
+
{},
|
|
2060
|
+
{ readOnlyHint: true },
|
|
2061
|
+
async () => {
|
|
2062
|
+
try {
|
|
2063
|
+
const [docInfo, collections, styles, conventions] = await Promise.all([
|
|
2064
|
+
sendCommand("get_document_info"),
|
|
2065
|
+
sendCommand("get_variable_collections"),
|
|
2066
|
+
sendCommand("get_styles"),
|
|
2067
|
+
// Older plugin builds don't have the handler — degrade quietly
|
|
2068
|
+
sendCommand("get_conventions", {}).catch(() => null)
|
|
2069
|
+
]);
|
|
2070
|
+
const componentCount = docInfo.componentCount ?? null;
|
|
2071
|
+
const stylesByType = styleCountsFrom(styles);
|
|
2072
|
+
const data = {
|
|
2073
|
+
name: docInfo.name,
|
|
2074
|
+
currentPage: docInfo.currentPage,
|
|
2075
|
+
pages: docInfo.pages || [],
|
|
2076
|
+
counts: {
|
|
2077
|
+
components: componentCount,
|
|
2078
|
+
styles: stylesByType.total,
|
|
2079
|
+
variableCollections: Array.isArray(collections) ? collections.length : 0
|
|
2080
|
+
}
|
|
2081
|
+
};
|
|
2082
|
+
const conventionsText = conventions?.content ?? null;
|
|
2083
|
+
return standardResult({
|
|
2084
|
+
summary: `"${data.name}" \u2014 ${data.pages.length} pages, ` + (data.counts.components != null ? `${data.counts.components} components, ` : "") + `${data.counts.styles} styles, ${data.counts.variableCollections} variable collections` + (data.counts.components == null ? " (component inventory via get_design_system)" : "") + (conventionsText ? ". This file has conventions \u2014 follow them (see data.conventions)." : ""),
|
|
2085
|
+
data: { ...data, ...conventionsText ? { conventions: conventionsText } : {} },
|
|
2086
|
+
recommended_next: [
|
|
2087
|
+
{ tool: "get_selection_context", reason: "Inspect currently selected nodes" },
|
|
2088
|
+
{ tool: "search_nodes", reason: "Find specific nodes by name or type" },
|
|
2089
|
+
{ tool: "get_design_tokens", reason: "Explore the design token system" }
|
|
2090
|
+
]
|
|
2091
|
+
});
|
|
2092
|
+
} catch (error) {
|
|
2093
|
+
return errorResult2(
|
|
2094
|
+
"connection_error",
|
|
2095
|
+
`Failed to get document info: ${error instanceof Error ? error.message : String(error)}`,
|
|
2096
|
+
{ suggestion: "Ensure Figma plugin is connected. Call join_room first.", tool: "join_room" }
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
);
|
|
2101
|
+
server.tool(
|
|
2102
|
+
"get_selection_context",
|
|
2103
|
+
"Get full context for the current Figma selection: node properties, fills, layout, token bindings, and children summary. Use as the starting point for any inspection or modification workflow. Follow with analyze_* tools or update_node.",
|
|
2104
|
+
{},
|
|
2105
|
+
{ readOnlyHint: true },
|
|
2106
|
+
async () => {
|
|
2107
|
+
try {
|
|
2108
|
+
const selection = await sendCommand("get_selection");
|
|
2109
|
+
if (!selection?.nodes?.length) {
|
|
2110
|
+
return noSelectionError();
|
|
2111
|
+
}
|
|
2112
|
+
const nodeIds = selection.nodes.map((n) => n.id);
|
|
2113
|
+
const [nodesInfo, ...bindings] = await Promise.all([
|
|
2114
|
+
sendCommand("get_nodes_info", { nodeIds, depth: 2 }),
|
|
2115
|
+
...nodeIds.map(
|
|
2116
|
+
(id) => sendCommand("get_bound_variables", { nodeId: id }).catch(() => null)
|
|
2117
|
+
)
|
|
2118
|
+
]);
|
|
2119
|
+
const nodes = (Array.isArray(nodesInfo) ? nodesInfo : []).map((raw, i) => {
|
|
2120
|
+
const summary = normalizeNode(raw);
|
|
2121
|
+
if (!summary) return null;
|
|
2122
|
+
const boundVars = bindings[i]?.boundVariables || null;
|
|
2123
|
+
summary.tokenCoverage = calculateTokenCoverage(raw, boundVars);
|
|
2124
|
+
if (boundVars) {
|
|
2125
|
+
summary.fillToken = extractTokenName(boundVars, "fills");
|
|
2126
|
+
summary.strokeToken = extractTokenName(boundVars, "strokes");
|
|
2127
|
+
}
|
|
2128
|
+
return summary;
|
|
2129
|
+
}).filter(Boolean);
|
|
2130
|
+
const data = {
|
|
2131
|
+
nodes,
|
|
2132
|
+
pageInfo: selection.currentPage || { id: "", name: "" }
|
|
2133
|
+
};
|
|
2134
|
+
const avgCoverage = nodes.length > 0 ? Math.round(nodes.reduce((sum, n) => sum + (n.tokenCoverage || 0), 0) / nodes.length * 100) : 0;
|
|
2135
|
+
const warnings = [];
|
|
2136
|
+
if (avgCoverage < 50) {
|
|
2137
|
+
warnings.push({
|
|
2138
|
+
category: "tokens",
|
|
2139
|
+
message: `Token coverage is ${avgCoverage}% \u2014 many properties are not bound to design variables`
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
return standardResult({
|
|
2143
|
+
summary: `Selected ${nodes.length} node(s): ${nodes.map((n) => `'${n.name}' (${n.type})`).join(", ")}. Token coverage: ${avgCoverage}%`,
|
|
2144
|
+
data,
|
|
2145
|
+
warnings,
|
|
2146
|
+
recommended_next: [
|
|
2147
|
+
{ tool: "analyze_color_usage", reason: "Audit color consistency and token coverage" },
|
|
2148
|
+
{ tool: "update_node", reason: "Modify selected node properties" },
|
|
2149
|
+
{ tool: "screenshot", reason: "Visually inspect the selection" }
|
|
2150
|
+
]
|
|
2151
|
+
});
|
|
2152
|
+
} catch (error) {
|
|
2153
|
+
return errorResult2(
|
|
2154
|
+
"connection_error",
|
|
2155
|
+
`Failed to get selection: ${error instanceof Error ? error.message : String(error)}`,
|
|
2156
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
);
|
|
2161
|
+
server.tool(
|
|
2162
|
+
"get_node_details",
|
|
2163
|
+
"Get a single node's full properties, CSS, token bindings, and children. Use after search_nodes or get_selection_context to inspect a specific node. Follow with update_node or analyze_* tools.",
|
|
2164
|
+
{
|
|
2165
|
+
nodeId: z19.string().describe("The node ID to inspect")
|
|
2166
|
+
},
|
|
2167
|
+
{ readOnlyHint: true },
|
|
2168
|
+
async ({ nodeId }) => {
|
|
2169
|
+
try {
|
|
2170
|
+
const [nodeInfo, boundVars, css] = await Promise.all([
|
|
2171
|
+
sendCommand("get_node_info", { nodeId, depth: 2 }),
|
|
2172
|
+
sendCommand("get_bound_variables", { nodeId }).catch(() => null),
|
|
2173
|
+
sendCommand("get_css", { nodeId }).catch(() => null)
|
|
2174
|
+
]);
|
|
2175
|
+
if (!nodeInfo) {
|
|
2176
|
+
return errorResult2(
|
|
2177
|
+
"invalid_input",
|
|
2178
|
+
`Node '${nodeId}' not found`,
|
|
2179
|
+
{ suggestion: "Use search_nodes to find the correct node ID", tool: "search_nodes" }
|
|
2180
|
+
);
|
|
2181
|
+
}
|
|
2182
|
+
const node = normalizeNode(nodeInfo);
|
|
2183
|
+
if (!node) {
|
|
2184
|
+
return errorResult2(
|
|
2185
|
+
"invalid_input",
|
|
2186
|
+
`Could not process node '${nodeId}'`,
|
|
2187
|
+
{ suggestion: "Try get_node_info directly for raw data", tool: "get_node_info" }
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
const bv = boundVars?.boundVariables || null;
|
|
2191
|
+
node.tokenCoverage = calculateTokenCoverage(nodeInfo, bv);
|
|
2192
|
+
if (bv) {
|
|
2193
|
+
node.fillToken = extractTokenName(bv, "fills");
|
|
2194
|
+
node.strokeToken = extractTokenName(bv, "strokes");
|
|
2195
|
+
}
|
|
2196
|
+
const children = nodeInfo.children ? normalizeNodes(nodeInfo.children) : void 0;
|
|
2197
|
+
const data = {
|
|
2198
|
+
node,
|
|
2199
|
+
css: css?.css,
|
|
2200
|
+
boundVariables: bv || void 0,
|
|
2201
|
+
children
|
|
2202
|
+
};
|
|
2203
|
+
return standardResult({
|
|
2204
|
+
summary: `'${node.name}' (${node.type}, ${node.size.width}\xD7${node.size.height}). Token coverage: ${Math.round((node.tokenCoverage || 0) * 100)}%`,
|
|
2205
|
+
data,
|
|
2206
|
+
recommended_next: [
|
|
2207
|
+
{ tool: "update_node", reason: "Modify this node's properties", args: { nodeId } },
|
|
2208
|
+
{ tool: "analyze_color_usage", reason: "Check color token usage", args: { nodeId } }
|
|
2209
|
+
]
|
|
2210
|
+
});
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
return errorResult2(
|
|
2213
|
+
"connection_error",
|
|
2214
|
+
`Failed to get node details: ${error instanceof Error ? error.message : String(error)}`,
|
|
2215
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
2216
|
+
);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
);
|
|
2220
|
+
server.tool(
|
|
2221
|
+
"search_nodes",
|
|
2222
|
+
"Search for nodes by name and/or type within a scope. Returns matching node IDs, names, and types. Use to find specific elements before inspecting or modifying them. Follow with get_node_details or update_node.",
|
|
2223
|
+
{
|
|
2224
|
+
query: z19.string().optional().describe("Name substring to search for"),
|
|
2225
|
+
types: z19.array(z19.string()).optional().describe("Node types to filter: FRAME, TEXT, COMPONENT, INSTANCE, RECTANGLE, etc."),
|
|
2226
|
+
parentId: z19.string().optional().describe("Scope search to children of this node (default: current page)")
|
|
2227
|
+
},
|
|
2228
|
+
{ readOnlyHint: true },
|
|
2229
|
+
async ({ query, types, parentId }) => {
|
|
2230
|
+
try {
|
|
2231
|
+
const result = await sendCommand("find_nodes", {
|
|
2232
|
+
name: query,
|
|
2233
|
+
types,
|
|
2234
|
+
parentId
|
|
2235
|
+
});
|
|
2236
|
+
const matches = (Array.isArray(result) ? result : []).map((n) => ({
|
|
2237
|
+
id: n.id,
|
|
2238
|
+
name: n.name,
|
|
2239
|
+
type: n.type,
|
|
2240
|
+
parentName: n.parent?.name
|
|
2241
|
+
}));
|
|
2242
|
+
const data = {
|
|
2243
|
+
matches,
|
|
2244
|
+
total: matches.length
|
|
2245
|
+
};
|
|
2246
|
+
return standardResult({
|
|
2247
|
+
summary: `Found ${matches.length} node(s)${query ? ` matching '${query}'` : ""}${types?.length ? ` of type ${types.join("/")}` : ""}`,
|
|
2248
|
+
data,
|
|
2249
|
+
recommended_next: matches.length > 0 ? [
|
|
2250
|
+
{ tool: "get_node_details", reason: "Inspect a specific node", args: { nodeId: matches[0].id } },
|
|
2251
|
+
{ tool: "navigate_to", reason: "Focus on a found node", args: { nodeId: matches[0].id } }
|
|
2252
|
+
] : [
|
|
2253
|
+
{ tool: "get_document_overview", reason: "Check document structure" }
|
|
2254
|
+
]
|
|
2255
|
+
});
|
|
2256
|
+
} catch (error) {
|
|
2257
|
+
return errorResult2(
|
|
2258
|
+
"connection_error",
|
|
2259
|
+
`Search failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2260
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
2261
|
+
);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
);
|
|
2265
|
+
server.tool(
|
|
2266
|
+
"get_design_tokens",
|
|
2267
|
+
"Get an overview of the design token system: variable collections, modes, variable counts, and style counts. Use to understand the design system before binding tokens or creating new variables. Follow with manage_variables or bind_tokens.",
|
|
2268
|
+
{},
|
|
2269
|
+
{ readOnlyHint: true },
|
|
2270
|
+
async () => {
|
|
2271
|
+
try {
|
|
2272
|
+
const [collections, styles] = await Promise.all([
|
|
2273
|
+
sendCommand("get_variable_collections"),
|
|
2274
|
+
sendCommand("get_styles")
|
|
2275
|
+
]);
|
|
2276
|
+
const collectionSummaries = (Array.isArray(collections) ? collections : []).map((c) => ({
|
|
2277
|
+
id: c.id,
|
|
2278
|
+
name: c.name,
|
|
2279
|
+
modes: Array.isArray(c.modes) ? c.modes : [],
|
|
2280
|
+
variableCount: Array.isArray(c.variableIds) ? c.variableIds.length : 0
|
|
2281
|
+
}));
|
|
2282
|
+
const { total: _ignored, ...stylesByType } = styleCountsFrom(styles);
|
|
2283
|
+
const totalVars = collectionSummaries.reduce((sum, c) => sum + c.variableCount, 0);
|
|
2284
|
+
const data = {
|
|
2285
|
+
collections: collectionSummaries,
|
|
2286
|
+
styles: stylesByType
|
|
2287
|
+
};
|
|
2288
|
+
return standardResult({
|
|
2289
|
+
summary: `${collectionSummaries.length} collection(s) with ${totalVars} variables. Styles: ${stylesByType.paint} paint, ${stylesByType.text} text, ${stylesByType.effect} effect, ${stylesByType.grid} grid`,
|
|
2290
|
+
data,
|
|
2291
|
+
recommended_next: [
|
|
2292
|
+
{ tool: "manage_variables", reason: "Create or update design tokens" },
|
|
2293
|
+
{ tool: "analyze_color_usage", reason: "Check token coverage on selected nodes" }
|
|
2294
|
+
]
|
|
2295
|
+
});
|
|
2296
|
+
} catch (error) {
|
|
2297
|
+
return errorResult2(
|
|
2298
|
+
"connection_error",
|
|
2299
|
+
`Failed to get design tokens: ${error instanceof Error ? error.message : String(error)}`,
|
|
2300
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
);
|
|
2305
|
+
server.tool(
|
|
2306
|
+
"screenshot",
|
|
2307
|
+
"Take a PNG screenshot of a node or the current view. Returns an image visible to the AI. Use to visually verify changes or inspect design details. Works for both context inspection and verification after modifications.",
|
|
2308
|
+
{
|
|
2309
|
+
nodeId: z19.string().optional().describe("Node ID to screenshot (default: current view)")
|
|
2310
|
+
},
|
|
2311
|
+
{ readOnlyHint: true },
|
|
2312
|
+
async ({ nodeId }) => {
|
|
2313
|
+
try {
|
|
2314
|
+
const result = await sendCommand("get_screenshot", nodeId ? { nodeId } : {});
|
|
2315
|
+
if (result?.imageData) {
|
|
2316
|
+
return {
|
|
2317
|
+
content: [
|
|
2318
|
+
{
|
|
2319
|
+
type: "image",
|
|
2320
|
+
data: result.imageData,
|
|
2321
|
+
mimeType: "image/png"
|
|
2322
|
+
},
|
|
2323
|
+
{
|
|
2324
|
+
type: "text",
|
|
2325
|
+
text: JSON.stringify({
|
|
2326
|
+
summary: `Screenshot taken${nodeId ? ` of node ${nodeId}` : " of current view"}`,
|
|
2327
|
+
recommended_next: [
|
|
2328
|
+
{ tool: "get_selection_context", reason: "Get structured data about the selection" },
|
|
2329
|
+
{ tool: "update_node", reason: "Make changes based on what you see" }
|
|
2330
|
+
]
|
|
2331
|
+
})
|
|
2332
|
+
}
|
|
2333
|
+
]
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
return {
|
|
2337
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
2338
|
+
};
|
|
2339
|
+
} catch (error) {
|
|
2340
|
+
return errorResult2(
|
|
2341
|
+
"connection_error",
|
|
2342
|
+
`Screenshot failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2343
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
);
|
|
2348
|
+
}
|
|
2349
|
+
function extractTokenName(boundVars, prefix) {
|
|
2350
|
+
for (const [key, value] of Object.entries(boundVars)) {
|
|
2351
|
+
if (key.startsWith(prefix) || key === prefix) {
|
|
2352
|
+
if (value && typeof value === "object" && "name" in value) {
|
|
2353
|
+
return value.name;
|
|
2354
|
+
}
|
|
2355
|
+
if (typeof value === "string") return value;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
return null;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// src/tools/v2/analysis.ts
|
|
2362
|
+
var analysis_exports = {};
|
|
2363
|
+
__export(analysis_exports, {
|
|
2364
|
+
collectColorIssues: () => collectColorIssues,
|
|
2365
|
+
register: () => register19
|
|
2366
|
+
});
|
|
2367
|
+
import { z as z20 } from "zod";
|
|
2368
|
+
|
|
2369
|
+
// src/semantic/health.ts
|
|
2370
|
+
var WEIGHTS = { color: 0.3, layout: 0.25, components: 0.2, accessibility: 0.25 };
|
|
2371
|
+
var clamp = (n) => Math.max(0, Math.min(100, Math.round(n)));
|
|
2372
|
+
function computeHealthScore(inputs) {
|
|
2373
|
+
const categories = [];
|
|
2374
|
+
if (inputs.color) {
|
|
2375
|
+
categories.push({
|
|
2376
|
+
category: "color",
|
|
2377
|
+
score: clamp(inputs.color.tokenCoverage * 100),
|
|
2378
|
+
weight: WEIGHTS.color,
|
|
2379
|
+
note: `${Math.round(inputs.color.tokenCoverage * 100)}% of color properties bound to tokens (${inputs.color.unboundCount} unbound)`
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
if (inputs.layout) {
|
|
2383
|
+
categories.push({
|
|
2384
|
+
category: "layout",
|
|
2385
|
+
score: clamp(inputs.layout.autoLayoutCoverage * 100 - inputs.layout.issueCount * 3),
|
|
2386
|
+
weight: WEIGHTS.layout,
|
|
2387
|
+
note: `${Math.round(inputs.layout.autoLayoutCoverage * 100)}% auto-layout coverage, ${inputs.layout.issueCount} issue(s)`
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
if (inputs.components) {
|
|
2391
|
+
const { totalInstances, detachedCount } = inputs.components;
|
|
2392
|
+
categories.push({
|
|
2393
|
+
category: "components",
|
|
2394
|
+
score: totalInstances > 0 ? clamp((1 - detachedCount / totalInstances) * 100) : 100,
|
|
2395
|
+
weight: WEIGHTS.components,
|
|
2396
|
+
note: totalInstances > 0 ? `${detachedCount}/${totalInstances} instances look detached` : "no instances in scope"
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
if (inputs.accessibility) {
|
|
2400
|
+
categories.push({
|
|
2401
|
+
category: "accessibility",
|
|
2402
|
+
score: clamp(100 - inputs.accessibility.issueCount * 8),
|
|
2403
|
+
weight: WEIGHTS.accessibility,
|
|
2404
|
+
note: `${inputs.accessibility.issueCount} issue(s) found`
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
const totalWeight = categories.reduce((s, c) => s + c.weight, 0);
|
|
2408
|
+
const score = totalWeight > 0 ? clamp(categories.reduce((s, c) => s + c.score * c.weight, 0) / totalWeight) : 0;
|
|
2409
|
+
const grade = score >= 90 ? "A" : score >= 75 ? "B" : score >= 55 ? "C" : "D";
|
|
2410
|
+
return { score, grade, categories };
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// src/tools/v2/analysis.ts
|
|
2414
|
+
var ASPECT_TOOL = {
|
|
2415
|
+
color: "analyze_color_usage",
|
|
2416
|
+
layout: "analyze_layout_quality",
|
|
2417
|
+
components: "analyze_component_health",
|
|
2418
|
+
accessibility: "analyze_accessibility"
|
|
2419
|
+
};
|
|
2420
|
+
function register19(server, sendCommand) {
|
|
2421
|
+
const aspectHandlers = /* @__PURE__ */ new Map();
|
|
2422
|
+
const interceptor = new Proxy(server, {
|
|
2423
|
+
get(target, prop, receiver) {
|
|
2424
|
+
if (prop !== "tool") return Reflect.get(target, prop, receiver);
|
|
2425
|
+
return (name, ...rest) => {
|
|
2426
|
+
if (name.startsWith("analyze_")) {
|
|
2427
|
+
aspectHandlers.set(name, rest[rest.length - 1]);
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
return target.tool.call(target, name, ...rest);
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
});
|
|
2434
|
+
registerAnalysisTools(interceptor, sendCommand);
|
|
2435
|
+
server.tool(
|
|
2436
|
+
"analyze_design",
|
|
2437
|
+
"Audit the design from one aspect: color (token coverage, unbound fills/strokes), layout (auto-layout quality, spacing consistency), components (detached instances, component health), accessibility (WCAG contrast incl. large-text thresholds, touch targets, minimum text sizes), tokens (hardcoded values that match an existing variable \u2014 each finding names the variable to bind; fix in one shot with manage_variables action:tokenize fix:true), or overall (runs color/layout/components/accessibility and returns a weighted 0-100 health score \u2014 good for audits and reports). Defaults to the current selection; tokens defaults to the current page.",
|
|
2438
|
+
{
|
|
2439
|
+
aspect: z20.enum(["color", "layout", "components", "accessibility", "tokens", "overall"]),
|
|
2440
|
+
nodeId: z20.string().optional().describe("Root node to analyze (default: current selection)")
|
|
2441
|
+
},
|
|
2442
|
+
{ readOnlyHint: true },
|
|
2443
|
+
async ({ aspect, nodeId }) => {
|
|
2444
|
+
if (aspect === "overall") {
|
|
2445
|
+
return runOverallAudit(aspectHandlers, nodeId);
|
|
2446
|
+
}
|
|
2447
|
+
if (aspect === "tokens") {
|
|
2448
|
+
return runTokenDrift(sendCommand, nodeId);
|
|
2449
|
+
}
|
|
2450
|
+
const handler = aspectHandlers.get(ASPECT_TOOL[aspect]);
|
|
2451
|
+
if (!handler) throw new Error(`Unknown aspect: ${aspect}`);
|
|
2452
|
+
return handler({ nodeId });
|
|
2453
|
+
}
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
async function runTokenDrift(sendCommand, nodeId) {
|
|
2457
|
+
const data = await sendCommand("scan_token_drift", { nodeId, fix: false }, 12e4);
|
|
2458
|
+
const matched = data.stats?.matched ?? 0;
|
|
2459
|
+
return standardResult({
|
|
2460
|
+
summary: data.note ?? (matched === 0 ? `No token drift: nothing hardcoded matches an existing variable (${data.stats?.nodesScanned ?? 0} nodes scanned).` : `${matched} hardcoded value(s) match an existing variable and could be bound.`),
|
|
2461
|
+
data,
|
|
2462
|
+
warnings: matched > 0 ? [
|
|
2463
|
+
{
|
|
2464
|
+
category: "general",
|
|
2465
|
+
message: `${matched} value(s) drift from tokens they visually match`
|
|
2466
|
+
}
|
|
2467
|
+
] : [],
|
|
2468
|
+
recommended_next: matched > 0 ? [
|
|
2469
|
+
{
|
|
2470
|
+
tool: "manage_variables",
|
|
2471
|
+
reason: 'Bind them in one pass: action "tokenize" with fix: true'
|
|
2472
|
+
}
|
|
2473
|
+
] : []
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
async function runOverallAudit(aspectHandlers, nodeId) {
|
|
2477
|
+
const parse = (result) => {
|
|
2478
|
+
try {
|
|
2479
|
+
const text = result.content?.[0]?.text ?? "";
|
|
2480
|
+
const parsed = JSON.parse(text);
|
|
2481
|
+
return parsed?.data ?? null;
|
|
2482
|
+
} catch {
|
|
2483
|
+
return null;
|
|
2484
|
+
}
|
|
2485
|
+
};
|
|
2486
|
+
const run = async (tool) => {
|
|
2487
|
+
const handler = aspectHandlers.get(tool);
|
|
2488
|
+
if (!handler) return null;
|
|
2489
|
+
try {
|
|
2490
|
+
return parse(await handler({ nodeId }));
|
|
2491
|
+
} catch {
|
|
2492
|
+
return null;
|
|
2493
|
+
}
|
|
2494
|
+
};
|
|
2495
|
+
const [color, layout, components, accessibility] = await Promise.all([
|
|
2496
|
+
run("analyze_color_usage"),
|
|
2497
|
+
run("analyze_layout_quality"),
|
|
2498
|
+
run("analyze_component_health"),
|
|
2499
|
+
run("analyze_accessibility")
|
|
2500
|
+
]);
|
|
2501
|
+
const inputs = {
|
|
2502
|
+
color: color ? { tokenCoverage: color.tokenCoverage ?? 0, unboundCount: color.unboundCount ?? 0 } : null,
|
|
2503
|
+
layout: layout ? {
|
|
2504
|
+
autoLayoutCoverage: layout.autoLayoutCoverage ?? 0,
|
|
2505
|
+
issueCount: layout.issues?.length ?? 0
|
|
2506
|
+
} : null,
|
|
2507
|
+
components: components ? {
|
|
2508
|
+
totalInstances: components.totalInstances ?? 0,
|
|
2509
|
+
detachedCount: components.detachedCount ?? 0
|
|
2510
|
+
} : null,
|
|
2511
|
+
accessibility: accessibility ? { issueCount: accessibility.issueCount ?? 0 } : null
|
|
2512
|
+
};
|
|
2513
|
+
const health = computeHealthScore(inputs);
|
|
2514
|
+
const excluded = Object.keys(inputs).filter((k) => !inputs[k]);
|
|
2515
|
+
const worst = [...health.categories].sort((a, b) => a.score - b.score)[0];
|
|
2516
|
+
return standardResult({
|
|
2517
|
+
summary: `Design health: ${health.score}/100 (grade ${health.grade}). Weakest area: ${worst ? `${worst.category} at ${worst.score}` : "n/a"}.`,
|
|
2518
|
+
data: {
|
|
2519
|
+
...health,
|
|
2520
|
+
...excluded.length > 0 ? { excluded } : {},
|
|
2521
|
+
details: { color, layout, components, accessibility }
|
|
2522
|
+
},
|
|
2523
|
+
warnings: health.grade === "A" ? [] : health.categories.filter((c) => c.score < 75).map((c) => ({
|
|
2524
|
+
category: "general",
|
|
2525
|
+
message: `${c.category}: ${c.score}/100 \u2014 ${c.note}`
|
|
2526
|
+
})),
|
|
2527
|
+
recommended_next: worst ? [
|
|
2528
|
+
{
|
|
2529
|
+
tool: "analyze_design",
|
|
2530
|
+
reason: `Drill into the weakest area with aspect: "${worst.category}"`
|
|
2531
|
+
}
|
|
2532
|
+
] : []
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
function registerAnalysisTools(server, sendCommand) {
|
|
2536
|
+
server.tool(
|
|
2537
|
+
"analyze_color_usage",
|
|
2538
|
+
"Audit color consistency and design token coverage for a node tree. Reports which fill/stroke colors are not bound to variables. Use after get_selection_context to diagnose token gaps. Follow with bind_tokens to fix issues.",
|
|
2539
|
+
{
|
|
2540
|
+
nodeId: z20.string().optional().describe("Root node to analyze (default: current selection)")
|
|
2541
|
+
},
|
|
2542
|
+
{ readOnlyHint: true },
|
|
2543
|
+
async ({ nodeId }) => {
|
|
2544
|
+
try {
|
|
2545
|
+
let targetIds;
|
|
2546
|
+
if (nodeId) {
|
|
2547
|
+
targetIds = [nodeId];
|
|
2548
|
+
} else {
|
|
2549
|
+
const selection = await sendCommand("get_selection");
|
|
2550
|
+
if (!selection?.nodes?.length) return noSelectionError();
|
|
2551
|
+
targetIds = selection.nodes.map((n) => n.id);
|
|
2552
|
+
}
|
|
2553
|
+
const unboundColors = [];
|
|
2554
|
+
const counters = { totalProps: 0, boundCount: 0 };
|
|
2555
|
+
let hiddenCount = 0;
|
|
2556
|
+
let scanned = 0;
|
|
2557
|
+
let capped = false;
|
|
2558
|
+
for (const id of targetIds) {
|
|
2559
|
+
let audit = null;
|
|
2560
|
+
try {
|
|
2561
|
+
audit = await sendCommand("audit_colors", { nodeId: id }, 6e4);
|
|
2562
|
+
} catch {
|
|
2563
|
+
audit = null;
|
|
2564
|
+
}
|
|
2565
|
+
if (audit && typeof audit.totalProperties === "number") {
|
|
2566
|
+
counters.totalProps += audit.totalProperties;
|
|
2567
|
+
counters.boundCount += audit.boundCount ?? 0;
|
|
2568
|
+
hiddenCount += audit.hiddenCount ?? 0;
|
|
2569
|
+
scanned += audit.scanned ?? 0;
|
|
2570
|
+
capped = capped || !!audit.capped;
|
|
2571
|
+
for (const issue of audit.issues ?? []) unboundColors.push(issue);
|
|
2572
|
+
continue;
|
|
2573
|
+
}
|
|
2574
|
+
const [nodeInfo, boundVars] = await Promise.all([
|
|
2575
|
+
sendCommand("get_node_info", { nodeId: id, depth: 2, maxNodes: 200 }),
|
|
2576
|
+
sendCommand("get_bound_variables", { nodeId: id }).catch(() => null)
|
|
2577
|
+
]);
|
|
2578
|
+
if (!nodeInfo) continue;
|
|
2579
|
+
const bv = boundVars?.boundVariables || {};
|
|
2580
|
+
collectColorIssues(nodeInfo, bv, unboundColors, counters);
|
|
2581
|
+
if (nodeInfo.children) {
|
|
2582
|
+
for (const child of nodeInfo.children) {
|
|
2583
|
+
const childBv = await sendCommand("get_bound_variables", { nodeId: child.id }).catch(() => null);
|
|
2584
|
+
const cbv = childBv?.boundVariables || {};
|
|
2585
|
+
collectColorIssues(child, cbv, unboundColors, counters);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
const tokenCoverage = counters.totalProps > 0 ? Math.round(counters.boundCount / counters.totalProps * 100) / 100 : 1;
|
|
2590
|
+
const data = {
|
|
2591
|
+
totalProperties: counters.totalProps,
|
|
2592
|
+
boundCount: counters.boundCount,
|
|
2593
|
+
unboundCount: unboundColors.length,
|
|
2594
|
+
tokenCoverage,
|
|
2595
|
+
unboundColors,
|
|
2596
|
+
...hiddenCount ? { hiddenCount } : {},
|
|
2597
|
+
...scanned ? { scanned } : {},
|
|
2598
|
+
...capped ? { capped } : {}
|
|
2599
|
+
};
|
|
2600
|
+
const visibleIssues = unboundColors.length - hiddenCount;
|
|
2601
|
+
const warnings = unboundColors.length > 0 ? [{ category: "tokens", message: `${unboundColors.length} color(s) not bound to design tokens${hiddenCount ? ` (${hiddenCount} on hidden paints)` : ""}` }] : [];
|
|
2602
|
+
return standardResult({
|
|
2603
|
+
summary: `Color audit: ${visibleIssues} unbound color(s)` + (hiddenCount ? ` + ${hiddenCount} on hidden paints` : "") + `. Token coverage: ${Math.round(data.tokenCoverage * 100)}%` + (capped ? " (scan capped \u2014 large subtree)" : ""),
|
|
2604
|
+
data,
|
|
2605
|
+
warnings,
|
|
2606
|
+
recommended_next: unboundColors.length > 0 ? [
|
|
2607
|
+
{ tool: "bind_tokens", reason: "Bind unbound colors to design tokens" },
|
|
2608
|
+
{ tool: "get_design_tokens", reason: "See available tokens for binding" }
|
|
2609
|
+
] : [
|
|
2610
|
+
{ tool: "analyze_layout_quality", reason: "Check layout quality next" }
|
|
2611
|
+
]
|
|
2612
|
+
});
|
|
2613
|
+
} catch (error) {
|
|
2614
|
+
return errorResult2(
|
|
2615
|
+
"connection_error",
|
|
2616
|
+
`Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2617
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
2618
|
+
);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
);
|
|
2622
|
+
server.tool(
|
|
2623
|
+
"analyze_layout_quality",
|
|
2624
|
+
"Check layout quality: auto-layout usage, magic numbers in spacing/padding, and sizing consistency. Use after get_selection_context to find layout improvements. Follow with set_auto_layout to fix issues.",
|
|
2625
|
+
{
|
|
2626
|
+
nodeId: z20.string().optional().describe("Root node to analyze (default: current selection)")
|
|
2627
|
+
},
|
|
2628
|
+
{ readOnlyHint: true },
|
|
2629
|
+
async ({ nodeId }) => {
|
|
2630
|
+
try {
|
|
2631
|
+
let targetId;
|
|
2632
|
+
if (nodeId) {
|
|
2633
|
+
targetId = nodeId;
|
|
2634
|
+
} else {
|
|
2635
|
+
const selection = await sendCommand("get_selection");
|
|
2636
|
+
if (!selection?.nodes?.length) return noSelectionError();
|
|
2637
|
+
targetId = selection.nodes[0].id;
|
|
2638
|
+
}
|
|
2639
|
+
const nodeInfo = await sendCommand("get_node_info", { nodeId: targetId, depth: 8, maxNodes: 500 });
|
|
2640
|
+
if (!nodeInfo) {
|
|
2641
|
+
return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
|
|
2642
|
+
}
|
|
2643
|
+
const issues = [];
|
|
2644
|
+
let totalFrames = 0;
|
|
2645
|
+
let autoLayoutCount = 0;
|
|
2646
|
+
analyzeLayoutNode(nodeInfo, issues, { totalFrames: 0, autoLayoutCount: 0 });
|
|
2647
|
+
const frameNodes = collectFrames(nodeInfo);
|
|
2648
|
+
totalFrames = frameNodes.length;
|
|
2649
|
+
autoLayoutCount = frameNodes.filter((f) => f.layoutMode && f.layoutMode !== "NONE").length;
|
|
2650
|
+
const data = {
|
|
2651
|
+
totalFrames,
|
|
2652
|
+
autoLayoutCount,
|
|
2653
|
+
autoLayoutCoverage: totalFrames > 0 ? Math.round(autoLayoutCount / totalFrames * 100) / 100 : 1,
|
|
2654
|
+
issues
|
|
2655
|
+
};
|
|
2656
|
+
return standardResult({
|
|
2657
|
+
summary: `Layout audit: ${autoLayoutCount}/${totalFrames} frames use auto-layout (${Math.round(data.autoLayoutCoverage * 100)}%). ${issues.length} issue(s) found.`,
|
|
2658
|
+
data,
|
|
2659
|
+
warnings: issues.filter((i) => i.severity === "warning").map((i) => ({
|
|
2660
|
+
category: "layout",
|
|
2661
|
+
message: i.issue,
|
|
2662
|
+
nodeId: i.nodeId
|
|
2663
|
+
})),
|
|
2664
|
+
recommended_next: issues.length > 0 ? [{ tool: "set_auto_layout", reason: "Fix layout issues on specific frames" }] : [{ tool: "analyze_color_usage", reason: "Check color token coverage next" }]
|
|
2665
|
+
});
|
|
2666
|
+
} catch (error) {
|
|
2667
|
+
return errorResult2(
|
|
2668
|
+
"connection_error",
|
|
2669
|
+
`Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2670
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
2671
|
+
);
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
);
|
|
2675
|
+
server.tool(
|
|
2676
|
+
"analyze_component_health",
|
|
2677
|
+
"Check component system health: detached instances, unused components, and override consistency. Use to audit design system adherence. Follow with create_component or update_instance to fix issues.",
|
|
2678
|
+
{
|
|
2679
|
+
nodeId: z20.string().optional().describe("Scope to analyze (default: current page)")
|
|
2680
|
+
},
|
|
2681
|
+
{ readOnlyHint: true },
|
|
2682
|
+
async ({ nodeId }) => {
|
|
2683
|
+
try {
|
|
2684
|
+
const [instances, components] = await Promise.all([
|
|
2685
|
+
sendCommand("scan_nodes_by_types", {
|
|
2686
|
+
types: ["INSTANCE"],
|
|
2687
|
+
parentId: nodeId
|
|
2688
|
+
}, 12e4),
|
|
2689
|
+
sendCommand("get_local_components")
|
|
2690
|
+
]);
|
|
2691
|
+
const instanceList = Array.isArray(instances) ? instances : [];
|
|
2692
|
+
const componentList = Array.isArray(components) ? components : [];
|
|
2693
|
+
const issues = [];
|
|
2694
|
+
let detachedCount = 0;
|
|
2695
|
+
for (const inst of instanceList) {
|
|
2696
|
+
if (!inst.mainComponent && !inst.componentId) {
|
|
2697
|
+
detachedCount++;
|
|
2698
|
+
issues.push({
|
|
2699
|
+
nodeId: inst.id,
|
|
2700
|
+
nodeName: inst.name,
|
|
2701
|
+
issue: "Instance appears detached from its main component"
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
const data = {
|
|
2706
|
+
totalComponents: componentList.length,
|
|
2707
|
+
totalInstances: instanceList.length,
|
|
2708
|
+
detachedCount,
|
|
2709
|
+
issues
|
|
2710
|
+
};
|
|
2711
|
+
return standardResult({
|
|
2712
|
+
summary: `Components: ${componentList.length} defined, ${instanceList.length} instances, ${detachedCount} potentially detached`,
|
|
2713
|
+
data,
|
|
2714
|
+
recommended_next: [
|
|
2715
|
+
{ tool: "get_design_tokens", reason: "Review design system completeness" },
|
|
2716
|
+
{ tool: "search_nodes", reason: "Find specific components by name" }
|
|
2717
|
+
]
|
|
2718
|
+
});
|
|
2719
|
+
} catch (error) {
|
|
2720
|
+
return errorResult2(
|
|
2721
|
+
"connection_error",
|
|
2722
|
+
`Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2723
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
2724
|
+
);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
);
|
|
2728
|
+
server.tool(
|
|
2729
|
+
"analyze_accessibility",
|
|
2730
|
+
"Check accessibility: text contrast ratios against backgrounds, touch target sizes. Use to ensure designs meet WCAG guidelines. Follow with update_node to fix contrast or sizing issues.",
|
|
2731
|
+
{
|
|
2732
|
+
nodeId: z20.string().optional().describe("Root node to analyze (default: current selection)")
|
|
2733
|
+
},
|
|
2734
|
+
{ readOnlyHint: true },
|
|
2735
|
+
async ({ nodeId }) => {
|
|
2736
|
+
try {
|
|
2737
|
+
let targetId;
|
|
2738
|
+
if (nodeId) {
|
|
2739
|
+
targetId = nodeId;
|
|
2740
|
+
} else {
|
|
2741
|
+
const selection = await sendCommand("get_selection");
|
|
2742
|
+
if (!selection?.nodes?.length) return noSelectionError();
|
|
2743
|
+
targetId = selection.nodes[0].id;
|
|
2744
|
+
}
|
|
2745
|
+
const nodeInfo = await sendCommand("get_node_info", { nodeId: targetId, depth: 8, maxNodes: 500 });
|
|
2746
|
+
if (!nodeInfo) {
|
|
2747
|
+
return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
|
|
2748
|
+
}
|
|
2749
|
+
const issues = [];
|
|
2750
|
+
checkAccessibility(nodeInfo, null, issues);
|
|
2751
|
+
const data = {
|
|
2752
|
+
issueCount: issues.length,
|
|
2753
|
+
issues
|
|
2754
|
+
};
|
|
2755
|
+
return standardResult({
|
|
2756
|
+
summary: `Accessibility: ${issues.length} issue(s) found`,
|
|
2757
|
+
data,
|
|
2758
|
+
warnings: issues.map((i) => ({
|
|
2759
|
+
category: "accessibility",
|
|
2760
|
+
message: i.issue,
|
|
2761
|
+
nodeId: i.nodeId
|
|
2762
|
+
})),
|
|
2763
|
+
recommended_next: issues.length > 0 ? [{ tool: "update_node", reason: "Fix accessibility issues" }] : [{ tool: "validate_design_rules", reason: "Run full design validation" }]
|
|
2764
|
+
});
|
|
2765
|
+
} catch (error) {
|
|
2766
|
+
return errorResult2(
|
|
2767
|
+
"connection_error",
|
|
2768
|
+
`Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2769
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
2770
|
+
);
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
);
|
|
2774
|
+
const CHECKPOINT_KEY = "relai.checkpoint";
|
|
2775
|
+
server.tool(
|
|
2776
|
+
"diff_nodes",
|
|
2777
|
+
`Compare two nodes and list their differences (fills, strokes, size, layout, etc.), or audit changes over time with checkpoints: checkpoint:"save" snapshots nodeIdA's key properties; checkpoint:"compare" later diffs the current state against that snapshot \u2014 useful before/after an editing session to show the designer exactly what changed.`,
|
|
2778
|
+
{
|
|
2779
|
+
nodeIdA: z20.string().describe("First node ID (or the checkpoint target)"),
|
|
2780
|
+
nodeIdB: z20.string().optional().describe("Second node ID (omit when using checkpoint)"),
|
|
2781
|
+
checkpoint: z20.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now; compare: diff current vs saved snapshot")
|
|
2782
|
+
},
|
|
2783
|
+
{ readOnlyHint: true },
|
|
2784
|
+
async ({ nodeIdA, nodeIdB, checkpoint }) => {
|
|
2785
|
+
try {
|
|
2786
|
+
if (checkpoint === "save") {
|
|
2787
|
+
const node = await sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 });
|
|
2788
|
+
if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
|
|
2789
|
+
const summary = normalizeNode(node);
|
|
2790
|
+
await sendCommand("set_plugin_data", {
|
|
2791
|
+
nodeId: nodeIdA,
|
|
2792
|
+
key: CHECKPOINT_KEY,
|
|
2793
|
+
value: JSON.stringify({ ts: Date.now(), name: node.name, summary })
|
|
2794
|
+
});
|
|
2795
|
+
return standardResult({
|
|
2796
|
+
summary: `Checkpoint saved for '${node.name}' (${nodeIdA}). Run diff_nodes with checkpoint:"compare" after editing to see what changed.`,
|
|
2797
|
+
data: { nodeId: nodeIdA, saved: true },
|
|
2798
|
+
recommended_next: []
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
if (checkpoint === "compare") {
|
|
2802
|
+
const [node, stored] = await Promise.all([
|
|
2803
|
+
sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 }),
|
|
2804
|
+
sendCommand("get_plugin_data", { nodeId: nodeIdA, key: CHECKPOINT_KEY })
|
|
2805
|
+
]);
|
|
2806
|
+
if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
|
|
2807
|
+
const raw = typeof stored === "string" ? stored : stored?.value;
|
|
2808
|
+
if (!raw) {
|
|
2809
|
+
return errorResult2("invalid_input", `No checkpoint saved on '${nodeIdA}'`, {
|
|
2810
|
+
suggestion: 'Save one first with checkpoint:"save"',
|
|
2811
|
+
tool: "diff_nodes"
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
const saved = JSON.parse(raw);
|
|
2815
|
+
const before = saved.summary;
|
|
2816
|
+
const after = normalizeNode(node);
|
|
2817
|
+
const differences2 = [];
|
|
2818
|
+
if (before && after) {
|
|
2819
|
+
compareField(differences2, "type", before.type, after.type);
|
|
2820
|
+
compareField(differences2, "fill", before.fill, after.fill);
|
|
2821
|
+
compareField(differences2, "stroke", before.stroke, after.stroke);
|
|
2822
|
+
compareField(differences2, "cornerRadius", before.cornerRadius, after.cornerRadius);
|
|
2823
|
+
compareField(differences2, "opacity", before.opacity, after.opacity);
|
|
2824
|
+
compareField(differences2, "width", before.size?.width, after.size.width);
|
|
2825
|
+
compareField(differences2, "height", before.size?.height, after.size.height);
|
|
2826
|
+
compareField(differences2, "layout.mode", before.layout?.mode, after.layout?.mode);
|
|
2827
|
+
compareField(differences2, "layout.gap", before.layout?.gap, after.layout?.gap);
|
|
2828
|
+
compareField(differences2, "layout.padding", before.layout?.padding, after.layout?.padding);
|
|
2829
|
+
}
|
|
2830
|
+
const age = Math.round((Date.now() - (saved.ts ?? Date.now())) / 1e3);
|
|
2831
|
+
return standardResult({
|
|
2832
|
+
summary: differences2.length === 0 ? `'${node.name}' is unchanged since the checkpoint ${age}s ago` : `${differences2.length} propert${differences2.length === 1 ? "y" : "ies"} changed on '${node.name}' since the checkpoint ${age}s ago`,
|
|
2833
|
+
data: {
|
|
2834
|
+
nodeId: nodeIdA,
|
|
2835
|
+
checkpointTs: saved.ts,
|
|
2836
|
+
identical: differences2.length === 0,
|
|
2837
|
+
differences: differences2
|
|
2838
|
+
},
|
|
2839
|
+
recommended_next: []
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
if (!nodeIdB) {
|
|
2843
|
+
return errorResult2("invalid_input", 'Provide nodeIdB, or use checkpoint:"save"/"compare" with nodeIdA alone', {
|
|
2844
|
+
suggestion: "Two-node diff needs both ids",
|
|
2845
|
+
tool: "diff_nodes"
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
const [nodeA, nodeB] = await Promise.all([
|
|
2849
|
+
sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 }),
|
|
2850
|
+
sendCommand("get_node_info", { nodeId: nodeIdB, depth: 2 })
|
|
2851
|
+
]);
|
|
2852
|
+
if (!nodeA) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
|
|
2853
|
+
if (!nodeB) return errorResult2("invalid_input", `Node '${nodeIdB}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
|
|
2854
|
+
const summaryA = normalizeNode(nodeA);
|
|
2855
|
+
const summaryB = normalizeNode(nodeB);
|
|
2856
|
+
const differences = [];
|
|
2857
|
+
if (summaryA && summaryB) {
|
|
2858
|
+
compareField(differences, "type", summaryA.type, summaryB.type);
|
|
2859
|
+
compareField(differences, "fill", summaryA.fill, summaryB.fill);
|
|
2860
|
+
compareField(differences, "stroke", summaryA.stroke, summaryB.stroke);
|
|
2861
|
+
compareField(differences, "cornerRadius", summaryA.cornerRadius, summaryB.cornerRadius);
|
|
2862
|
+
compareField(differences, "opacity", summaryA.opacity, summaryB.opacity);
|
|
2863
|
+
compareField(differences, "width", summaryA.size.width, summaryB.size.width);
|
|
2864
|
+
compareField(differences, "height", summaryA.size.height, summaryB.size.height);
|
|
2865
|
+
compareField(differences, "layout.mode", summaryA.layout?.mode, summaryB.layout?.mode);
|
|
2866
|
+
compareField(differences, "layout.gap", summaryA.layout?.gap, summaryB.layout?.gap);
|
|
2867
|
+
compareField(differences, "layout.padding", summaryA.layout?.padding, summaryB.layout?.padding);
|
|
2868
|
+
}
|
|
2869
|
+
const data = {
|
|
2870
|
+
nodeA: { id: nodeIdA, name: nodeA.name },
|
|
2871
|
+
nodeB: { id: nodeIdB, name: nodeB.name },
|
|
2872
|
+
identical: differences.length === 0,
|
|
2873
|
+
differences
|
|
2874
|
+
};
|
|
2875
|
+
return standardResult({
|
|
2876
|
+
summary: differences.length === 0 ? `'${nodeA.name}' and '${nodeB.name}' are identical in key properties` : `${differences.length} difference(s) between '${nodeA.name}' and '${nodeB.name}'`,
|
|
2877
|
+
data,
|
|
2878
|
+
recommended_next: differences.length > 0 ? [{ tool: "update_node", reason: "Align properties between nodes" }] : []
|
|
2879
|
+
});
|
|
2880
|
+
} catch (error) {
|
|
2881
|
+
return errorResult2(
|
|
2882
|
+
"connection_error",
|
|
2883
|
+
`Diff failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2884
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
2885
|
+
);
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
);
|
|
2889
|
+
}
|
|
2890
|
+
function collectColorIssues(node, boundVars, issues, counters) {
|
|
2891
|
+
if (node.fills?.length > 0) {
|
|
2892
|
+
const solidFills = node.fills.filter((f) => f.type === "SOLID" && f.visible !== false);
|
|
2893
|
+
if (solidFills.length > 0) {
|
|
2894
|
+
counters.totalProps++;
|
|
2895
|
+
const hasFillBinding = Object.keys(boundVars).some((k) => k.startsWith("fills"));
|
|
2896
|
+
if (hasFillBinding) {
|
|
2897
|
+
counters.boundCount++;
|
|
2898
|
+
} else {
|
|
2899
|
+
for (const fill of solidFills) {
|
|
2900
|
+
if (!fill.color) continue;
|
|
2901
|
+
const color = typeof fill.color === "string" ? fill.color : rgbaToHex({
|
|
2902
|
+
r: fill.color.r,
|
|
2903
|
+
g: fill.color.g,
|
|
2904
|
+
b: fill.color.b,
|
|
2905
|
+
a: fill.color.a ?? 1
|
|
2906
|
+
});
|
|
2907
|
+
issues.push({
|
|
2908
|
+
nodeId: node.id,
|
|
2909
|
+
nodeName: node.name,
|
|
2910
|
+
property: "fill",
|
|
2911
|
+
color
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
if (node.strokes?.length > 0) {
|
|
2918
|
+
const solidStrokes = node.strokes.filter((s) => s.type === "SOLID" && s.visible !== false);
|
|
2919
|
+
if (solidStrokes.length > 0) {
|
|
2920
|
+
counters.totalProps++;
|
|
2921
|
+
const hasStrokeBinding = Object.keys(boundVars).some((k) => k.startsWith("strokes"));
|
|
2922
|
+
if (hasStrokeBinding) {
|
|
2923
|
+
counters.boundCount++;
|
|
2924
|
+
} else {
|
|
2925
|
+
for (const stroke of solidStrokes) {
|
|
2926
|
+
if (!stroke.color) continue;
|
|
2927
|
+
const color = typeof stroke.color === "string" ? stroke.color : rgbaToHex({
|
|
2928
|
+
r: stroke.color.r,
|
|
2929
|
+
g: stroke.color.g,
|
|
2930
|
+
b: stroke.color.b,
|
|
2931
|
+
a: stroke.color.a ?? 1
|
|
2932
|
+
});
|
|
2933
|
+
issues.push({
|
|
2934
|
+
nodeId: node.id,
|
|
2935
|
+
nodeName: node.name,
|
|
2936
|
+
property: "stroke",
|
|
2937
|
+
color
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
function collectFrames(node) {
|
|
2945
|
+
const frames = [];
|
|
2946
|
+
if (node.type === "FRAME" || node.type === "COMPONENT" || node.type === "COMPONENT_SET") {
|
|
2947
|
+
frames.push(node);
|
|
2948
|
+
}
|
|
2949
|
+
if (node.children) {
|
|
2950
|
+
for (const child of node.children) {
|
|
2951
|
+
frames.push(...collectFrames(child));
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
return frames;
|
|
2955
|
+
}
|
|
2956
|
+
function analyzeLayoutNode(node, issues, _counters) {
|
|
2957
|
+
if (node.type === "FRAME" || node.type === "COMPONENT") {
|
|
2958
|
+
if (!node.layoutMode || node.layoutMode === "NONE") {
|
|
2959
|
+
if (node.children?.length >= 2) {
|
|
2960
|
+
issues.push({
|
|
2961
|
+
nodeId: node.id,
|
|
2962
|
+
nodeName: node.name,
|
|
2963
|
+
issue: `Frame '${node.name}' has ${node.children.length} children but no auto-layout`,
|
|
2964
|
+
severity: "warning",
|
|
2965
|
+
suggestion: "Add auto-layout with set_auto_layout for responsive behavior"
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
if (node.children) {
|
|
2971
|
+
for (const child of node.children) {
|
|
2972
|
+
analyzeLayoutNode(child, issues, _counters);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
function checkAccessibility(node, parentBg, issues) {
|
|
2977
|
+
if (node.type === "INSTANCE" || node.name?.toLowerCase().includes("button")) {
|
|
2978
|
+
const w = node.width ?? node.absoluteBoundingBox?.width ?? 0;
|
|
2979
|
+
const h = node.height ?? node.absoluteBoundingBox?.height ?? 0;
|
|
2980
|
+
if (w > 0 && w < 44 || h > 0 && h < 44) {
|
|
2981
|
+
issues.push({
|
|
2982
|
+
nodeId: node.id,
|
|
2983
|
+
nodeName: node.name,
|
|
2984
|
+
issue: `Touch target too small: ${Math.round(w)}\xD7${Math.round(h)}px (minimum 44\xD744px)`
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
if (node.type === "TEXT") {
|
|
2989
|
+
const fontSize = typeof node.fontSize === "number" ? node.fontSize : null;
|
|
2990
|
+
const fontWeight = typeof node.fontWeight === "number" ? node.fontWeight : 400;
|
|
2991
|
+
if (fontSize !== null && fontSize < 11) {
|
|
2992
|
+
issues.push({
|
|
2993
|
+
nodeId: node.id,
|
|
2994
|
+
nodeName: node.name,
|
|
2995
|
+
issue: `Text size ${fontSize}px is below the 11px readability floor`
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
const textFill = node.fills?.find((f) => f.type === "SOLID" && f.visible !== false);
|
|
2999
|
+
if (textFill?.color && parentBg?.color) {
|
|
3000
|
+
if (parentBg.solid) {
|
|
3001
|
+
const alpha = (textFill.opacity ?? 1) * (typeof node.opacity === "number" ? node.opacity : 1) * (textFill.color.a ?? 1);
|
|
3002
|
+
const effective = blendOver(textFill.color, parentBg.color, alpha);
|
|
3003
|
+
const isLarge = fontSize !== null && (fontSize >= 24 || fontSize >= 18.66 && fontWeight >= 700);
|
|
3004
|
+
const required = isLarge ? 3 : 4.5;
|
|
3005
|
+
const ratio = calculateContrastRatio(effective, parentBg.color);
|
|
3006
|
+
if (ratio < required) {
|
|
3007
|
+
issues.push({
|
|
3008
|
+
nodeId: node.id,
|
|
3009
|
+
nodeName: node.name,
|
|
3010
|
+
issue: `Low contrast ratio: ${ratio.toFixed(1)}:1 (minimum ${required}:1 for ${isLarge ? "large" : "body"} text${alpha < 1 ? ", opacity included" : ""})`,
|
|
3011
|
+
contrastRatio: Math.round(ratio * 10) / 10,
|
|
3012
|
+
requiredRatio: required
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
} else {
|
|
3016
|
+
issues.push({
|
|
3017
|
+
nodeId: node.id,
|
|
3018
|
+
nodeName: node.name,
|
|
3019
|
+
issue: `Text sits on a ${parentBg.kind} background \u2014 contrast cannot be verified automatically; check manually or add a solid scrim`
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
let bg = parentBg;
|
|
3025
|
+
if (node.type !== "TEXT" && node.fills?.length > 0) {
|
|
3026
|
+
const visible = node.fills.filter((f) => f.visible !== false);
|
|
3027
|
+
const solid = visible.find((f) => f.type === "SOLID");
|
|
3028
|
+
const nonSolid = visible.find((f) => f.type !== "SOLID");
|
|
3029
|
+
if (nonSolid) {
|
|
3030
|
+
const type = String(nonSolid.type ?? "IMAGE");
|
|
3031
|
+
bg = {
|
|
3032
|
+
color: solid?.color ?? { r: 0.5, g: 0.5, b: 0.5 },
|
|
3033
|
+
solid: false,
|
|
3034
|
+
kind: type.startsWith("GRADIENT") ? "gradient" : type.toLowerCase()
|
|
3035
|
+
};
|
|
3036
|
+
} else if (solid?.color) {
|
|
3037
|
+
bg = { color: solid.color, solid: true, kind: "solid" };
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
if (node.children) {
|
|
3041
|
+
for (const child of node.children) {
|
|
3042
|
+
checkAccessibility(child, bg, issues);
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
function blendOver(fg, bg, alpha) {
|
|
3047
|
+
const a = Math.max(0, Math.min(1, alpha));
|
|
3048
|
+
return {
|
|
3049
|
+
r: fg.r * a + bg.r * (1 - a),
|
|
3050
|
+
g: fg.g * a + bg.g * (1 - a),
|
|
3051
|
+
b: fg.b * a + bg.b * (1 - a)
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
function calculateContrastRatio(fg, bg) {
|
|
3055
|
+
const fgL = relativeLuminance(fg.r ?? 0, fg.g ?? 0, fg.b ?? 0);
|
|
3056
|
+
const bgL = relativeLuminance(bg.r ?? 0, bg.g ?? 0, bg.b ?? 0);
|
|
3057
|
+
const lighter = Math.max(fgL, bgL);
|
|
3058
|
+
const darker = Math.min(fgL, bgL);
|
|
3059
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
3060
|
+
}
|
|
3061
|
+
function relativeLuminance(r, g, b) {
|
|
3062
|
+
const rL = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
|
|
3063
|
+
const gL = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
|
|
3064
|
+
const bL = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
|
|
3065
|
+
return 0.2126 * rL + 0.7152 * gL + 0.0722 * bL;
|
|
3066
|
+
}
|
|
3067
|
+
function compareField(diffs, field, a, b) {
|
|
3068
|
+
if (a !== b) {
|
|
3069
|
+
diffs.push({ field, nodeA: a, nodeB: b });
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
// src/tools/v2/verification.ts
|
|
3074
|
+
var verification_exports = {};
|
|
3075
|
+
__export(verification_exports, {
|
|
3076
|
+
register: () => register20
|
|
3077
|
+
});
|
|
3078
|
+
import { z as z21 } from "zod";
|
|
3079
|
+
function register20(server, sendCommand) {
|
|
3080
|
+
server.tool(
|
|
3081
|
+
"verify_changes",
|
|
3082
|
+
"Verify that a node's current properties match expected values. Use after update_node, set_auto_layout, or batch_execute to confirm changes applied correctly. Returns match/mismatch for each checked field.",
|
|
3083
|
+
{
|
|
3084
|
+
nodeId: z21.string().describe("Node ID to verify"),
|
|
3085
|
+
expected: z21.record(z21.string(), z21.unknown()).describe("Expected property values, e.g. { fill: '#3B82F6', cornerRadius: 8 }")
|
|
3086
|
+
},
|
|
3087
|
+
{ readOnlyHint: true },
|
|
3088
|
+
async ({ nodeId, expected }) => {
|
|
3089
|
+
try {
|
|
3090
|
+
const nodeInfo = await sendCommand("get_node_info", { nodeId, depth: 2 });
|
|
3091
|
+
if (!nodeInfo) {
|
|
3092
|
+
return errorResult2(
|
|
3093
|
+
"invalid_input",
|
|
3094
|
+
`Node '${nodeId}' not found \u2014 it may have been deleted`,
|
|
3095
|
+
{ suggestion: "Use search_nodes to find the node", tool: "search_nodes" }
|
|
3096
|
+
);
|
|
3097
|
+
}
|
|
3098
|
+
const normalized = normalizeNode(nodeInfo);
|
|
3099
|
+
if (!normalized) {
|
|
3100
|
+
return errorResult2(
|
|
3101
|
+
"invalid_input",
|
|
3102
|
+
`Could not normalize node '${nodeId}'`,
|
|
3103
|
+
{ suggestion: "Try get_node_details for raw data", tool: "get_node_details", args: { nodeId } }
|
|
3104
|
+
);
|
|
3105
|
+
}
|
|
3106
|
+
const fields = [];
|
|
3107
|
+
for (const [key, expectedValue] of Object.entries(expected)) {
|
|
3108
|
+
const actual = getNestedValue(normalized, nodeInfo, key);
|
|
3109
|
+
const match = deepEqual(actual, expectedValue);
|
|
3110
|
+
fields.push({ field: key, expected: expectedValue, actual, match });
|
|
3111
|
+
}
|
|
3112
|
+
const allMatch = fields.every((f) => f.match);
|
|
3113
|
+
const data = {
|
|
3114
|
+
nodeId,
|
|
3115
|
+
allMatch,
|
|
3116
|
+
fields
|
|
3117
|
+
};
|
|
3118
|
+
return standardResult({
|
|
3119
|
+
summary: allMatch ? `All ${fields.length} checked properties match expected values` : `${fields.filter((f) => !f.match).length} of ${fields.length} properties don't match expected values`,
|
|
3120
|
+
data,
|
|
3121
|
+
recommended_next: allMatch ? [{ tool: "screenshot", reason: "Visually confirm the changes" }] : [{ tool: "update_node", reason: "Re-apply changes that didn't match", args: { nodeId } }]
|
|
3122
|
+
});
|
|
3123
|
+
} catch (error) {
|
|
3124
|
+
return errorResult2(
|
|
3125
|
+
"connection_error",
|
|
3126
|
+
`Verification failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3127
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
3128
|
+
);
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
);
|
|
3132
|
+
server.tool(
|
|
3133
|
+
"validate_design_rules",
|
|
3134
|
+
"Run design quality rules on a node: token coverage, auto-layout usage, naming conventions, and accessibility basics. Use after modifications or during design audits. Returns pass/fail for each rule with fix suggestions.",
|
|
3135
|
+
{
|
|
3136
|
+
nodeId: z21.string().optional().describe("Root node to validate (default: current selection)")
|
|
3137
|
+
},
|
|
3138
|
+
{ readOnlyHint: true },
|
|
3139
|
+
async ({ nodeId }) => {
|
|
3140
|
+
try {
|
|
3141
|
+
let targetId;
|
|
3142
|
+
if (nodeId) {
|
|
3143
|
+
targetId = nodeId;
|
|
3144
|
+
} else {
|
|
3145
|
+
const selection = await sendCommand("get_selection");
|
|
3146
|
+
if (!selection?.nodes?.length) return noSelectionError();
|
|
3147
|
+
targetId = selection.nodes[0].id;
|
|
3148
|
+
}
|
|
3149
|
+
const [nodeInfo, boundVars] = await Promise.all([
|
|
3150
|
+
sendCommand("get_node_info", { nodeId: targetId, depth: 2 }),
|
|
3151
|
+
sendCommand("get_bound_variables", { nodeId: targetId }).catch(() => null)
|
|
3152
|
+
]);
|
|
3153
|
+
if (!nodeInfo) {
|
|
3154
|
+
return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
|
|
3155
|
+
}
|
|
3156
|
+
const bv = boundVars?.boundVariables || {};
|
|
3157
|
+
const results = [];
|
|
3158
|
+
const coverage = calculateTokenCoverage(nodeInfo, bv);
|
|
3159
|
+
results.push({
|
|
3160
|
+
rule: "token_coverage",
|
|
3161
|
+
passed: coverage >= 0.8,
|
|
3162
|
+
severity: coverage >= 0.5 ? "warning" : "error",
|
|
3163
|
+
message: `Token coverage: ${Math.round(coverage * 100)}% (target: 80%+)`,
|
|
3164
|
+
nodeId: targetId,
|
|
3165
|
+
fix: coverage < 0.8 ? { tool: "bind_tokens", reason: "Bind unbound properties to design tokens", args: { nodeId: targetId } } : void 0
|
|
3166
|
+
});
|
|
3167
|
+
if (nodeInfo.type === "FRAME" || nodeInfo.type === "COMPONENT") {
|
|
3168
|
+
const hasAutoLayout = nodeInfo.layoutMode && nodeInfo.layoutMode !== "NONE";
|
|
3169
|
+
const hasChildren = nodeInfo.children?.length >= 2;
|
|
3170
|
+
results.push({
|
|
3171
|
+
rule: "auto_layout",
|
|
3172
|
+
passed: hasAutoLayout || !hasChildren,
|
|
3173
|
+
severity: "warning",
|
|
3174
|
+
message: hasAutoLayout ? `Auto-layout: ${nodeInfo.layoutMode}` : hasChildren ? `No auto-layout on frame with ${nodeInfo.children.length} children` : "No children requiring auto-layout",
|
|
3175
|
+
nodeId: targetId,
|
|
3176
|
+
fix: !hasAutoLayout && hasChildren ? { tool: "set_auto_layout", reason: "Add auto-layout for responsive behavior", args: { nodeId: targetId } } : void 0
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
3179
|
+
const hasDefaultName = /^(Frame|Rectangle|Ellipse|Group|Text|Vector|Line|Polygon|Star)\s*\d*$/.test(nodeInfo.name);
|
|
3180
|
+
results.push({
|
|
3181
|
+
rule: "naming_convention",
|
|
3182
|
+
passed: !hasDefaultName,
|
|
3183
|
+
severity: "info",
|
|
3184
|
+
message: hasDefaultName ? `'${nodeInfo.name}' uses a default Figma name \u2014 rename for clarity` : `Name '${nodeInfo.name}' looks intentional`,
|
|
3185
|
+
nodeId: targetId,
|
|
3186
|
+
fix: hasDefaultName ? { tool: "update_node", reason: "Rename with a semantic name", args: { nodeId: targetId } } : void 0
|
|
3187
|
+
});
|
|
3188
|
+
if (nodeInfo.type === "INSTANCE" || nodeInfo.name?.toLowerCase().includes("button")) {
|
|
3189
|
+
const w = nodeInfo.width ?? 0;
|
|
3190
|
+
const h = nodeInfo.height ?? 0;
|
|
3191
|
+
const adequate = w >= 44 && h >= 44;
|
|
3192
|
+
results.push({
|
|
3193
|
+
rule: "touch_target_size",
|
|
3194
|
+
passed: adequate,
|
|
3195
|
+
severity: "warning",
|
|
3196
|
+
message: adequate ? `Touch target: ${Math.round(w)}\xD7${Math.round(h)}px (OK)` : `Touch target: ${Math.round(w)}\xD7${Math.round(h)}px (minimum 44\xD744px)`,
|
|
3197
|
+
nodeId: targetId,
|
|
3198
|
+
fix: !adequate ? { tool: "update_node", reason: "Increase size to meet touch target minimum", args: { nodeId: targetId } } : void 0
|
|
3199
|
+
});
|
|
3200
|
+
}
|
|
3201
|
+
try {
|
|
3202
|
+
const orph = await sendCommand("find_orphan_instances", { nodeId: targetId }, 6e4);
|
|
3203
|
+
if (orph && typeof orph.scanned === "number") {
|
|
3204
|
+
const found = orph.orphans ?? [];
|
|
3205
|
+
results.push({
|
|
3206
|
+
rule: "orphaned_instances",
|
|
3207
|
+
passed: found.length === 0,
|
|
3208
|
+
severity: "warning",
|
|
3209
|
+
message: found.length ? `${found.length} instance(s) reference deleted components (of ${orph.scanned} scanned): ${found.slice(0, 3).map((o) => o.name).join(", ")}${found.length > 3 ? "\u2026" : ""}` : `No orphaned instances (${orph.scanned} scanned)`,
|
|
3210
|
+
nodeId: targetId,
|
|
3211
|
+
fix: found.length ? { tool: "manage_components", reason: "Re-instantiate from a living component, or detach deliberately", args: { action: "list" } } : void 0
|
|
3212
|
+
});
|
|
3213
|
+
}
|
|
3214
|
+
} catch {
|
|
3215
|
+
}
|
|
3216
|
+
const passed = results.filter((r) => r.passed).length;
|
|
3217
|
+
const failed = results.filter((r) => !r.passed).length;
|
|
3218
|
+
const data = {
|
|
3219
|
+
rulesChecked: results.length,
|
|
3220
|
+
passed,
|
|
3221
|
+
failed,
|
|
3222
|
+
results
|
|
3223
|
+
};
|
|
3224
|
+
return standardResult({
|
|
3225
|
+
summary: `Design validation: ${passed}/${results.length} rules passed, ${failed} failed`,
|
|
3226
|
+
data,
|
|
3227
|
+
recommended_next: failed > 0 ? results.filter((r) => !r.passed && r.fix).map((r) => r.fix).slice(0, 3) : [{ tool: "screenshot", reason: "Visually confirm the design quality" }]
|
|
3228
|
+
});
|
|
3229
|
+
} catch (error) {
|
|
3230
|
+
return errorResult2(
|
|
3231
|
+
"connection_error",
|
|
3232
|
+
`Validation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3233
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
3234
|
+
);
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
);
|
|
3238
|
+
server.tool(
|
|
3239
|
+
"verify_visual",
|
|
3240
|
+
"The write\u2192see\u2192assert loop in one call: screenshots a node AND (optionally) checks expected property values. Returns the image plus structured pass/fail. Use after visual edits instead of separate screenshot + verify_changes calls.",
|
|
3241
|
+
{
|
|
3242
|
+
nodeId: z21.string().describe("Node to verify"),
|
|
3243
|
+
expected: z21.record(z21.string(), z21.unknown()).optional().describe("Expected property values, e.g. { cornerRadius: 8, width: 320 }")
|
|
3244
|
+
},
|
|
3245
|
+
{ readOnlyHint: true },
|
|
3246
|
+
async ({ nodeId, expected }) => {
|
|
3247
|
+
try {
|
|
3248
|
+
const [shot, nodeInfo] = await Promise.all([
|
|
3249
|
+
sendCommand("get_screenshot", { nodeId }, 6e4),
|
|
3250
|
+
expected ? sendCommand("get_node_info", { nodeId, depth: 2 }) : Promise.resolve(null)
|
|
3251
|
+
]);
|
|
3252
|
+
const content = [];
|
|
3253
|
+
if (shot?.imageData) {
|
|
3254
|
+
content.push({
|
|
3255
|
+
type: "image",
|
|
3256
|
+
data: shot.imageData,
|
|
3257
|
+
mimeType: shot.mimeType ?? "image/png"
|
|
3258
|
+
});
|
|
3259
|
+
}
|
|
3260
|
+
if (expected && nodeInfo) {
|
|
3261
|
+
const normalized = normalizeNode(nodeInfo) ?? {};
|
|
3262
|
+
const fields = Object.entries(expected).map(([key, expectedValue]) => {
|
|
3263
|
+
const actual = getNestedValue(normalized, nodeInfo, key);
|
|
3264
|
+
return { field: key, expected: expectedValue, actual, match: deepEqual(actual, expectedValue) };
|
|
3265
|
+
});
|
|
3266
|
+
const failing = fields.filter((f) => !f.match);
|
|
3267
|
+
content.push({
|
|
3268
|
+
type: "text",
|
|
3269
|
+
text: JSON.stringify(
|
|
3270
|
+
{
|
|
3271
|
+
summary: failing.length === 0 ? `All ${fields.length} checked properties match \u2014 confirm the screenshot looks right` : `${failing.length}/${fields.length} properties do NOT match`,
|
|
3272
|
+
fields
|
|
3273
|
+
},
|
|
3274
|
+
null,
|
|
3275
|
+
2
|
|
3276
|
+
)
|
|
3277
|
+
});
|
|
3278
|
+
} else if (!shot?.imageData) {
|
|
3279
|
+
content.push({ type: "text", text: "Screenshot unavailable for this node." });
|
|
3280
|
+
}
|
|
3281
|
+
return { content };
|
|
3282
|
+
} catch (error) {
|
|
3283
|
+
return errorResult2(
|
|
3284
|
+
"connection_error",
|
|
3285
|
+
`Visual verification failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3286
|
+
{ suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
|
|
3287
|
+
);
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
);
|
|
3291
|
+
}
|
|
3292
|
+
function getNestedValue(normalized, raw, key) {
|
|
3293
|
+
if (key in normalized) return normalized[key];
|
|
3294
|
+
if (key === "width") return normalized.size?.width;
|
|
3295
|
+
if (key === "height") return normalized.size?.height;
|
|
3296
|
+
if (key === "x") return normalized.position?.x;
|
|
3297
|
+
if (key === "y") return normalized.position?.y;
|
|
3298
|
+
return raw[key];
|
|
3299
|
+
}
|
|
3300
|
+
function deepEqual(a, b) {
|
|
3301
|
+
if (a === b) return true;
|
|
3302
|
+
if (a == null || b == null) return false;
|
|
3303
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
3304
|
+
return Math.abs(a - b) < 0.01;
|
|
3305
|
+
}
|
|
3306
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
3307
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
3308
|
+
}
|
|
3309
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
// src/tools/index.ts
|
|
3313
|
+
var moduleCategories = [
|
|
3314
|
+
[context_exports, "context"],
|
|
3315
|
+
[analysis_exports, "analysis"],
|
|
3316
|
+
[verification_exports, "verification"],
|
|
3317
|
+
[read_exports, "read"],
|
|
3318
|
+
[create_exports, "create"],
|
|
3319
|
+
[properties_exports, "edit"],
|
|
3320
|
+
[structure_exports, "edit"],
|
|
3321
|
+
[text_exports, "edit"],
|
|
3322
|
+
[components_exports, "components"],
|
|
3323
|
+
[variables_exports, "design-system"],
|
|
3324
|
+
[styles_exports, "design-system"],
|
|
3325
|
+
[library_exports, "design-system"],
|
|
3326
|
+
[design_system_exports, "design-system"],
|
|
3327
|
+
[pages_exports, "document"],
|
|
3328
|
+
[navigate_exports, "document"],
|
|
3329
|
+
[assets_exports, "assets"],
|
|
3330
|
+
[annotate_exports, "annotations"],
|
|
3331
|
+
[comments_exports, "comments"],
|
|
3332
|
+
[batch_exports, "advanced"],
|
|
3333
|
+
[execute_exports, "advanced"]
|
|
3334
|
+
];
|
|
3335
|
+
function registerRoomTool(server, joinRoom) {
|
|
3336
|
+
server.tool(
|
|
3337
|
+
"join_room",
|
|
3338
|
+
"Connect to a specific Figma plugin instance by room name. Usually unnecessary \u2014 pairing is automatic when one plugin is connected. Use only when an error reports multiple plugins/rooms; the room name is shown in each plugin's UI.",
|
|
3339
|
+
{
|
|
3340
|
+
room: z22.string().describe("Room name shown in the Figma plugin")
|
|
3341
|
+
},
|
|
3342
|
+
async ({ room }) => {
|
|
3343
|
+
try {
|
|
3344
|
+
if (!room) {
|
|
3345
|
+
return {
|
|
3346
|
+
content: [{ type: "text", text: "Please provide a room name." }]
|
|
3347
|
+
};
|
|
3348
|
+
}
|
|
3349
|
+
await joinRoom(room);
|
|
3350
|
+
return {
|
|
3351
|
+
content: [
|
|
3352
|
+
{ type: "text", text: `Successfully joined room: ${room}` }
|
|
3353
|
+
]
|
|
3354
|
+
};
|
|
3355
|
+
} catch (error) {
|
|
3356
|
+
return {
|
|
3357
|
+
content: [
|
|
3358
|
+
{
|
|
3359
|
+
type: "text",
|
|
3360
|
+
text: `Error joining room: ${error instanceof Error ? error.message : String(error)}`
|
|
3361
|
+
}
|
|
3362
|
+
]
|
|
3363
|
+
};
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
);
|
|
3367
|
+
}
|
|
3368
|
+
function registerEventsTool(server, consumeEvents, getSessionLog) {
|
|
3369
|
+
server.tool(
|
|
3370
|
+
"get_events",
|
|
3371
|
+
"Activity since the last check. scope 'designer' (default): the designer's selection/node/page changes (also piggybacked as designer_events on command results). scope 'agent': this session's own command log \u2014 every plugin command sent, with success/duration \u2014 useful for summarizing what was changed. 'all': both.",
|
|
3372
|
+
{
|
|
3373
|
+
scope: z22.enum(["designer", "agent", "all"]).optional()
|
|
3374
|
+
},
|
|
3375
|
+
{ readOnlyHint: true },
|
|
3376
|
+
async ({ scope = "designer" }) => {
|
|
3377
|
+
const parts = {};
|
|
3378
|
+
if (scope === "designer" || scope === "all") {
|
|
3379
|
+
parts.designer_events = consumeEvents();
|
|
3380
|
+
}
|
|
3381
|
+
if (scope === "agent" || scope === "all") {
|
|
3382
|
+
parts.session_log = getSessionLog();
|
|
3383
|
+
}
|
|
3384
|
+
const empty = Object.values(parts).every((v) => Array.isArray(v) && v.length === 0);
|
|
3385
|
+
return {
|
|
3386
|
+
content: [
|
|
3387
|
+
{
|
|
3388
|
+
type: "text",
|
|
3389
|
+
text: empty ? "No activity recorded." : JSON.stringify(parts, null, 2)
|
|
3390
|
+
}
|
|
3391
|
+
]
|
|
3392
|
+
};
|
|
3393
|
+
}
|
|
3394
|
+
);
|
|
3395
|
+
}
|
|
3396
|
+
function registerAllTools(server, sendCommand) {
|
|
3397
|
+
for (const [mod] of moduleCategories) {
|
|
3398
|
+
mod.register(server, sendCommand);
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
function listToolCatalog() {
|
|
3402
|
+
const catalog = [
|
|
3403
|
+
{ name: "join_room", category: "advanced" },
|
|
3404
|
+
{ name: "get_events", category: "context" }
|
|
3405
|
+
];
|
|
3406
|
+
for (const [mod, category] of moduleCategories) {
|
|
3407
|
+
const collector = {
|
|
3408
|
+
tool: (name) => {
|
|
3409
|
+
catalog.push({ name, category });
|
|
3410
|
+
}
|
|
3411
|
+
};
|
|
3412
|
+
mod.register(collector, async () => void 0);
|
|
3413
|
+
}
|
|
3414
|
+
return catalog;
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
// ../../docs/skills/figma-plugin-api.md
|
|
3418
|
+
var figma_plugin_api_default = '# Figma Plugin API Cheat Sheet \u2014 for `execute_figma`\n\nRules and patterns for writing code that runs in the plugin sandbox. Work in small incremental scripts and `screenshot` between steps.\n\n## Non-negotiable rules (dynamic-page mode)\n\n- **Node lookup is async**: `await figma.getNodeByIdAsync(id)` \u2014 `getNodeById` throws in this plugin.\n- **Load pages before traversal**: `await page.loadAsync()` before `page.findAll(...)` on a non-current page; `figma.currentPage` is always loaded.\n- **Load fonts before ANY text edit**: `await figma.loadFontAsync(textNode.fontName)` \u2014 if `fontName === figma.mixed`, load every range: `for (const f of textNode.getRangeAllFontNames(0, textNode.characters.length)) await figma.loadFontAsync(f)`.\n- **`figma.mixed` is a Symbol**: properties like `fontSize`, `cornerRadius`, `fills` return it when values differ across ranges/children. Check `=== figma.mixed` before using; never JSON-serialize it.\n\n## Common patterns\n\n```js\n// Selection\nconst sel = figma.currentPage.selection; // read\nfigma.currentPage.selection = [node]; // write\n\n// Create + place\nconst frame = figma.createFrame();\nframe.resize(320, 200); // width/height are read-only; use resize()\nparent.appendChild(frame); // default parent is currentPage\n\n// Fills/strokes are ARRAYS and must be reassigned wholesale\nnode.fills = [{ type: "SOLID", color: { r: 1, g: 0.5, b: 0 } }]; // rgb 0-1, no alpha key \u2014 use opacity\nconst fills = JSON.parse(JSON.stringify(node.fills)); // clone before mutating\nfills[0].color.r = 0; node.fills = fills;\n\n// Auto-layout: set layoutMode FIRST, then padding/spacing/align\nframe.layoutMode = "VERTICAL";\nframe.itemSpacing = 8; frame.paddingTop = 16;\nchild.layoutSizingHorizontal = "FILL"; // requires PARENT with auto-layout\n\n// Components\nconst comp = await figma.importComponentByKeyAsync(key); // team library\nconst inst = comp.createInstance();\ninst.setProperties({ Variant: "Primary" }); // throws on unknown property names\n\n// Variables\nconst collections = await figma.variables.getLocalVariableCollectionsAsync();\nconst v = figma.variables.createVariable("name", collection, "COLOR");\nv.setValueForMode(modeId, { r: 0, g: 0, b: 0 });\nnode.setBoundVariable("fills", v); // binding paint needs figma.variables.setBoundVariableForPaint on the paint object for fills in older APIs \u2014 prefer setBoundVariable where available\n\n// Export (returns Uint8Array)\nconst bytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 2 } });\n```\n\n## `relai.*` helpers \u2014 the shortest path is the correct one\n\nAlongside `figma`, scripts get a `relai` object whose helpers are immune to the pitfalls below. Prefer them:\n\n```js\nawait relai.text(parent, "Hello", { font: {family:"IBM Plex Mono", style:"Regular"}, size: 14, color: {r:0,g:0,b:0} });\n // loads the font BEFORE writing characters\nconst card = relai.autoLayout("VERTICAL", { name: "Card", itemSpacing: 8 });\n // auto-layout frame, both axes hugging\nrelai.set(node, { layoutMode: "HORIZONTAL", width: 320, opacity: 0.9 });\n // layoutMode applied first; width/height via resize()\nrelai.hug(node); // HUG that sticks \u2014 call after appending children\nrelai.focusRing(button); // clipsContent + double spread shadows that render\nconst page = await relai.page(p => p.children.some(c => c.name === "Button"));\n // find pages by CONTENT \u2014 names get renamed\nconst titles = relai.query(\'FRAME[name^=Card] > TEXT\');\n // CSS-like search on the current page; relai.query(node, sel) scopes to a subtree.\n // Supported: TYPE, *, [name=] [name*=] [name^=] [name$=], descendant, >, comma.\n // NOT supported: pseudo-classes, dot-paths, sibling combinators \u2014 use findAll.\nrelai.placeholder(section); // construction veil so the designer sees work-in-progress\nrelai.placeholder(section, false); // ALWAYS remove when the section is done\n```\n\nTwo important facts: **scripts are NOT atomic** \u2014 on error, changes made before the throw persist, so keep scripts small and clean up after failures; and results may carry a `warnings` array for silent mistakes the lint catches on relai-created nodes and any node ids you return (e.g. spread shadows on a non-clipping frame). Convention: **return every created/mutated node id** (`return { createdNodeIds: [...] }`) \u2014 it powers both follow-up calls and the lint.\n\n## Pitfalls that throw (or silently do the wrong thing)\n\nWhen one of these throws inside `execute_figma`, the error already carries the remedy as a `Hint:` \u2014 this list is generated from the same registry (`packages/shared/src/pitfalls.ts`).\n\n<!-- PITFALLS:START -->\n- **Editing text without loading its font throws** (`unloaded font`). `await figma.loadFontAsync(textNode.fontName)` first; new TextNodes default to Inter Regular; when `fontName === figma.mixed`, load every font from `getRangeAllFontNames()`.\n- **`getNodeById` throws in dynamic-page mode** \u2014 always `await figma.getNodeByIdAsync(id)`.\n- **Traversing a non-current page throws until you `await page.loadAsync()`** \u2014 `figma.currentPage` is always loaded, other pages are not.\n- **`layoutSizingHorizontal/Vertical` throw without auto-layout** \u2014 FILL requires the parent to have a `layoutMode`, HUG requires it on the node itself.\n- **`createPage()` throws on the free plan once a file has 3 pages** \u2014 reuse an existing page instead.\n- **`width`/`height` are read-only** \u2014 use `node.resize(w, h)`.\n- **`figma.mixed` is a Symbol** returned by `fontSize`, `cornerRadius`, `fills` etc. when values differ across ranges/children \u2014 check `=== figma.mixed` before use; never JSON-serialize it.\n- **Exact-name lookups are fragile** \u2014 designers rename pages and layers freely; locate nodes by type/content instead of `name ===`.\n- **Stale node ids throw `does not exist`** \u2014 nodes get deleted while you work; re-read before editing and check `node.removed`.\n- **Nodes are non-extensible** \u2014 `node.myCustomProp = x` throws `object is not extensible`; use `setPluginData` or return the data instead.\n- **Shadow `spread` renders only on shapes or frames with `clipsContent: true`** \u2014 a focus ring built from spread shadows is invisible on a non-clipping frame/component.\n- **`resize()` on an auto-layout frame silently pins that axis to FIXED**, overriding `primaryAxisSizingMode: "AUTO"`. Append children first, then set `layoutSizingHorizontal = "HUG"`; use `resize` only for the fixed cross-axis.\n- **Per-corner radius** (`topLeftRadius` \u2026) only exists on RectangleCornerMixin nodes (rectangles, frames, components) \u2014 polygons, stars and lines throw.\n- **Instance children can\'t be added or removed** \u2014 detach first, or edit the main component.\n<!-- PITFALLS:END -->\n\nAlso: `resize(w, h)` throws on `w <= 0 || h <= 0`; `layoutWrap = "WRAP"` is HORIZONTAL-only, `counterAxisAlignItems = "BASELINE"` too; writing to `locked` nodes is allowed by the API but surprises designers \u2014 check first.\n\n## Return values\n\nReturn JSON-serializable data (the bridge summarizes nodes automatically and truncates >50k chars). Prefer returning `{ id, name, type }` summaries over whole nodes. `console.log` is captured and returned in `logs`.\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
|
|
3419
|
+
|
|
3420
|
+
// ../../docs/skills/design-tokens-strategy.md
|
|
3421
|
+
var design_tokens_strategy_default = "# Design Tokens Strategy \u2014 4-Tier Token Architecture\n\n## Token Hierarchy\n\n### Tier 1 \u2014 Core (static)\nRaw values. Never referenced directly by components.\n- Naming: `color-static/{hue}/{shade}`, `spacing-static/{value}`\n- Examples: `color-static/slate/50`, `color-static/rose/500`\n- Single mode: Value\n- hiddenFromPublishing: true\n\n### Tier 1 \u2014 Core (dynamic)\nReferences static tokens. Handles Light/Dark inversion.\n- Naming: `color-dynamic/{hue}/{shade}`\n- Light mode: shade 50 \u2192 static/50, shade 900 \u2192 static/900\n- Dark mode: shade 50 \u2192 static/900, shade 900 \u2192 static/50 (inverted)\n- 2 modes: Light / Dark\n- hiddenFromPublishing: true\n\n### Tier 2 \u2014 Themes\nSemantic tokens. Define brand personality.\n- Naming: `color/{role}/{variant}`, `typography/{style}/{property}`, `border/{type}/{size}`\n- Color roles: background, content, border, icon\n- Color variants: surface-page, surface-default, surface-subtle, surface-knockout, disabled, utility/{status}/{emphasis|subtle}\n- Typography: headline-{lg|md|sm}, title-{lg|md}, body-{lg|md|sm}[-bold], label-{lg|md|sm}, code-{md|sm}[-bold]\n- Each typography style has: font-family, font-weight, font-size, letter-spacing, line-height\n- Modes: one per brand (e.g., Brand A, Brand B)\n- description: required \u2014 explain purpose and usage constraints\n- scopes: set appropriately (TEXT_FILL for content, STROKE_COLOR for border, FRAME_FILL for background, etc.)\n\n### Tier 3 \u2014 Components\nComponent-specific tokens. Reference Tier 2 or dynamic Tier 1.\n- Naming: `{component}/color/{role}/{state}`, `{component}/size/{variant}`\n- States: default, hover, pressed, disabled, selected, selected-hover, selected-pressed, selected-disabled\n- Sizes: sm, md, lg\n- Examples: `button/color/background/default`, `button/size/md`, `navigation/color/content/selected`\n- Modes: same as Tier 2 (one per brand)\n- Not every component needs Tier 3 \u2014 only when reuse or shared decision-making justifies it\n\n## Key Rules\n- Components MUST reference tokens only \u2014 never raw hex/px values\n- Tier 1 is internal plumbing \u2014 never expose to consumers\n- Adding a new brand = adding a mode to Tier 2 and Tier 3 collections\n- Light/Dark switching is handled entirely in Tier 1 dynamic \u2014 no brand-level light/dark logic needed\n\n## Workflow\n1. `get_design_tokens` \u2192 understand existing collections and modes\n2. `manage_variables` (list) with collectionId \u2192 inspect current tokens\n3. `manage_variables` (create_collection) \u2192 new collection with modes\n4. `manage_variables` (create) \u2192 define tokens with values per mode\n5. `manage_variables` (bind) \u2192 connect tokens to node properties\n6. `manage_variables` (set_scopes) \u2192 restrict UI picker visibility\n7. `manage_variables` (set_code_syntax) \u2192 set Dev Mode code references\n\n## Verification\n- `analyze_design` (aspect: color) \u2192 find unbound colors (token coverage)\n- `validate_design_rules` \u2192 check token_coverage rule\n- `get_node_data` (detail: variables) on key nodes \u2192 confirm bindings are correct\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n";
|
|
3422
|
+
|
|
3423
|
+
// ../../docs/skills/component-architecture-strategy.md
|
|
3424
|
+
var component_architecture_strategy_default = "# Component Architecture \u2014 Component Conventions\n\n## Token-First Principle\nEvery visual property must reference a token. No raw values in components.\n- Colors \u2192 Tier 2 semantic tokens or Tier 3 component tokens\n- Sizes (height, spacing) \u2192 Tier 2 or Tier 3 size tokens\n- Typography \u2192 Tier 2 typography tokens\n- Border radius/width \u2192 Tier 2 border tokens\n\n## Tier 3 Token Pattern\nWhen a component needs brand-differentiated values:\n\n`{component}/color/{role}/{state}`\n- Roles: background, border, content\n- States: default, hover, pressed, disabled\n- Selected states: selected, selected-hover, selected-pressed, selected-disabled\n- Variants: primary, ai, critical, ghost (prefix before role)\n\nExample \u2014 Button tokens:\n- `button/color/background/default` \u2192 neutral button bg\n- `button/primary/color/background/hover` \u2192 primary variant hover\n- `button/size/sm` / `button/size/md` / `button/size/lg`\n\n## State Management\nStandard interactive states:\n- default \u2192 hover \u2192 pressed \u2192 disabled (always present)\n- selected \u2192 selected-hover \u2192 selected-pressed \u2192 selected-disabled (for toggleable elements)\n\n## Size Variants\nUse sm / md / lg consistently. Define as Tier 3 FLOAT tokens when brand-differentiated.\n- `{component}/size/sm`, `{component}/size/md`, `{component}/size/lg`\n\n## Component Structure\n1. Use auto-layout (VERTICAL or HORIZONTAL) for all containers\n2. Use HUG for content-driven sizes, FILL for responsive elements\n3. Use component properties for configurable aspects:\n - BOOLEAN: toggle visibility (icons, labels)\n - INSTANCE_SWAP: swap nested components\n - VARIANT: select between variant sets\n - TEXT: configurable text content\n\n## Workflow\n1. `get_selection_context` \u2192 understand current component structure\n2. `manage_components` (list) \u2192 check existing components\n3. Design the component with auto-layout\n4. Create variants for states using `manage_components` (create_set)\n5. `manage_variables` (bind) \u2192 attach Tier 2/3 tokens to all visual properties\n6. `manage_components` (set_props) \u2192 configure instance properties\n\n## Verification\n- `analyze_design` (aspect: components) \u2192 find detached instances, unused components\n- `verify_changes` \u2192 confirm properties match expected values\n- `get_node_data` (detail: variables) \u2192 ensure all fills/strokes are token-bound\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n";
|
|
3425
|
+
|
|
3426
|
+
// ../../docs/skills/design-audit-strategy.md
|
|
3427
|
+
var design_audit_strategy_default = "# Design Audit Strategy\n\n## Phase 1: Overview\n1. `get_document_overview` \u2192 pages, component/style/variable counts\n2. `get_design_tokens` \u2192 token system structure (collections, modes, coverage)\n\n## Phase 2: Color & Token Audit\n1. `analyze_design` (aspect: color) \u2192 find colors not backed by variables\n2. `get_node_data` (detail: variables) on key nodes \u2192 verify correct tier references\n3. Check: components should reference Tier 2/3 tokens, never Tier 1 or raw values\n\n## Phase 3: Layout Quality\n1. `analyze_design` (aspect: layout) \u2192 detect missing auto-layout, inconsistent spacing\n2. Check: all containers should use auto-layout (not absolute positioning)\n3. Check: spacing should use token values, not arbitrary px\n\n## Phase 4: Component Health\n1. `analyze_design` (aspect: components) \u2192 detached instances, unused components\n2. `manage_components` (list) \u2192 review component inventory\n3. Check: all instances should be connected to their main component\n\n## Phase 5: Accessibility\n1. `analyze_design` (aspect: accessibility) \u2192 contrast ratios, touch target sizes\n2. Check: minimum contrast 4.5:1 for text, 3:1 for large text\n3. Check: touch targets \u2265 44\xD744px\n\n## Phase 6: Comprehensive Validation\n1. `validate_design_rules` \u2192 runs all checks in one pass:\n - token_coverage: % of nodes with variable bindings\n - auto_layout: % of frames using auto-layout\n - naming_convention: layer naming quality\n - touch_target_size: minimum size compliance\n\n## Reporting\n- `screenshot` before and after fixes\n- `annotate` (set) / `annotate` (set_multiple) to flag issues directly in the design\n- Summarize findings by severity (critical \u2192 warning \u2192 info)\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n";
|
|
3428
|
+
|
|
3429
|
+
// ../../docs/skills/token-audit.md
|
|
3430
|
+
var token_audit_default = "# Token Audit \u2014 Token Hierarchy Compliance Check\n\n## Purpose\nVerify that a design correctly uses the 4-tier token hierarchy:\n- No raw color/size values (everything should be token-bound)\n- Correct tier references (components \u2192 Tier 2/3, never Tier 1 directly)\n- Consistent scope usage\n\n## Audit Process\n\n### Step 1: Scope the Audit\n- `get_selection_context` \u2192 identify the area to audit\n- Or `get_document_overview` \u2192 audit the full document\n\n### Step 2: Color Token Coverage\n- `analyze_design` (aspect: color) \u2192 find all colors and whether they're token-bound\n- Flag any raw hex values not backed by variables\n\n### Step 3: Variable Binding Check\n- `get_node_data` (detail: variables) on key nodes \u2192 check what's bound\n- For each binding, verify the variable comes from the correct tier:\n - \u2705 Component referencing Tier 2 (e.g., `color/content/default`)\n - \u2705 Component referencing Tier 3 (e.g., `button/color/background/default`)\n - \u274C Component referencing Tier 1 (e.g., `color-static/slate/500`)\n - \u274C Component referencing Tier 1 dynamic (e.g., `color-dynamic/slate/500`)\n\n### Step 4: Scope Validation\n- `manage_variables` (list) \u2192 check that scopes are set correctly:\n - Color content tokens \u2192 TEXT_FILL\n - Color background tokens \u2192 FRAME_FILL, SHAPE_FILL\n - Color border tokens \u2192 STROKE_COLOR\n - Size tokens \u2192 WIDTH_HEIGHT\n - Border radius \u2192 CORNER_RADIUS\n - Border width \u2192 STROKE_FLOAT\n\n### Step 5: Comprehensive Check\n- `validate_design_rules` \u2192 run all design rules at once\n\n## Output Format\nReport findings as:\n- **Coverage**: X% of nodes have token bindings\n- **Tier violations**: list of nodes referencing wrong tier\n- **Raw values**: list of unbound colors/sizes with suggested token mappings\n- **Scope issues**: variables with missing or incorrect scopes\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n";
|
|
3431
|
+
|
|
3432
|
+
// ../../docs/skills/component-spec.md
|
|
3433
|
+
var component_spec_default = "# Component Specification Generator\n\n## Purpose\nGenerate a structured specification document for a component, covering its properties, variants, token bindings, and states.\n\n## Process\n\n### Step 1: Identify the Component\n- `get_selection_context` \u2192 read the selected component/instance\n- Or `search_nodes` by name to find it\n\n### Step 2: Extract Properties\n- `manage_components` (get_props) \u2192 list all configurable properties (VARIANT, BOOLEAN, INSTANCE_SWAP, TEXT)\n- `get_node_details` \u2192 full node structure with children summary\n\n### Step 3: Token Bindings\n- `get_node_data` (detail: variables) \u2192 list all token bindings on the component and its children\n- `manage_variables` (list) \u2192 resolve variable names, descriptions, scopes, codeSyntax\n- Map bindings to the 4-tier token structure\n\n### Step 4: Visual Reference\n- `screenshot` \u2192 capture the component in its default state\n- If variants exist, capture key states (default, hover, disabled, etc.)\n\n### Step 5: Generate Spec\n\nOutput format:\n\n#### {Component Name}\n**Description**: (from context or user input)\n\n**Properties**\n| Name | Type | Values | Default |\n|------|------|--------|---------|\n\n**Token Bindings**\n| Property | Token | Tier | Value (default mode) |\n|----------|-------|------|---------------------|\n\n**States**\n| State | Visual Changes |\n|-------|---------------|\n\n**Size Variants**\n| Size | Height | Token |\n|------|--------|-------|\n\n## Tips\n- For components from a library (remote: true), some properties may be read-only\n- Use `manage_components` (get_overrides) to see what's been customized on instances\n- Include codeSyntax values for developer handoff\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n";
|
|
3434
|
+
|
|
3435
|
+
// ../../docs/skills/comment-driven-tasks.md
|
|
3436
|
+
var comment_driven_tasks_default = '# Comment-driven tasks\n\nDesigners leave requests as **Figma comments**; you pick them up, do the work, and reply on the thread. This turns Relai into an async collaborator without the designer ever leaving Figma \u2014 comments are their native input surface.\n\n## Honest model\n\nThis is **polling, not a live feed**. An MCP server only acts when called, so requests are noticed when you check \u2014 at the start of a session, when the user says "check the comments", or on a loop the user runs in their own client. Say so if the user expects real-time pickup.\n\nRequires `FIGMA_TOKEN` (personal access token with comment scopes). A free-plan token works on the user\'s own files.\n\n## The loop\n\n1. **Scan** \u2014 `manage_comments` `action:"list"` with `unresolved:true`, plus `since:<checkedAt from the previous scan>` after the first pass. Keep the returned `checkedAt` as the next cursor.\n2. **Select** \u2014 treat a comment as a task when it reads as an instruction ("make this...", "fix...", "@relai ..."). Skip chatter between humans and threads you already replied to (your replies carry your token account\'s handle).\n3. **Anchor** \u2014 pinned comments carry a `nodeId`. Start there: `get_node_details` / `screenshot` for context. Unpinned comments apply to the file broadly; ask before guessing a target.\n4. **Claim** \u2014 reply on the thread *before* working: `action:"reply"` with a one-liner like `\u{1F916} On it \u2014 <what you understood>`. This prevents double-work across sessions and tells the designer they were heard.\n5. **Execute** \u2014 normal Relai flow: design-system first, verify with `verify_visual` / `screenshot`.\n6. **Report back** \u2014 reply on the same thread with what changed and the node ids touched, e.g. `\u{1F916} Done: CTA restyled to primary/600, radius token applied (2 nodes). Reply here if it\'s off.` The REST API cannot mark threads resolved \u2014 the designer resolves it, which doubles as their sign-off.\n\n## One-shot phrasing for users\n\nUsers can drive the whole loop with a single ask: *"Check the file\'s comments and handle anything addressed to you, then report back on each thread."* For a standing loop, they re-issue that ask periodically (or wire it to their client\'s scheduler if it has one).\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
|
|
3437
|
+
|
|
3438
|
+
// ../../docs/skills/design-system-first.md
|
|
3439
|
+
var design_system_first_default = '# Design-system-first building\n\nThe difference between "AI slop" and work a design team accepts is almost always this: **slop draws new shapes; good work reuses the file\'s own system**. Before building any UI in a file that has one, wire yourself to it.\n\n## The sequence\n\n1. **`get_design_system`** \u2014 one call, cached per session. Read what exists: components (sorted by usage \u2014 high-usage items are the file\'s vocabulary), variable collections, styles, and the remote items the file already consumes. If the team\'s library lives in another file and `FIGMA_TOKEN` is set, pass `libraryFileUrl` for the full catalog.\n2. **`manage_conventions` (get)** \u2014 if the file carries conventions, they outrank your defaults. (`get_document_overview` includes them automatically.)\n3. **Compose from instances** \u2014 `manage_components` `action:"instantiate"` with the component key (works for local AND enabled-library keys; remote keys import automatically). Set variants/text via `set_props`. Only draw raw shapes for genuinely new elements.\n4. **Bind, don\'t hardcode** \u2014 colors/spacing/radius come from the file\'s variables (`manage_variables` `action:"bind"`, or variable fields in `set_properties`). If you must hardcode during exploration, run `analyze_design aspect:"tokens"` afterward and fix with `manage_variables action:"tokenize" fix:true`.\n5. **Verify like a designer** \u2014 `screenshot` / `verify_visual` after each increment, not at the end.\n\n## When the file has no system\n\nDon\'t invent variables for a one-off request. But if the work IS system-shaped (a palette, a type scale, components), build variables first, then styles/components on top \u2014 and offer to record naming decisions in `manage_conventions`.\n\n## Signals you\'re drifting\n\n- You drew a rectangle that looks like an existing Button/Card/Badge \u2192 replace it with an instance.\n- A hex value appears in your code that `get_design_system` lists as a variable \u2192 bind instead.\n- You renamed or restructured library instances the designer didn\'t ask about \u2192 undo; instances belong to their component.\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
|
|
3440
|
+
|
|
3441
|
+
// ../../docs/skills/janitorial-cleanup.md
|
|
3442
|
+
var janitorial_cleanup_default = '# Janitorial cleanup\n\nThe chores designers postpone for months \u2014 layer renaming, token drift, spacing normalization \u2014 are where an agent earns trust fastest. These jobs are big, mechanical, and easy to verify. Do them conservatively and loudly.\n\n## Ground rules\n\n- **Preview before bulk.** Use `dryRun:true` on `batch_execute` / `set_properties`, or report findings first (`analyze_design`), and tell the designer the scale ("about 140 renames on this page \u2014 go?"). If the plugin\'s approval gate is on, big writes will ask them anyway.\n- **Scope tightly.** Work page by page (or within the designer\'s selection). Never sweep the whole file unasked.\n- **Never rename or restyle library instances\' internals** \u2014 only top-level frames/groups the team owns.\n\n## Recipes\n\n### Rename layers by content\n`Frame 427` tells nobody anything. Walk the target scope with `execute_figma` + `relai.query`, and derive names:\n- A frame whose only text child says "Submit" \u2192 `Button/Submit` (match the file\'s existing naming pattern from `get_design_system`, don\'t invent one).\n- Text nodes \u2192 their content, truncated (`Heading: Pricing plans`).\n- Skip nodes that already have deliberate names (anything not matching `/^(Frame|Group|Rectangle|Ellipse|Vector|Line) \\d+$/`).\nReturn the rename list; apply via one `batch_execute` of `rename_node` commands.\n\n### Tokenize hardcoded values\n`analyze_design aspect:"tokens"` finds hardcoded colors/numbers that visually match existing variables; `manage_variables action:"tokenize" fix:true` binds them in one pass. Report anything close-but-not-matching (deltaE just over tolerance) instead of silently snapping it.\n\n### Normalize spacing\nIn auto-layout scopes, collect `itemSpacing`/padding values; flag off-scale values (not on the file\'s 4/8pt grid or spacing variables) and propose the nearest on-scale value. Apply only after the designer confirms the mapping.\n\n### Sweep detached instances\n`analyze_design aspect:"components"` lists likely-detached instances. Offer to re-link obvious ones (matching component still exists) and just report the rest \u2014 re-attaching wrong is worse than detached.\n\n## Report format\n\nEnd with counts and jump-points: what changed, what was skipped and why, node ids for spot-checking (the designer can click entries in the plugin feed).\n\n---\n\n*Tool parameter contracts are deliberately not duplicated in this document \u2014 every tool is self-describing over MCP, and `npx figma-relai docs <tool>` (or the generated `docs/manifest.json`) is the always-current reference.*\n';
|
|
3443
|
+
|
|
3444
|
+
// src/prompts.ts
|
|
3445
|
+
var SKILLS = [
|
|
3446
|
+
[
|
|
3447
|
+
"figma-plugin-api",
|
|
3448
|
+
"Cheat sheet for writing execute_figma code: dynamic-page rules, font loading, auto-layout pitfalls, common patterns. Load before non-trivial execute_figma work.",
|
|
3449
|
+
figma_plugin_api_default
|
|
3450
|
+
],
|
|
3451
|
+
[
|
|
3452
|
+
"design-tokens-strategy",
|
|
3453
|
+
"4-tier design-token architecture: naming, mode design, tier reference rules. Load before creating or restructuring variables.",
|
|
3454
|
+
design_tokens_strategy_default
|
|
3455
|
+
],
|
|
3456
|
+
[
|
|
3457
|
+
"component-architecture-strategy",
|
|
3458
|
+
"Component conventions: token-first properties, state handling, size variants, variant naming. Load before building components.",
|
|
3459
|
+
component_architecture_strategy_default
|
|
3460
|
+
],
|
|
3461
|
+
[
|
|
3462
|
+
"design-audit-strategy",
|
|
3463
|
+
"End-to-end design audit workflow: overview \u2192 tokens \u2192 per-aspect analysis \u2192 report.",
|
|
3464
|
+
design_audit_strategy_default
|
|
3465
|
+
],
|
|
3466
|
+
[
|
|
3467
|
+
"token-audit",
|
|
3468
|
+
"Token-compliance check: find raw values, tier violations, scope issues, unbound properties.",
|
|
3469
|
+
token_audit_default
|
|
3470
|
+
],
|
|
3471
|
+
[
|
|
3472
|
+
"component-spec",
|
|
3473
|
+
"Generate a structured component specification: properties, variants, token bindings, state matrix.",
|
|
3474
|
+
component_spec_default
|
|
3475
|
+
],
|
|
3476
|
+
[
|
|
3477
|
+
"comment-driven-tasks",
|
|
3478
|
+
"Async collaboration via Figma comments: poll unresolved threads, claim, execute, report back on-thread. Load when asked to 'handle the comments' or work comment-driven.",
|
|
3479
|
+
comment_driven_tasks_default
|
|
3480
|
+
],
|
|
3481
|
+
[
|
|
3482
|
+
"design-system-first",
|
|
3483
|
+
"Build UI from the file's own system: inventory \u2192 conventions \u2192 instantiate components \u2192 bind variables \u2192 verify. Load before building screens in any file that has a design system.",
|
|
3484
|
+
design_system_first_default
|
|
3485
|
+
],
|
|
3486
|
+
[
|
|
3487
|
+
"janitorial-cleanup",
|
|
3488
|
+
"Bulk cleanup recipes: content-based layer renaming, tokenizing hardcoded values, spacing normalization, detached-instance sweeps \u2014 with preview-first discipline.",
|
|
3489
|
+
janitorial_cleanup_default
|
|
3490
|
+
]
|
|
3491
|
+
];
|
|
3492
|
+
function registerPrompts(server) {
|
|
3493
|
+
for (const [name, description, text] of SKILLS) {
|
|
3494
|
+
server.prompt(name, description, () => ({
|
|
3495
|
+
messages: [
|
|
3496
|
+
{
|
|
3497
|
+
role: "user",
|
|
3498
|
+
content: { type: "text", text }
|
|
3499
|
+
}
|
|
3500
|
+
]
|
|
3501
|
+
}));
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
export {
|
|
3506
|
+
createServer,
|
|
3507
|
+
FIGMA_COMMANDS,
|
|
3508
|
+
RelayCore,
|
|
3509
|
+
PITFALLS,
|
|
3510
|
+
registerRoomTool,
|
|
3511
|
+
registerEventsTool,
|
|
3512
|
+
registerAllTools,
|
|
3513
|
+
listToolCatalog,
|
|
3514
|
+
registerPrompts
|
|
3515
|
+
};
|
|
3516
|
+
//# sourceMappingURL=chunk-EBP7POOP.js.map
|