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/index.js CHANGED
@@ -1,79 +1,22 @@
1
1
  #!/usr/bin/env node
2
- var __defProp = Object.defineProperty;
3
- var __export = (target, all) => {
4
- for (var name in all)
5
- __defProp(target, name, { get: all[name], enumerable: true });
6
- };
2
+ import {
3
+ RelayCore,
4
+ createServer,
5
+ listToolCatalog,
6
+ registerAllTools,
7
+ registerEventsTool,
8
+ registerPrompts,
9
+ registerRoomTool
10
+ } from "./chunk-EBP7POOP.js";
11
+ import {
12
+ loadState,
13
+ saveState
14
+ } from "./chunk-XBWGSCRZ.js";
15
+ import "./chunk-2H7UOFLK.js";
7
16
 
8
17
  // src/index.ts
9
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
19
 
11
- // src/server.ts
12
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
- function createServer() {
14
- return new McpServer(
15
- {
16
- name: "Relai",
17
- version: "0.1.4"
18
- },
19
- {
20
- instructions: `
21
- 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.
22
-
23
- \u{1F4D6} UNDERSTAND (start here):
24
- get_document_overview \u2014 structure, page/component/style/variable counts
25
- get_selection_context \u2014 full context for what the designer selected
26
- get_node_details \u2014 deep single-node inspection (CSS, token bindings)
27
- search_nodes \u2014 find nodes by name/type \xB7 get_node_data \u2014 raw data/tree/css
28
- get_design_tokens \u2014 variable collections, modes, styles
29
- screenshot \u2014 see the canvas (use this to verify your work visually)
30
-
31
- \u{1F50D} DIAGNOSE:
32
- analyze_design \u2014 aspect: color/layout/components/accessibility, or
33
- "overall" for a weighted 0-100 health score across all four
34
- diff_nodes \u2014 compare two nodes, or checkpoint save/compare to audit
35
- what changed on a node over an editing session
36
-
37
- \u270F\uFE0F EDIT:
38
- create_node \u2014 any node type (rectangle/frame/text/svg/image/\u2026)
39
- set_properties \u2014 one call for geometry, fills, strokes, effects, text,
40
- auto-layout, constraints, style/variable bindings, on one or many nodes
41
- set_text \u2014 single, bulk, or character-range text edits
42
- edit_structure \u2014 group/reparent/reorder/clone/flatten/boolean/delete
43
- navigate \u2014 focus/select/viewport/switch_page \xB7 manage_pages
44
-
45
- \u{1F9F1} DESIGN SYSTEM:
46
- manage_components \u2014 create/variants/instantiate/overrides/props/detach
47
- manage_variables \u2014 collections, modes, tokens, bind/unbind
48
- manage_styles \u2014 paint/text/effect/grid styles
49
- import_from_library \u2014 bring in library components/styles/variables
50
-
51
- \u{1F4E6} ASSETS: export_asset (PNG/JPG/SVG/PDF) \xB7 add_image (URL or fill)
52
- \u{1F4DD} ANNOTATIONS: annotate (Dev Mode annotations)
53
- \u{1F4AC} COMMENTS: manage_comments \u2014 read/apply/reply to file comments
54
- (needs FIGMA_TOKEN env; the tool explains setup when missing)
55
-
56
- \u2705 VERIFY: verify_changes \xB7 validate_design_rules
57
-
58
- \u26A1 ADVANCED:
59
- batch_execute \u2014 run many plugin-level commands in one round-trip
60
- execute_figma \u2014 run JavaScript against the Figma Plugin API directly
61
- (the escape hatch when no tool fits; small incremental scripts,
62
- screenshot between steps; the designer can disable it)
63
-
64
- OPERATING PRINCIPLES:
65
- - Inspect context BEFORE changing things; don't assume selection state
66
- - After visual edits, verify: screenshot or verify_changes
67
- - Colors are RGBA in 0-1 range (Figma format), not 0-255
68
- - layoutSizing FILL requires the node's PARENT to have auto-layout;
69
- set layoutMode on a frame before padding/alignment/spacing
70
- - Responses include recommended_next tools \u2014 follow them
71
- - Errors name the node, its type, and the fix \u2014 read them, don't retry blindly
72
- `
73
- }
74
- );
75
- }
76
-
77
20
  // src/connection.ts
78
21
  import WebSocket from "ws";
79
22
  import { v4 as uuidv4 } from "uuid";
@@ -432,3047 +375,88 @@ var FigmaConnection = class {
432
375
  }
433
376
  };
434
377
 
435
- // src/tools/index.ts
436
- import { z as z21 } from "zod";
437
-
438
- // src/tools/core/create.ts
439
- var create_exports = {};
440
- __export(create_exports, {
441
- register: () => register
442
- });
443
- import { z as z2 } from "zod";
444
-
445
- // ../shared/src/utils/color.ts
446
- function rgbaToHex(color) {
447
- const r = Math.round(color.r * 255);
448
- const g = Math.round(color.g * 255);
449
- const b = Math.round(color.b * 255);
450
- const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
451
- if (color.a !== void 0 && color.a < 1) {
452
- const a = Math.round(color.a * 255);
453
- return hex + a.toString(16).padStart(2, "0");
454
- }
455
- return hex;
456
- }
457
-
458
- // ../shared/src/utils/schemas.ts
459
- import { z } from "zod";
460
- var colorSchema = z.object({
461
- r: z.number().min(0).max(1).describe("Red channel (0-1)"),
462
- g: z.number().min(0).max(1).describe("Green channel (0-1)"),
463
- b: z.number().min(0).max(1).describe("Blue channel (0-1)"),
464
- a: z.number().min(0).max(1).optional().describe("Alpha channel (0-1)")
465
- });
466
- var dimensionSchema = z.number().positive();
467
- var layoutModeSchema = z.enum(["NONE", "HORIZONTAL", "VERTICAL"]);
468
- var layoutWrapSchema = z.enum(["NO_WRAP", "WRAP"]);
469
- var primaryAxisAlignSchema = z.enum([
470
- "MIN",
471
- "MAX",
472
- "CENTER",
473
- "SPACE_BETWEEN"
474
- ]);
475
- var counterAxisAlignSchema = z.enum([
476
- "MIN",
477
- "MAX",
478
- "CENTER",
479
- "BASELINE"
480
- ]);
481
- var sizingModeSchema = z.enum(["FIXED", "HUG", "FILL"]);
482
- var exportFormatSchema = z.enum(["PNG", "JPG", "SVG", "PDF"]);
483
- var blendModeSchema = z.enum([
484
- "PASS_THROUGH",
485
- "NORMAL",
486
- "DARKEN",
487
- "MULTIPLY",
488
- "LINEAR_BURN",
489
- "COLOR_BURN",
490
- "LIGHTEN",
491
- "SCREEN",
492
- "LINEAR_DODGE",
493
- "COLOR_DODGE",
494
- "OVERLAY",
495
- "SOFT_LIGHT",
496
- "HARD_LIGHT",
497
- "DIFFERENCE",
498
- "EXCLUSION",
499
- "HUE",
500
- "SATURATION",
501
- "COLOR",
502
- "LUMINOSITY"
503
- ]);
504
- var gradientTypeSchema = z.enum([
505
- "GRADIENT_LINEAR",
506
- "GRADIENT_RADIAL",
507
- "GRADIENT_ANGULAR",
508
- "GRADIENT_DIAMOND"
509
- ]);
510
- var gradientStopSchema = z.object({
511
- position: z.number().min(0).max(1).describe("Stop position (0-1)"),
512
- color: colorSchema
513
- });
514
- var booleanOperationSchema = z.enum([
515
- "UNION",
516
- "SUBTRACT",
517
- "INTERSECT",
518
- "EXCLUDE"
519
- ]);
520
- var constraintTypeSchema = z.enum([
521
- "MIN",
522
- "CENTER",
523
- "MAX",
524
- "STRETCH",
525
- "SCALE"
526
- ]);
527
- var reorderDirectionSchema = z.enum([
528
- "FRONT",
529
- "BACK",
530
- "FORWARD",
531
- "BACKWARD"
532
- ]);
533
-
534
- // ../shared/src/relay-core.ts
535
- var RelayCore = class {
536
- rooms = /* @__PURE__ */ new Map();
537
- lastActivity = /* @__PURE__ */ new Map();
538
- staleTimer = null;
539
- version;
540
- log;
541
- constructor(options = {}) {
542
- this.version = options.version ?? "unknown";
543
- this.log = options.log ?? (() => {
544
- });
545
- }
546
- handleOpen(ws) {
547
- this.lastActivity.set(ws, Date.now());
548
- this.send(ws, { type: "system", message: "Please join a room to start" });
549
- }
550
- handleMessage(ws, raw) {
551
- this.lastActivity.set(ws, Date.now());
552
- let data;
553
- try {
554
- data = JSON.parse(raw);
555
- } catch {
556
- this.send(ws, { type: "error", message: "Invalid JSON" });
557
- return;
558
- }
559
- switch (data.type) {
560
- case "hello":
561
- this.send(ws, { type: "hello", server: "figma-relai", version: this.version });
562
- return;
563
- case "join": {
564
- const room = data.room;
565
- if (!room || typeof room !== "string") {
566
- this.send(ws, { type: "error", message: "Room name required" });
567
- return;
568
- }
569
- const role = data.role === "plugin" || data.role === "agent" ? data.role : "unknown";
570
- const peers = this.roomPeers(room);
571
- peers.set(ws, { role, meta: data.meta });
572
- this.log(`Client joined "${room}" as ${role} (${peers.size} clients)`);
573
- this.send(ws, { type: "system", message: `Joined room: ${room}`, room });
574
- this.send(ws, {
575
- type: "system",
576
- message: { id: data.id, result: `Connected to room: ${room}` },
577
- room
578
- });
579
- this.broadcastPresence(room);
580
- return;
581
- }
582
- case "list_rooms": {
583
- const rooms = [];
584
- for (const [room, peers] of this.rooms) {
585
- if (peers.size === 0) continue;
586
- const pluginPeer = [...peers.values()].find((p) => p.role === "plugin");
587
- const fileName = typeof pluginPeer?.meta?.fileName === "string" ? pluginPeer.meta.fileName : void 0;
588
- rooms.push({
589
- room,
590
- hasPlugin: pluginPeer !== void 0,
591
- agentCount: [...peers.values()].filter((p) => p.role === "agent").length,
592
- ...fileName ? { fileName } : {}
593
- });
594
- }
595
- this.send(ws, { type: "list_rooms_result", id: data.id, rooms });
596
- return;
378
+ // src/embedded-relay.ts
379
+ import { createServer as createServer2 } from "http";
380
+ import { WebSocketServer } from "ws";
381
+ function startEmbeddedRelay(port2, version) {
382
+ return new Promise((resolve) => {
383
+ const httpServer = createServer2();
384
+ httpServer.once("error", (err) => {
385
+ if (err.code === "EADDRINUSE") {
386
+ resolve(null);
387
+ } else {
388
+ logger.warn(`Embedded relay failed to start: ${err.message}`);
389
+ resolve(null);
597
390
  }
598
- case "message": {
599
- const room = data.room;
600
- if (!room || typeof room !== "string") {
601
- this.send(ws, { type: "error", message: "Room name required" });
602
- return;
603
- }
604
- if (!this.rooms.get(room)?.has(ws)) {
605
- this.send(ws, { type: "error", message: "Must join room first" });
606
- return;
607
- }
608
- const payload = JSON.stringify({
609
- type: "broadcast",
610
- message: data.message,
611
- sender: "peer",
612
- room
391
+ });
392
+ httpServer.listen(port2, "127.0.0.1", () => {
393
+ const core = new RelayCore({
394
+ version,
395
+ log: (msg) => logger.debug(`[relay] ${msg}`)
396
+ });
397
+ const wss = new WebSocketServer({ server: httpServer });
398
+ wss.on("connection", (ws) => {
399
+ core.handleOpen(ws);
400
+ ws.on("message", (data) => core.handleMessage(ws, data.toString()));
401
+ ws.on("close", () => core.handleClose(ws));
402
+ ws.on("error", () => {
613
403
  });
614
- const count = this.broadcast(ws, room, payload);
615
- if (count === 0) this.log(`No peers in "${room}" to receive message`);
616
- return;
617
- }
618
- case "progress_update": {
619
- const room = data.room;
620
- if (room && typeof room === "string" && this.rooms.get(room)?.has(ws)) {
621
- this.broadcast(ws, room, raw);
622
- }
623
- return;
624
- }
625
- }
626
- }
627
- handleClose(ws) {
628
- this.lastActivity.delete(ws);
629
- const affected = [];
630
- for (const [room, peers] of this.rooms) {
631
- if (peers.delete(ws)) affected.push(room);
632
- if (peers.size === 0) this.rooms.delete(room);
633
- }
634
- for (const room of affected) this.broadcastPresence(room);
635
- }
636
- // Periodically close connections with no activity (heartbeat)
637
- startStaleCleanup(staleTimeoutMs = 12e4, checkIntervalMs = 3e4) {
638
- this.staleTimer = setInterval(() => {
639
- const now = Date.now();
640
- for (const [ws, lastTime] of this.lastActivity) {
641
- if (now - lastTime > staleTimeoutMs) {
642
- this.log("Closing stale connection");
643
- try {
644
- ws.close();
645
- } catch {
646
- }
647
- this.handleClose(ws);
404
+ });
405
+ core.startStaleCleanup();
406
+ logger.info(`Hosting embedded relay on 127.0.0.1:${port2}`);
407
+ resolve({
408
+ close() {
409
+ core.stop();
410
+ wss.close();
411
+ httpServer.close();
648
412
  }
649
- }
650
- }, checkIntervalMs);
651
- }
652
- stop() {
653
- if (this.staleTimer) {
654
- clearInterval(this.staleTimer);
655
- this.staleTimer = null;
656
- }
657
- }
658
- roomPeers(room) {
659
- let peers = this.rooms.get(room);
660
- if (!peers) {
661
- peers = /* @__PURE__ */ new Map();
662
- this.rooms.set(room, peers);
663
- }
664
- return peers;
665
- }
666
- // Presence goes to every member (including the newcomer) so both the plugin
667
- // UI and the MCP server always know who is in the room
668
- broadcastPresence(room) {
669
- const peers = this.rooms.get(room);
670
- if (!peers) return;
671
- const payload = JSON.stringify({
672
- type: "presence",
673
- room,
674
- peers: [...peers.values()].map((p) => ({ role: p.role, meta: p.meta }))
675
- });
676
- for (const ws of peers.keys()) {
677
- try {
678
- ws.send(payload);
679
- } catch {
680
- }
681
- }
682
- }
683
- broadcast(sender, room, payload) {
684
- const peers = this.rooms.get(room);
685
- if (!peers) return 0;
686
- let count = 0;
687
- for (const ws of peers.keys()) {
688
- if (ws === sender) continue;
689
- try {
690
- ws.send(payload);
691
- count++;
692
- } catch {
693
- }
694
- }
695
- return count;
696
- }
697
- send(ws, data) {
698
- try {
699
- ws.send(JSON.stringify(data));
700
- } catch {
701
- }
702
- }
703
- };
704
-
705
- // src/tools/core/helpers.ts
706
- function textResult(text) {
707
- return { content: [{ type: "text", text }] };
708
- }
709
- function jsonResult(value) {
710
- return textResult(typeof value === "string" ? value : JSON.stringify(value, null, 2));
711
- }
712
- function errorResult(error) {
713
- return textResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
714
- }
715
-
716
- // src/tools/core/create.ts
717
- var NODE_KINDS = [
718
- "rectangle",
719
- "frame",
720
- "text",
721
- "ellipse",
722
- "polygon",
723
- "star",
724
- "line",
725
- "section",
726
- "slice",
727
- "svg",
728
- "image",
729
- "connector",
730
- "table",
731
- "sticky",
732
- "code_block"
733
- ];
734
- var KIND_COMMAND = {
735
- rectangle: "create_rectangle",
736
- frame: "create_frame",
737
- text: "create_text",
738
- ellipse: "create_ellipse",
739
- polygon: "create_polygon",
740
- star: "create_star",
741
- line: "create_line",
742
- section: "create_section",
743
- slice: "create_slice",
744
- svg: "create_node_from_svg",
745
- image: "create_image_from_url",
746
- connector: "create_connector",
747
- table: "create_table",
748
- sticky: "create_sticky",
749
- code_block: "create_code_block"
750
- };
751
- function register(server, sendCommand) {
752
- server.tool(
753
- "create_node",
754
- "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.",
755
- {
756
- type: z2.enum(NODE_KINDS).describe("What to create"),
757
- x: z2.number().optional().describe("X position (default 0)"),
758
- y: z2.number().optional().describe("Y position (default 0)"),
759
- width: dimensionSchema.optional(),
760
- height: dimensionSchema.optional(),
761
- name: z2.string().optional(),
762
- parentId: z2.string().optional().describe("Parent node id (default: current page)"),
763
- text: z2.string().optional().describe("Text content (text/sticky)"),
764
- fontSize: z2.number().positive().optional().describe("text only"),
765
- fontWeight: z2.number().optional().describe("text only (e.g. 400, 700)"),
766
- fontColor: colorSchema.optional().describe("text only"),
767
- fillColor: colorSchema.optional().describe("frame/rectangle fill"),
768
- pointCount: z2.number().int().min(3).optional().describe("polygon/star points"),
769
- innerRadius: z2.number().min(0).max(1).optional().describe("star inner radius ratio"),
770
- length: z2.number().positive().optional().describe("line length"),
771
- rotation: z2.number().optional().describe("line rotation in degrees"),
772
- strokeWeight: z2.number().min(0).optional().describe("line stroke weight"),
773
- svg: z2.string().optional().describe("SVG markup (type=svg)"),
774
- url: z2.string().optional().describe("Image URL (type=image)"),
775
- scaleMode: z2.enum(["FILL", "FIT", "CROP", "TILE"]).optional().describe("image only"),
776
- rows: z2.number().int().positive().optional().describe("table only"),
777
- cols: z2.number().int().positive().optional().describe("table only"),
778
- startNodeId: z2.string().optional().describe("connector only"),
779
- endNodeId: z2.string().optional().describe("connector only"),
780
- lineType: z2.enum(["ELBOWED", "STRAIGHT"]).optional().describe("connector only")
781
- },
782
- async ({ type, ...params }) => {
783
- try {
784
- const clean = Object.fromEntries(
785
- Object.entries(params).filter(([, v]) => v !== void 0)
786
- );
787
- const result = await sendCommand(KIND_COMMAND[type], clean);
788
- return jsonResult(result);
789
- } catch (error) {
790
- return errorResult(error);
791
- }
792
- }
793
- );
794
- }
795
-
796
- // src/tools/core/properties.ts
797
- var properties_exports = {};
798
- __export(properties_exports, {
799
- mapPropertiesToCommands: () => mapPropertiesToCommands,
800
- propertiesSchema: () => propertiesSchema,
801
- register: () => register2
802
- });
803
- import { z as z3 } from "zod";
804
- var effectSchema = z3.object({
805
- type: z3.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]),
806
- color: colorSchema.optional().describe("Shadow color (shadows only)"),
807
- offsetX: z3.number().optional(),
808
- offsetY: z3.number().optional(),
809
- radius: z3.number().min(0).optional().describe("Blur radius"),
810
- spread: z3.number().optional()
811
- });
812
- var propertiesSchema = z3.object({
813
- // Geometry
814
- x: z3.number().optional(),
815
- y: z3.number().optional(),
816
- width: dimensionSchema.optional(),
817
- height: dimensionSchema.optional(),
818
- rotation: z3.number().optional().describe("Degrees"),
819
- // Identity / visibility
820
- name: z3.string().optional(),
821
- visible: z3.boolean().optional(),
822
- locked: z3.boolean().optional(),
823
- opacity: z3.number().min(0).max(1).optional(),
824
- blendMode: blendModeSchema.optional(),
825
- // Paint
826
- fillColor: colorSchema.optional(),
827
- strokeColor: colorSchema.optional(),
828
- strokeWeight: z3.number().min(0).optional(),
829
- cornerRadius: z3.number().min(0).optional(),
830
- corners: z3.array(z3.boolean()).length(4).optional().describe("With cornerRadius: apply per corner [TL, TR, BR, BL]"),
831
- gradient: z3.object({ gradientType: gradientTypeSchema, stops: z3.array(gradientStopSchema).min(2) }).optional(),
832
- // Effects (replaces existing effects list entry of same type)
833
- effects: z3.array(effectSchema).optional(),
834
- removeEffects: z3.boolean().optional(),
835
- // Text
836
- text: z3.string().optional().describe("Text content (TEXT nodes)"),
837
- // Auto-layout (frame itself)
838
- layoutMode: layoutModeSchema.optional(),
839
- layoutWrap: layoutWrapSchema.optional().describe("WRAP requires layoutMode HORIZONTAL"),
840
- paddingTop: z3.number().min(0).optional(),
841
- paddingRight: z3.number().min(0).optional(),
842
- paddingBottom: z3.number().min(0).optional(),
843
- paddingLeft: z3.number().min(0).optional(),
844
- primaryAxisAlignItems: primaryAxisAlignSchema.optional(),
845
- counterAxisAlignItems: counterAxisAlignSchema.optional(),
846
- itemSpacing: z3.number().optional(),
847
- counterAxisSpacing: z3.number().optional(),
848
- // Auto-layout (as a child; FILL requires the PARENT to have auto-layout)
849
- layoutSizingHorizontal: sizingModeSchema.optional(),
850
- layoutSizingVertical: sizingModeSchema.optional(),
851
- // Constraints & sizing limits
852
- constraintsHorizontal: constraintTypeSchema.optional(),
853
- constraintsVertical: constraintTypeSchema.optional(),
854
- minWidth: z3.number().min(0).optional(),
855
- maxWidth: z3.number().min(0).optional(),
856
- minHeight: z3.number().min(0).optional(),
857
- maxHeight: z3.number().min(0).optional(),
858
- aspectRatioLocked: z3.boolean().optional(),
859
- clipsContent: z3.boolean().optional(),
860
- // Design-system bindings
861
- styleId: z3.string().optional().describe("Apply a paint/text/effect/grid style by id"),
862
- styleType: z3.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional(),
863
- boundVariables: z3.record(z3.string()).optional().describe('Bind variables: {"fills": "VariableID:...", "width": "..."}')
864
- }).strict();
865
- function defined(obj) {
866
- return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
867
- }
868
- function mapPropertiesToCommands(nodeId, p) {
869
- const calls = [];
870
- const add = (command, params) => calls.push({ command, params: { nodeId, ...defined(params) } });
871
- if (p.name !== void 0) add("rename_node", { name: p.name });
872
- if (p.visible !== void 0) add("set_visible", { visible: p.visible });
873
- if (p.locked !== void 0) add("set_locked", { locked: p.locked });
874
- if (p.x !== void 0 || p.y !== void 0) add("move_node", { x: p.x, y: p.y });
875
- if (p.width !== void 0 || p.height !== void 0)
876
- add("resize_node", { width: p.width, height: p.height });
877
- if (p.rotation !== void 0) add("set_rotation", { rotation: p.rotation });
878
- if (p.layoutMode !== void 0)
879
- add("set_layout_mode", { layoutMode: p.layoutMode, layoutWrap: p.layoutWrap });
880
- if (p.paddingTop !== void 0 || p.paddingRight !== void 0 || p.paddingBottom !== void 0 || p.paddingLeft !== void 0)
881
- add("set_padding", {
882
- paddingTop: p.paddingTop,
883
- paddingRight: p.paddingRight,
884
- paddingBottom: p.paddingBottom,
885
- paddingLeft: p.paddingLeft
886
- });
887
- if (p.primaryAxisAlignItems !== void 0 || p.counterAxisAlignItems !== void 0)
888
- add("set_axis_align", {
889
- primaryAxisAlignItems: p.primaryAxisAlignItems,
890
- counterAxisAlignItems: p.counterAxisAlignItems
891
- });
892
- if (p.itemSpacing !== void 0 || p.counterAxisSpacing !== void 0)
893
- add("set_item_spacing", {
894
- itemSpacing: p.itemSpacing,
895
- counterAxisSpacing: p.counterAxisSpacing
896
- });
897
- if (p.layoutSizingHorizontal !== void 0 || p.layoutSizingVertical !== void 0)
898
- add("set_layout_sizing", {
899
- layoutSizingHorizontal: p.layoutSizingHorizontal,
900
- layoutSizingVertical: p.layoutSizingVertical
901
- });
902
- if (p.fillColor !== void 0) add("set_fill_color", { color: p.fillColor });
903
- if (p.strokeColor !== void 0 || p.strokeWeight !== void 0)
904
- add("set_stroke_color", {
905
- color: p.strokeColor ?? { r: 0, g: 0, b: 0, a: 1 },
906
- weight: p.strokeWeight
907
- });
908
- if (p.gradient !== void 0)
909
- add("set_gradient_fill", { gradientType: p.gradient.gradientType, stops: p.gradient.stops });
910
- if (p.cornerRadius !== void 0)
911
- add("set_corner_radius", { radius: p.cornerRadius, corners: p.corners });
912
- if (p.opacity !== void 0) add("set_opacity", { opacity: p.opacity });
913
- if (p.blendMode !== void 0) add("set_blend_mode", { blendMode: p.blendMode });
914
- if (p.removeEffects) add("remove_effects", {});
915
- for (const effect of p.effects ?? []) {
916
- const common = {
917
- color: effect.color,
918
- offsetX: effect.offsetX,
919
- offsetY: effect.offsetY,
920
- radius: effect.radius,
921
- spread: effect.spread
922
- };
923
- if (effect.type === "DROP_SHADOW") add("add_drop_shadow", common);
924
- else if (effect.type === "INNER_SHADOW") add("add_inner_shadow", common);
925
- else if (effect.type === "LAYER_BLUR") add("add_blur", { radius: effect.radius });
926
- else add("add_background_blur", { radius: effect.radius });
927
- }
928
- if (p.text !== void 0) add("set_text_content", { text: p.text });
929
- if (p.constraintsHorizontal !== void 0 || p.constraintsVertical !== void 0)
930
- add("set_constraints", {
931
- horizontal: p.constraintsHorizontal,
932
- vertical: p.constraintsVertical
933
- });
934
- if (p.minWidth !== void 0 || p.maxWidth !== void 0 || p.minHeight !== void 0 || p.maxHeight !== void 0)
935
- add("set_min_max_size", {
936
- minWidth: p.minWidth,
937
- maxWidth: p.maxWidth,
938
- minHeight: p.minHeight,
939
- maxHeight: p.maxHeight
413
+ });
940
414
  });
941
- if (p.aspectRatioLocked !== void 0) add("set_aspect_ratio", { lock: p.aspectRatioLocked });
942
- if (p.clipsContent !== void 0) add("set_clips_content", { clipsContent: p.clipsContent });
943
- if (p.styleId !== void 0)
944
- add("apply_style", { styleId: p.styleId, styleType: p.styleType });
945
- for (const [property, variableId] of Object.entries(p.boundVariables ?? {})) {
946
- add("bind_variable", { property, variableId });
947
- }
948
- return calls;
949
- }
950
- function register2(server, sendCommand) {
951
- server.tool(
952
- "set_properties",
953
- "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.",
954
- {
955
- nodeIds: z3.array(z3.string()).min(1).describe("Node IDs to update"),
956
- properties: propertiesSchema.describe("Properties to apply to every listed node")
957
- },
958
- { idempotentHint: true },
959
- async ({ nodeIds, properties }) => {
960
- try {
961
- const commands = nodeIds.flatMap(
962
- (nodeId) => mapPropertiesToCommands(nodeId, properties)
963
- );
964
- if (commands.length === 0) return textResult("No properties given \u2014 nothing to do.");
965
- if (commands.length === 1) {
966
- const result = await sendCommand(commands[0].command, commands[0].params);
967
- return jsonResult(result);
968
- }
969
- const results = await sendCommand(
970
- "batch_execute",
971
- { commands },
972
- Math.max(3e4, commands.length * 5e3)
973
- );
974
- return jsonResult(results);
975
- } catch (error) {
976
- return errorResult(error);
977
- }
978
- }
979
- );
980
- }
981
-
982
- // src/tools/core/structure.ts
983
- var structure_exports = {};
984
- __export(structure_exports, {
985
- register: () => register3
986
- });
987
- import { z as z4 } from "zod";
988
- function register3(server, sendCommand) {
989
- server.tool(
990
- "edit_structure",
991
- "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.",
992
- {
993
- operation: z4.enum([
994
- "group",
995
- "ungroup",
996
- "reparent",
997
- "reorder",
998
- "clone",
999
- "flatten",
1000
- "boolean",
1001
- "delete"
1002
- ]),
1003
- nodeId: z4.string().optional().describe("Target node (ungroup/reparent/reorder/clone/flatten)"),
1004
- nodeIds: z4.array(z4.string()).optional().describe("Target nodes (group/boolean/delete)"),
1005
- parentId: z4.string().optional().describe("reparent: new parent"),
1006
- index: z4.number().int().min(0).optional().describe("reparent: insertion index"),
1007
- direction: reorderDirectionSchema.optional().describe("reorder direction"),
1008
- booleanOperation: booleanOperationSchema.optional().describe("boolean: which operation"),
1009
- x: z4.number().optional().describe("clone: position of the copy"),
1010
- y: z4.number().optional()
1011
- },
1012
- async ({ operation, nodeId, nodeIds, parentId, index, direction, booleanOperation, x, y }) => {
1013
- try {
1014
- const need = (value, what) => {
1015
- if (value === void 0) throw new Error(`"${operation}" requires ${what}`);
1016
- };
1017
- let result;
1018
- switch (operation) {
1019
- case "group":
1020
- need(nodeIds, "nodeIds");
1021
- result = await sendCommand("group_nodes", { nodeIds });
1022
- break;
1023
- case "ungroup":
1024
- need(nodeId, "nodeId");
1025
- result = await sendCommand("ungroup_nodes", { nodeId });
1026
- break;
1027
- case "reparent":
1028
- need(nodeId, "nodeId");
1029
- need(parentId, "parentId");
1030
- result = await sendCommand("reparent_node", { nodeId, parentId, index });
1031
- break;
1032
- case "reorder":
1033
- need(nodeId, "nodeId");
1034
- need(direction, "direction");
1035
- result = await sendCommand("reorder_node", { nodeId, direction });
1036
- break;
1037
- case "clone":
1038
- need(nodeId, "nodeId");
1039
- result = await sendCommand("clone_node", { nodeId, x, y });
1040
- break;
1041
- case "flatten":
1042
- need(nodeId, "nodeId");
1043
- result = await sendCommand("flatten_node", { nodeId });
1044
- break;
1045
- case "boolean":
1046
- need(nodeIds, "nodeIds");
1047
- need(booleanOperation, "booleanOperation");
1048
- result = await sendCommand("boolean_operation", {
1049
- nodeIds,
1050
- operation: booleanOperation
1051
- });
1052
- break;
1053
- case "delete":
1054
- if (nodeIds) result = await sendCommand("delete_multiple_nodes", { nodeIds });
1055
- else {
1056
- need(nodeId, "nodeId or nodeIds");
1057
- result = await sendCommand("delete_node", { nodeId });
1058
- }
1059
- break;
1060
- }
1061
- return jsonResult(result);
1062
- } catch (error) {
1063
- return errorResult(error);
1064
- }
1065
- }
1066
- );
1067
- }
1068
-
1069
- // src/tools/core/text.ts
1070
- var text_exports = {};
1071
- __export(text_exports, {
1072
- register: () => register4
1073
- });
1074
- import { z as z5 } from "zod";
1075
- function register4(server, sendCommand) {
1076
- server.tool(
1077
- "set_text",
1078
- "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.",
1079
- {
1080
- nodeId: z5.string().optional().describe("Target text node (single / range mode)"),
1081
- text: z5.string().optional().describe("New content (single mode)"),
1082
- items: z5.array(z5.object({ nodeId: z5.string(), text: z5.string() })).optional().describe("Bulk replacements"),
1083
- range: z5.object({
1084
- start: z5.number().int().min(0),
1085
- end: z5.number().int().positive(),
1086
- fontSize: z5.number().positive().optional(),
1087
- fontWeight: z5.number().optional(),
1088
- letterSpacing: z5.number().optional(),
1089
- lineHeight: z5.number().optional()
1090
- }).refine((r) => r.end > r.start, { message: "end must be greater than start" }).optional().describe("Style a character range [start, end)")
1091
- },
1092
- async ({ nodeId, text, items, range }) => {
1093
- try {
1094
- if (items?.length) {
1095
- const result = await sendCommand(
1096
- "set_multiple_text_contents",
1097
- { text: items },
1098
- Math.max(3e4, items.length * 2e3)
1099
- );
1100
- return jsonResult(result);
1101
- }
1102
- if (nodeId && range) {
1103
- const result = await sendCommand("set_text_style_range", { nodeId, ...range });
1104
- return jsonResult(result);
1105
- }
1106
- if (nodeId && text !== void 0) {
1107
- const result = await sendCommand("set_text_content", { nodeId, text });
1108
- return jsonResult(result);
1109
- }
1110
- return textResult(
1111
- "Provide either items (bulk), nodeId + text (single), or nodeId + range (styling)."
1112
- );
1113
- } catch (error) {
1114
- return errorResult(error);
1115
- }
1116
- }
1117
- );
1118
- }
1119
-
1120
- // src/tools/core/components.ts
1121
- var components_exports = {};
1122
- __export(components_exports, {
1123
- register: () => register5
1124
- });
1125
- import { z as z6 } from "zod";
1126
- function register5(server, sendCommand) {
1127
- server.tool(
1128
- "manage_components",
1129
- "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), detach.",
1130
- {
1131
- action: z6.enum([
1132
- "list",
1133
- "create",
1134
- "create_set",
1135
- "instantiate",
1136
- "get_props",
1137
- "set_props",
1138
- "get_overrides",
1139
- "set_overrides",
1140
- "detach"
1141
- ]),
1142
- nodeId: z6.string().optional().describe("Target node (create/get_props/set_props/detach)"),
1143
- componentIds: z6.array(z6.string()).optional().describe("create_set: components to combine"),
1144
- componentKey: z6.string().optional().describe("instantiate: component key (or node id)"),
1145
- x: z6.number().optional().describe("instantiate: position"),
1146
- y: z6.number().optional(),
1147
- properties: z6.record(z6.union([z6.string(), z6.boolean()])).optional().describe("set_props: property name \u2192 value (variant/text/boolean/swap)"),
1148
- sourceInstanceId: z6.string().optional().describe("set_overrides: copy from this instance"),
1149
- targetNodeIds: z6.array(z6.string()).optional().describe("set_overrides: apply to these"),
1150
- instanceNodeId: z6.string().optional().describe("get_overrides: instance to inspect")
1151
- },
1152
- async (args2) => {
1153
- try {
1154
- let result;
1155
- switch (args2.action) {
1156
- case "list":
1157
- result = await sendCommand("get_local_components", {}, 6e4);
1158
- break;
1159
- case "create":
1160
- result = await sendCommand("create_component_from_node", { nodeId: args2.nodeId });
1161
- break;
1162
- case "create_set":
1163
- result = await sendCommand("create_component_set", { componentIds: args2.componentIds });
1164
- break;
1165
- case "instantiate":
1166
- result = await sendCommand("create_component_instance", {
1167
- componentKey: args2.componentKey,
1168
- x: args2.x,
1169
- y: args2.y
1170
- });
1171
- break;
1172
- case "get_props":
1173
- result = await sendCommand("get_component_properties", { nodeId: args2.nodeId });
1174
- break;
1175
- case "set_props":
1176
- result = await sendCommand("set_component_properties", {
1177
- nodeId: args2.nodeId,
1178
- properties: args2.properties
1179
- });
1180
- break;
1181
- case "get_overrides":
1182
- result = await sendCommand("get_instance_overrides", {
1183
- instanceNodeId: args2.instanceNodeId ?? args2.nodeId
1184
- });
1185
- break;
1186
- case "set_overrides":
1187
- result = await sendCommand("set_instance_overrides", {
1188
- sourceInstanceId: args2.sourceInstanceId,
1189
- targetNodeIds: args2.targetNodeIds
1190
- });
1191
- break;
1192
- case "detach":
1193
- result = await sendCommand("detach_instance", { nodeId: args2.nodeId });
1194
- break;
1195
- }
1196
- return jsonResult(result);
1197
- } catch (error) {
1198
- return errorResult(error);
1199
- }
1200
- }
1201
- );
1202
- }
1203
-
1204
- // src/tools/core/variables.ts
1205
- var variables_exports = {};
1206
- __export(variables_exports, {
1207
- register: () => register6
1208
- });
1209
- import { z as z7 } from "zod";
1210
- var ACTIONS = {
1211
- list_collections: ["get_variable_collections", []],
1212
- list: ["get_variables", ["collectionId"]],
1213
- create_collection: ["create_variable_collection", ["name", "modes"]],
1214
- update_collection: ["update_variable_collection", ["collectionId", "name", "hiddenFromPublishing"]],
1215
- delete_collection: ["delete_variable_collection", ["collectionId"]],
1216
- create: ["create_variable", ["collectionId", "name", "resolvedType", "value"]],
1217
- update: ["update_variable", ["variableId", "modeId", "value", "name", "description", "hiddenFromPublishing"]],
1218
- delete: ["delete_variable", ["variableId"]],
1219
- add_mode: ["add_mode", ["collectionId", "name"]],
1220
- remove_mode: ["remove_mode", ["collectionId", "modeId"]],
1221
- rename_mode: ["rename_mode", ["collectionId", "modeId", "name"]],
1222
- set_scopes: ["set_variable_scopes", ["variableId", "scopes"]],
1223
- set_code_syntax: ["set_variable_code_syntax", ["variableId", "platform", "value"]],
1224
- remove_code_syntax: ["remove_variable_code_syntax", ["variableId", "platform"]],
1225
- create_alias: ["create_variable_alias", ["variableId", "targetVariableId", "modeId"]],
1226
- bind: ["bind_variable", ["nodeId", "variableId", "property"]],
1227
- unbind: ["unbind_variable", ["nodeId", "property"]],
1228
- set_node_mode: ["set_node_variable_mode", ["nodeId", "collectionId", "modeId"]],
1229
- get_node_modes: ["get_resolved_variable_modes", ["nodeId"]]
1230
- };
1231
- function register6(server, sendCommand) {
1232
- server.tool(
1233
- "manage_variables",
1234
- "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. Pass only the fields the action needs.",
1235
- {
1236
- action: z7.enum(Object.keys(ACTIONS)),
1237
- collectionId: z7.string().optional(),
1238
- variableId: z7.string().optional(),
1239
- targetVariableId: z7.string().optional().describe("create_alias: variable to alias to"),
1240
- nodeId: z7.string().optional().describe("bind/unbind/set_node_mode/get_node_modes"),
1241
- name: z7.string().optional(),
1242
- description: z7.string().optional(),
1243
- modes: z7.array(z7.string()).optional().describe("create_collection: mode names"),
1244
- modeId: z7.string().optional(),
1245
- resolvedType: z7.enum(["COLOR", "FLOAT", "STRING", "BOOLEAN"]).optional(),
1246
- value: z7.unknown().optional().describe("Variable value (color object, number, string, bool)"),
1247
- property: z7.string().optional().describe('bind/unbind: node property (e.g. "fills", "width")'),
1248
- scopes: z7.array(z7.string()).optional(),
1249
- platform: z7.enum(["WEB", "ANDROID", "iOS"]).optional(),
1250
- hiddenFromPublishing: z7.boolean().optional()
1251
- },
1252
- async (args2) => {
1253
- try {
1254
- const [command, fields] = ACTIONS[args2.action];
1255
- const params = Object.fromEntries(
1256
- fields.map((f) => [f, args2[f]]).filter(([, v]) => v !== void 0)
1257
- );
1258
- const result = await sendCommand(command, params);
1259
- return jsonResult(result);
1260
- } catch (error) {
1261
- return errorResult(error);
1262
- }
1263
- }
1264
- );
1265
- }
1266
-
1267
- // src/tools/core/styles.ts
1268
- var styles_exports = {};
1269
- __export(styles_exports, {
1270
- register: () => register7
1271
- });
1272
- import { z as z8 } from "zod";
1273
- function register7(server, sendCommand) {
1274
- server.tool(
1275
- "manage_styles",
1276
- "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.",
1277
- {
1278
- action: z8.enum([
1279
- "list",
1280
- "create_paint",
1281
- "create_text",
1282
- "create_effect",
1283
- "create_grid",
1284
- "update",
1285
- "delete",
1286
- "apply",
1287
- "reorder"
1288
- ]),
1289
- name: z8.string().optional().describe("Style name (create_*/update)"),
1290
- styleId: z8.string().optional().describe("Target style (update/delete/apply/reorder)"),
1291
- styleType: z8.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional().describe("apply/reorder"),
1292
- nodeId: z8.string().optional().describe("apply: target node"),
1293
- color: colorSchema.optional().describe("create_paint/create_effect"),
1294
- gradientType: gradientTypeSchema.optional(),
1295
- gradientStops: z8.array(gradientStopSchema).optional(),
1296
- fontFamily: z8.string().optional().describe("create_text"),
1297
- fontSize: z8.number().positive().optional(),
1298
- fontWeight: z8.number().optional(),
1299
- lineHeight: z8.number().optional(),
1300
- letterSpacing: z8.number().optional(),
1301
- effectType: z8.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]).optional().describe("create_effect"),
1302
- offsetX: z8.number().optional(),
1303
- offsetY: z8.number().optional(),
1304
- radius: z8.number().min(0).optional(),
1305
- spread: z8.number().optional(),
1306
- grids: z8.array(z8.record(z8.unknown())).optional().describe("create_grid: layout grid objects"),
1307
- properties: z8.record(z8.unknown()).optional().describe("update: properties to change"),
1308
- afterStyleId: z8.string().optional().describe("reorder: place after this style")
1309
- },
1310
- async (args2) => {
1311
- try {
1312
- let result;
1313
- switch (args2.action) {
1314
- case "list":
1315
- result = await sendCommand("get_styles", {});
1316
- break;
1317
- case "create_paint":
1318
- result = await sendCommand("create_paint_style", {
1319
- name: args2.name,
1320
- color: args2.color,
1321
- gradientType: args2.gradientType,
1322
- gradientStops: args2.gradientStops
1323
- });
1324
- break;
1325
- case "create_text":
1326
- result = await sendCommand("create_text_style", {
1327
- name: args2.name,
1328
- fontFamily: args2.fontFamily,
1329
- fontSize: args2.fontSize,
1330
- fontWeight: args2.fontWeight,
1331
- lineHeight: args2.lineHeight,
1332
- letterSpacing: args2.letterSpacing
1333
- });
1334
- break;
1335
- case "create_effect":
1336
- result = await sendCommand("create_effect_style", {
1337
- name: args2.name,
1338
- effectType: args2.effectType,
1339
- color: args2.color,
1340
- offsetX: args2.offsetX,
1341
- offsetY: args2.offsetY,
1342
- radius: args2.radius,
1343
- spread: args2.spread
1344
- });
1345
- break;
1346
- case "create_grid":
1347
- result = await sendCommand("create_grid_style", { name: args2.name, grids: args2.grids });
1348
- break;
1349
- case "update":
1350
- result = await sendCommand("update_style", {
1351
- styleId: args2.styleId,
1352
- name: args2.name,
1353
- properties: args2.properties
1354
- });
1355
- break;
1356
- case "delete":
1357
- result = await sendCommand("delete_style", { styleId: args2.styleId });
1358
- break;
1359
- case "apply":
1360
- result = await sendCommand("apply_style", {
1361
- nodeId: args2.nodeId,
1362
- styleId: args2.styleId,
1363
- styleType: args2.styleType
1364
- });
1365
- break;
1366
- case "reorder":
1367
- result = await sendCommand("reorder_style", {
1368
- styleId: args2.styleId,
1369
- afterStyleId: args2.afterStyleId,
1370
- styleType: args2.styleType
1371
- });
1372
- break;
1373
- }
1374
- return jsonResult(result);
1375
- } catch (error) {
1376
- return errorResult(error);
1377
- }
1378
- }
1379
- );
1380
- }
1381
-
1382
- // src/tools/core/pages.ts
1383
- var pages_exports = {};
1384
- __export(pages_exports, {
1385
- register: () => register8
1386
- });
1387
- import { z as z9 } from "zod";
1388
- function register8(server, sendCommand) {
1389
- server.tool(
1390
- "manage_pages",
1391
- "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.",
1392
- {
1393
- action: z9.enum(["list", "create", "rename", "delete", "set_background"]),
1394
- pageId: z9.string().optional().describe("Target page (rename/delete/set_background)"),
1395
- name: z9.string().optional().describe("Page name (create/rename)"),
1396
- color: colorSchema.optional().describe("Background color (set_background)")
1397
- },
1398
- async ({ action, pageId, name, color }) => {
1399
- try {
1400
- let result;
1401
- switch (action) {
1402
- case "list":
1403
- result = await sendCommand("get_pages", {});
1404
- break;
1405
- case "create":
1406
- result = await sendCommand("create_page", { name });
1407
- break;
1408
- case "rename":
1409
- result = await sendCommand("rename_page", { pageId, name });
1410
- break;
1411
- case "delete":
1412
- result = await sendCommand("delete_page", { pageId });
1413
- break;
1414
- case "set_background":
1415
- result = await sendCommand("set_page_background", { pageId, color });
1416
- break;
1417
- }
1418
- return jsonResult(result);
1419
- } catch (error) {
1420
- return errorResult(error);
1421
- }
1422
- }
1423
- );
1424
- }
1425
-
1426
- // src/tools/core/navigate.ts
1427
- var navigate_exports = {};
1428
- __export(navigate_exports, {
1429
- register: () => register9
1430
- });
1431
- import { z as z10 } from "zod";
1432
- function register9(server, sendCommand) {
1433
- server.tool(
1434
- "navigate",
1435
- "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.",
1436
- {
1437
- action: z10.enum(["focus", "select", "get_viewport", "set_viewport", "switch_page"]),
1438
- nodeId: z10.string().optional().describe("focus target"),
1439
- nodeIds: z10.array(z10.string()).optional().describe("select targets"),
1440
- x: z10.number().optional().describe("set_viewport center x"),
1441
- y: z10.number().optional(),
1442
- zoom: z10.number().positive().optional(),
1443
- pageId: z10.string().optional().describe("switch_page target")
1444
- },
1445
- async ({ action, nodeId, nodeIds, x, y, zoom, pageId }) => {
1446
- try {
1447
- let result;
1448
- switch (action) {
1449
- case "focus":
1450
- result = await sendCommand("set_focus", { nodeId });
1451
- break;
1452
- case "select":
1453
- result = await sendCommand("set_selections", { nodeIds });
1454
- break;
1455
- case "get_viewport":
1456
- result = await sendCommand("get_viewport", {});
1457
- break;
1458
- case "set_viewport":
1459
- result = await sendCommand("set_viewport", { x, y, zoom });
1460
- break;
1461
- case "switch_page":
1462
- result = await sendCommand("switch_page", { pageId });
1463
- break;
1464
- }
1465
- return jsonResult(result);
1466
- } catch (error) {
1467
- return errorResult(error);
1468
- }
1469
- }
1470
- );
415
+ });
1471
416
  }
1472
417
 
1473
- // src/tools/core/assets.ts
1474
- var assets_exports = {};
1475
- __export(assets_exports, {
1476
- register: () => register10
1477
- });
1478
- import { z as z11 } from "zod";
1479
- function register10(server, sendCommand) {
1480
- server.tool(
1481
- "export_asset",
1482
- "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).",
1483
- {
1484
- nodeId: z11.string().describe("Node to export"),
1485
- format: exportFormatSchema.optional().describe("Default PNG"),
1486
- scale: z11.number().positive().optional().describe("Export scale (default 1)")
1487
- },
1488
- { readOnlyHint: true },
1489
- async ({ nodeId, format, scale }) => {
1490
- try {
1491
- const result = await sendCommand("export_node_as_image", { nodeId, format, scale }, 6e4);
1492
- return jsonResult(result);
1493
- } catch (error) {
1494
- return errorResult(error);
1495
- }
1496
- }
1497
- );
1498
- server.tool(
1499
- "add_image",
1500
- "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.",
1501
- {
1502
- url: z11.string().optional().describe("Image URL \u2014 creates a new node"),
1503
- nodeId: z11.string().optional().describe("Existing node \u2014 sets its fill from imageData"),
1504
- imageData: z11.string().optional().describe("Base64 image data (with nodeId)"),
1505
- x: z11.number().optional(),
1506
- y: z11.number().optional(),
1507
- width: z11.number().positive().optional(),
1508
- height: z11.number().positive().optional(),
1509
- name: z11.string().optional(),
1510
- scaleMode: z11.enum(["FILL", "FIT", "CROP", "TILE"]).optional()
1511
- },
1512
- async ({ url, nodeId, imageData, x, y, width, height, name, scaleMode }) => {
1513
- try {
1514
- if (url) {
1515
- const result = await sendCommand(
1516
- "create_image_from_url",
1517
- { url, x, y, width, height, name, scaleMode },
1518
- 6e4
1519
- );
1520
- return jsonResult(result);
1521
- }
1522
- if (nodeId && imageData) {
1523
- const result = await sendCommand("set_image_fill", { nodeId, imageData, scaleMode }, 6e4);
1524
- return jsonResult(result);
1525
- }
1526
- return textResult("Provide either url (new image node) or nodeId + imageData (image fill).");
1527
- } catch (error) {
1528
- return errorResult(error);
1529
- }
1530
- }
1531
- );
418
+ // src/session-log.ts
419
+ var MAX_ENTRIES = 200;
420
+ var entries = [];
421
+ function recordCommand(entry) {
422
+ entries.push(entry);
423
+ if (entries.length > MAX_ENTRIES) entries = entries.slice(-MAX_ENTRIES);
1532
424
  }
1533
-
1534
- // src/tools/core/library.ts
1535
- var library_exports = {};
1536
- __export(library_exports, {
1537
- register: () => register11
1538
- });
1539
- import { z as z12 } from "zod";
1540
- function register11(server, sendCommand) {
1541
- server.tool(
1542
- "import_from_library",
1543
- "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.",
1544
- {
1545
- kind: z12.enum(["component", "style", "variable"]),
1546
- key: z12.string().describe("Library key")
1547
- },
1548
- async ({ kind, key }) => {
1549
- try {
1550
- const command = kind === "component" ? "import_component_by_key" : kind === "style" ? "import_style_by_key" : "import_variable_by_key";
1551
- const result = await sendCommand(command, { key }, 6e4);
1552
- return jsonResult(result);
1553
- } catch (error) {
1554
- return errorResult(error);
1555
- }
1556
- }
1557
- );
425
+ function getSessionLog() {
426
+ return [...entries];
1558
427
  }
1559
428
 
1560
- // src/tools/core/annotate.ts
1561
- var annotate_exports = {};
1562
- __export(annotate_exports, {
1563
- register: () => register12
1564
- });
1565
- import { z as z13 } from "zod";
1566
- function register12(server, sendCommand) {
1567
- server.tool(
1568
- "annotate",
1569
- "Read or write Dev Mode annotations. get: read annotations on a node. set: add/update one annotation (labelMarkdown). set_multiple: batch annotations.",
1570
- {
1571
- action: z13.enum(["get", "set", "set_multiple"]),
1572
- nodeId: z13.string().optional().describe("Target node (get/set)"),
1573
- labelMarkdown: z13.string().optional().describe("Annotation text (set)"),
1574
- categoryId: z13.string().optional(),
1575
- annotations: z13.array(
1576
- z13.object({
1577
- nodeId: z13.string(),
1578
- labelMarkdown: z13.string(),
1579
- categoryId: z13.string().optional()
1580
- })
1581
- ).optional().describe("Batch annotations (set_multiple)")
1582
- },
1583
- async ({ action, nodeId, labelMarkdown, categoryId, annotations }) => {
1584
- try {
1585
- if (action === "get") {
1586
- const result2 = await sendCommand("get_annotations", { nodeId });
1587
- return jsonResult(result2);
1588
- }
1589
- if (action === "set") {
1590
- if (!nodeId || labelMarkdown === void 0)
1591
- return textResult("set requires nodeId and labelMarkdown.");
1592
- const result2 = await sendCommand("set_annotation", { nodeId, labelMarkdown, categoryId });
1593
- return jsonResult(result2);
1594
- }
1595
- if (!annotations?.length) return textResult("set_multiple requires annotations.");
1596
- const result = await sendCommand("set_multiple_annotations", { annotations });
1597
- return jsonResult(result);
1598
- } catch (error) {
1599
- return errorResult(error);
1600
- }
1601
- }
1602
- );
429
+ // src/index.ts
430
+ var VERSION = "0.2.1";
431
+ var args = process.argv.slice(2);
432
+ var serverArg = args.find((arg) => arg.startsWith("--server="));
433
+ var serverUrl = serverArg ? serverArg.split("=")[1] : "localhost";
434
+ var portArg = args.find((arg) => arg.startsWith("--port="));
435
+ var port = portArg ? parseInt(portArg.split("=")[1]) : 9055;
436
+ var roomArg = args.find((arg) => arg.startsWith("--room="));
437
+ if (args.includes("--list-tools")) {
438
+ console.log(JSON.stringify(listToolCatalog(), null, 2));
439
+ process.exit(0);
1603
440
  }
1604
-
1605
- // src/tools/core/read.ts
1606
- var read_exports = {};
1607
- __export(read_exports, {
1608
- register: () => register13
1609
- });
1610
- import { z as z14 } from "zod";
1611
- function register13(server, sendCommand) {
1612
- server.tool(
1613
- "get_node_data",
1614
- "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.",
1615
- {
1616
- nodeIds: z14.array(z14.string()).min(1).describe("One or more node ids"),
1617
- detail: z14.enum(["summary", "tree", "full", "css", "variables"]).optional().describe("Default summary"),
1618
- depth: z14.number().int().min(0).optional().describe("Override serialization depth"),
1619
- maxNodes: z14.number().int().positive().optional().describe("Override node budget")
1620
- },
1621
- { readOnlyHint: true },
1622
- async ({ nodeIds, detail = "summary", depth, maxNodes }) => {
1623
- try {
1624
- if (detail === "css" || detail === "variables") {
1625
- const command = detail === "css" ? "get_css" : "get_bound_variables";
1626
- const results = [];
1627
- for (const nodeId of nodeIds) {
1628
- results.push({ nodeId, data: await sendCommand(command, { nodeId }) });
1629
- }
1630
- return jsonResult(nodeIds.length === 1 ? results[0].data : results);
1631
- }
1632
- if (detail === "tree") {
1633
- const results = [];
1634
- for (const nodeId of nodeIds) {
1635
- results.push(await sendCommand("get_node_tree", { nodeId, depth, maxNodes }));
1636
- }
1637
- return jsonResult(nodeIds.length === 1 ? results[0] : results);
1638
- }
1639
- if (nodeIds.length > 1) {
1640
- const result2 = await sendCommand("get_nodes_info", { nodeIds });
1641
- return jsonResult(result2);
1642
- }
1643
- const result = await sendCommand("get_node_info", {
1644
- nodeId: nodeIds[0],
1645
- depth: depth ?? (detail === "full" ? 4 : 1),
1646
- maxNodes
1647
- });
1648
- return jsonResult(result);
1649
- } catch (error) {
1650
- return errorResult(error);
1651
- }
1652
- }
1653
- );
1654
- }
1655
-
1656
- // src/tools/core/execute.ts
1657
- var execute_exports = {};
1658
- __export(execute_exports, {
1659
- register: () => register14
1660
- });
1661
- import { z as z15 } from "zod";
1662
- function register14(server, sendCommand) {
1663
- server.tool(
1664
- "execute_figma",
1665
- "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. The designer can disable this tool via the plugin's 'Allow code execution' toggle.",
1666
- {
1667
- code: z15.string().describe("JavaScript source. May use await. Return a JSON-serializable value."),
1668
- description: z15.string().optional().describe("One line shown in the plugin's activity feed describing what this does"),
1669
- timeoutMs: z15.number().int().min(1e3).max(3e5).optional().describe("Execution timeout in ms (default 60000) \u2014 raise for scripts creating hundreds of nodes")
1670
- },
1671
- async ({ code, description, timeoutMs }) => {
1672
- try {
1673
- const result = await sendCommand("execute_code", { code, description }, timeoutMs ?? 6e4);
1674
- return jsonResult(result);
1675
- } catch (error) {
1676
- return errorResult(error);
1677
- }
1678
- }
1679
- );
1680
- }
1681
-
1682
- // src/tools/core/comments.ts
1683
- var comments_exports = {};
1684
- __export(comments_exports, {
1685
- parseFileKey: () => parseFileKey,
1686
- register: () => register15
1687
- });
1688
- import { z as z16 } from "zod";
1689
- var API = "https://api.figma.com/v1";
1690
- function parseFileKey(input) {
1691
- const url = input.match(/figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/);
1692
- if (url) return url[1];
1693
- if (/^[A-Za-z0-9]{15,}$/.test(input)) return input;
1694
- return null;
1695
- }
1696
- function compactComment(c) {
1697
- return {
1698
- id: c.id,
1699
- message: c.message,
1700
- author: c.user?.handle,
1701
- created_at: c.created_at,
1702
- ...c.resolved_at ? { resolved: true } : {},
1703
- ...c.parent_id ? { replyTo: c.parent_id } : {},
1704
- ...c.client_meta?.node_id ? { nodeId: c.client_meta.node_id } : {}
1705
- };
1706
- }
1707
- function register15(server, sendCommand) {
1708
- server.tool(
1709
- "manage_comments",
1710
- "Read and write comments on the Figma file: list (with node anchors \u2014 great for 'apply the feedback in the comments'), add (optionally pinned to a node), reply, delete. 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.",
1711
- {
1712
- action: z16.enum(["list", "add", "reply", "delete"]),
1713
- fileUrl: z16.string().optional().describe("Figma file URL or key (auto-detected when omitted)"),
1714
- message: z16.string().optional().describe("Comment text (add/reply)"),
1715
- commentId: z16.string().optional().describe("Target comment (reply/delete)"),
1716
- nodeId: z16.string().optional().describe("add: pin the comment to this node"),
1717
- x: z16.number().optional().describe("add: canvas position (with y, when not pinning to a node)"),
1718
- y: z16.number().optional()
1719
- },
1720
- async ({ action, fileUrl, message, commentId, nodeId, x, y }) => {
1721
- const token = process.env.FIGMA_TOKEN;
1722
- if (!token) {
1723
- return textResult(
1724
- '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.'
1725
- );
1726
- }
1727
- try {
1728
- let fileKey = fileUrl ? parseFileKey(fileUrl) : null;
1729
- if (fileUrl && !fileKey) {
1730
- return textResult(`Could not extract a file key from "${fileUrl}" \u2014 pass a figma.com/design/... URL.`);
1731
- }
1732
- if (!fileKey) {
1733
- const info = await sendCommand("get_file_info", {});
1734
- fileKey = info?.fileKey ?? null;
1735
- }
1736
- if (!fileKey) {
1737
- return textResult(
1738
- "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)."
1739
- );
1740
- }
1741
- const request = async (method, path, body) => {
1742
- const res = await fetch(`${API}${path}`, {
1743
- method,
1744
- headers: {
1745
- "X-Figma-Token": token,
1746
- ...body ? { "Content-Type": "application/json" } : {}
1747
- },
1748
- ...body ? { body: JSON.stringify(body) } : {}
1749
- });
1750
- const json = await res.json().catch(() => ({}));
1751
- if (!res.ok) {
1752
- throw new Error(
1753
- `Figma API ${res.status}: ${json.err ?? json.message ?? "request failed"}` + (res.status === 403 ? " \u2014 check the token's comment scopes and file access" : "")
1754
- );
1755
- }
1756
- return json;
1757
- };
1758
- switch (action) {
1759
- case "list": {
1760
- const data = await request("GET", `/files/${fileKey}/comments`);
1761
- const comments = (data.comments ?? []).slice(0, 100).map(compactComment);
1762
- return jsonResult({ count: comments.length, comments });
1763
- }
1764
- case "add": {
1765
- if (!message) return textResult("add requires message.");
1766
- 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;
1767
- const data = await request("POST", `/files/${fileKey}/comments`, {
1768
- message,
1769
- ...client_meta ? { client_meta } : {}
1770
- });
1771
- return jsonResult(compactComment(data));
1772
- }
1773
- case "reply": {
1774
- if (!message || !commentId) return textResult("reply requires commentId and message.");
1775
- const data = await request("POST", `/files/${fileKey}/comments`, {
1776
- message,
1777
- comment_id: commentId
1778
- });
1779
- return jsonResult(compactComment(data));
1780
- }
1781
- case "delete": {
1782
- if (!commentId) return textResult("delete requires commentId.");
1783
- await request("DELETE", `/files/${fileKey}/comments/${commentId}`);
1784
- return textResult(`Comment ${commentId} deleted.`);
1785
- }
1786
- }
1787
- } catch (error) {
1788
- return errorResult(error);
1789
- }
1790
- }
1791
- );
1792
- }
1793
-
1794
- // src/tools/batch.ts
1795
- var batch_exports = {};
1796
- __export(batch_exports, {
1797
- register: () => register16
1798
- });
1799
- import { z as z17 } from "zod";
1800
- function textResult2(text) {
1801
- return { content: [{ type: "text", text }] };
1802
- }
1803
- function register16(server, sendCommand) {
1804
- server.tool(
1805
- "batch_execute",
1806
- "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.",
1807
- {
1808
- commands: z17.array(
1809
- z17.object({
1810
- command: z17.string().describe("Command name"),
1811
- params: z17.record(z17.string(), z17.unknown()).optional().describe("Command parameters")
1812
- })
1813
- ).describe("Array of commands to execute sequentially")
1814
- },
1815
- async ({ commands }) => {
1816
- try {
1817
- const result = await sendCommand(
1818
- "batch_execute",
1819
- { commands },
1820
- commands.length * 3e4
1821
- // Scale timeout by command count
1822
- );
1823
- return textResult2(JSON.stringify(result));
1824
- } catch (error) {
1825
- return textResult2(`Error: ${error instanceof Error ? error.message : String(error)}`);
1826
- }
1827
- }
1828
- );
1829
- }
1830
-
1831
- // src/tools/v2/context.ts
1832
- var context_exports = {};
1833
- __export(context_exports, {
1834
- register: () => register17
1835
- });
1836
- import { z as z18 } from "zod";
1837
-
1838
- // src/semantic/response.ts
1839
- function standardResult(opts) {
1840
- const response = {
1841
- summary: opts.summary,
1842
- data: opts.data
1843
- };
1844
- if (opts.warnings?.length) response.warnings = opts.warnings;
1845
- if (opts.recommended_next?.length) response.recommended_next = opts.recommended_next;
1846
- if (opts.partial_failures?.length) response.partial_failures = opts.partial_failures;
1847
- return {
1848
- content: [{ type: "text", text: JSON.stringify(response) }]
1849
- };
1850
- }
1851
-
1852
- // src/semantic/errors.ts
1853
- function errorResult2(category, message, recovery, extra) {
1854
- const error = {
1855
- category,
1856
- message,
1857
- recovery
1858
- };
1859
- if (extra?.preconditions) error.preconditions = extra.preconditions;
1860
- if (extra?.partial_result !== void 0) error.partial_result = extra.partial_result;
1861
- return {
1862
- content: [{ type: "text", text: JSON.stringify({ error }, null, 2) }]
1863
- };
1864
- }
1865
- function noSelectionError() {
1866
- return errorResult2(
1867
- "precondition_failed",
1868
- "No nodes selected in Figma",
1869
- {
1870
- suggestion: "Select nodes in Figma, or use search_nodes to find nodes by name/type",
1871
- tool: "search_nodes"
1872
- },
1873
- { preconditions: { required: "At least 1 node selected", current_state: "Selection is empty" } }
1874
- );
1875
- }
1876
-
1877
- // src/semantic/normalize.ts
1878
- function normalizeNode(raw) {
1879
- if (!raw) return null;
1880
- const summary = {
1881
- id: raw.id,
1882
- name: raw.name,
1883
- type: raw.type,
1884
- size: {
1885
- width: raw.width ?? raw.absoluteBoundingBox?.width ?? 0,
1886
- height: raw.height ?? raw.absoluteBoundingBox?.height ?? 0
1887
- },
1888
- position: {
1889
- x: raw.x ?? raw.absoluteBoundingBox?.x ?? 0,
1890
- y: raw.y ?? raw.absoluteBoundingBox?.y ?? 0
1891
- }
1892
- };
1893
- const fill = extractFillColor(raw);
1894
- if (fill) summary.fill = fill;
1895
- const stroke = extractStrokeColor(raw);
1896
- if (stroke) summary.stroke = stroke;
1897
- if (raw.cornerRadius !== void 0) {
1898
- summary.cornerRadius = raw.cornerRadius === "mixed" ? "mixed" : raw.cornerRadius;
1899
- }
1900
- if (raw.opacity !== void 0 && raw.opacity < 1) {
1901
- summary.opacity = raw.opacity;
1902
- }
1903
- summary.layout = extractLayout(raw);
1904
- if (raw.children?.length > 0) {
1905
- summary.childSummary = summarizeChildren(raw.children);
1906
- }
1907
- summary.componentStatus = extractComponentStatus(raw);
1908
- return summary;
1909
- }
1910
- function normalizeNodes(rawNodes) {
1911
- return rawNodes.map(normalizeNode).filter((n) => n !== null);
1912
- }
1913
- function extractFillColor(node) {
1914
- if (!node.fills?.length) return void 0;
1915
- const solidFill = node.fills.find((f) => f.type === "SOLID" && f.visible !== false);
1916
- if (!solidFill?.color) return void 0;
1917
- if (typeof solidFill.color === "string" && solidFill.color.startsWith("#")) {
1918
- return solidFill.color;
1919
- }
1920
- return rgbaToHex({
1921
- r: solidFill.color.r,
1922
- g: solidFill.color.g,
1923
- b: solidFill.color.b,
1924
- a: solidFill.opacity ?? solidFill.color.a ?? 1
1925
- });
1926
- }
1927
- function extractStrokeColor(node) {
1928
- if (!node.strokes?.length) return void 0;
1929
- const stroke = node.strokes.find((s) => s.type === "SOLID" && s.visible !== false);
1930
- if (!stroke?.color) return void 0;
1931
- if (typeof stroke.color === "string" && stroke.color.startsWith("#")) {
1932
- return stroke.color;
1933
- }
1934
- return rgbaToHex({
1935
- r: stroke.color.r,
1936
- g: stroke.color.g,
1937
- b: stroke.color.b,
1938
- a: stroke.opacity ?? stroke.color.a ?? 1
1939
- });
1940
- }
1941
- function extractLayout(node) {
1942
- if (!node.layoutMode || node.layoutMode === "NONE") return null;
1943
- const pt = node.paddingTop ?? 0;
1944
- const pr = node.paddingRight ?? 0;
1945
- const pb = node.paddingBottom ?? 0;
1946
- const pl = node.paddingLeft ?? 0;
1947
- let padding;
1948
- if (pt === pr && pr === pb && pb === pl) {
1949
- padding = `${pt}px all`;
1950
- } else if (pt === pb && pl === pr) {
1951
- padding = `${pt}/${pl}`;
441
+ var subcommand = args.find((a) => !a.startsWith("--"));
442
+ if (subcommand === "manifest" || subcommand === "docs" || subcommand === "doctor") {
443
+ const { buildManifest } = await import("./manifest-CFCCUJEV.js");
444
+ if (subcommand === "doctor") {
445
+ const { runDoctor, renderDoctor } = await import("./doctor-ZN2PKAPH.js");
446
+ const results = await runDoctor();
447
+ console.log(args.includes("--json") ? JSON.stringify(results, null, 2) : renderDoctor(results));
448
+ process.exit(results.some((r) => r.status === "warn") ? 1 : 0);
449
+ }
450
+ const manifest = await buildManifest(VERSION);
451
+ if (subcommand === "manifest") {
452
+ console.log(JSON.stringify(manifest, null, 2));
1952
453
  } else {
1953
- padding = `${pt}/${pr}/${pb}/${pl}`;
1954
- }
1955
- return {
1956
- mode: node.layoutMode,
1957
- padding,
1958
- gap: node.itemSpacing ?? 0,
1959
- sizing: {
1960
- horizontal: node.primaryAxisSizingMode ?? node.layoutSizingHorizontal ?? "FIXED",
1961
- vertical: node.counterAxisSizingMode ?? node.layoutSizingVertical ?? "FIXED"
1962
- }
1963
- };
1964
- }
1965
- function summarizeChildren(children) {
1966
- const typeCounts = /* @__PURE__ */ new Map();
1967
- for (const child of children) {
1968
- const type = child.type || "UNKNOWN";
1969
- typeCounts.set(type, (typeCounts.get(type) || 0) + 1);
1970
- }
1971
- const parts = Array.from(typeCounts.entries()).map(([type, count]) => `${count} ${type}`).join(", ");
1972
- return `${children.length} children: ${parts}`;
1973
- }
1974
- function extractComponentStatus(node) {
1975
- if (node.type === "COMPONENT") return "component";
1976
- if (node.type === "COMPONENT_SET") return "component_set";
1977
- if (node.type === "INSTANCE") {
1978
- const mainName = node.mainComponent?.name || node.componentName;
1979
- return mainName ? `instance of '${mainName}'` : "instance";
1980
- }
1981
- return null;
1982
- }
1983
- function calculateTokenCoverage(node, boundVariables) {
1984
- let totalProps = 0;
1985
- let boundProps = 0;
1986
- if (node.fills?.length > 0) {
1987
- totalProps++;
1988
- if (boundVariables && hasBinding(boundVariables, "fills")) boundProps++;
1989
- }
1990
- if (node.strokes?.length > 0) {
1991
- totalProps++;
1992
- if (boundVariables && hasBinding(boundVariables, "strokes")) boundProps++;
1993
- }
1994
- for (const prop of ["itemSpacing", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]) {
1995
- if (node[prop] !== void 0 && node[prop] > 0) {
1996
- totalProps++;
1997
- if (boundVariables && hasBinding(boundVariables, prop)) boundProps++;
1998
- }
1999
- }
2000
- if (node.cornerRadius !== void 0 && node.cornerRadius > 0) {
2001
- totalProps++;
2002
- if (boundVariables && hasBinding(boundVariables, "cornerRadius")) boundProps++;
2003
- }
2004
- if (totalProps === 0) return 1;
2005
- return Math.round(boundProps / totalProps * 100) / 100;
2006
- }
2007
- function hasBinding(boundVars, prefix) {
2008
- return Object.keys(boundVars).some((key) => key.startsWith(prefix) || key === prefix);
2009
- }
2010
-
2011
- // src/tools/v2/context.ts
2012
- function register17(server, sendCommand) {
2013
- server.tool(
2014
- "get_document_overview",
2015
- "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.",
2016
- {},
2017
- { readOnlyHint: true },
2018
- async () => {
2019
- try {
2020
- const [docInfo, collections, styles] = await Promise.all([
2021
- sendCommand("get_document_info"),
2022
- sendCommand("get_variable_collections"),
2023
- sendCommand("get_styles")
2024
- ]);
2025
- const componentCount = docInfo.componentCount ?? 0;
2026
- const stylesByType = { paint: 0, text: 0, effect: 0, grid: 0 };
2027
- if (Array.isArray(styles)) {
2028
- for (const s of styles) {
2029
- const t = (s.type || "").toLowerCase();
2030
- if (t === "paint") stylesByType.paint++;
2031
- else if (t === "text") stylesByType.text++;
2032
- else if (t === "effect") stylesByType.effect++;
2033
- else if (t === "grid") stylesByType.grid++;
2034
- }
2035
- }
2036
- const data = {
2037
- name: docInfo.name,
2038
- currentPage: docInfo.currentPage,
2039
- pages: docInfo.pages || [],
2040
- counts: {
2041
- components: componentCount,
2042
- styles: Array.isArray(styles) ? styles.length : 0,
2043
- variableCollections: Array.isArray(collections) ? collections.length : 0
2044
- }
2045
- };
2046
- return standardResult({
2047
- summary: `"${data.name}" \u2014 ${data.pages.length} pages, ${data.counts.components} components, ${data.counts.styles} styles, ${data.counts.variableCollections} variable collections`,
2048
- data,
2049
- recommended_next: [
2050
- { tool: "get_selection_context", reason: "Inspect currently selected nodes" },
2051
- { tool: "search_nodes", reason: "Find specific nodes by name or type" },
2052
- { tool: "get_design_tokens", reason: "Explore the design token system" }
2053
- ]
2054
- });
2055
- } catch (error) {
2056
- return errorResult2(
2057
- "connection_error",
2058
- `Failed to get document info: ${error instanceof Error ? error.message : String(error)}`,
2059
- { suggestion: "Ensure Figma plugin is connected. Call join_room first.", tool: "join_room" }
2060
- );
2061
- }
2062
- }
2063
- );
2064
- server.tool(
2065
- "get_selection_context",
2066
- "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.",
2067
- {},
2068
- { readOnlyHint: true },
2069
- async () => {
2070
- try {
2071
- const selection = await sendCommand("get_selection");
2072
- if (!selection?.nodes?.length) {
2073
- return noSelectionError();
2074
- }
2075
- const nodeIds = selection.nodes.map((n) => n.id);
2076
- const [nodesInfo, ...bindings] = await Promise.all([
2077
- sendCommand("get_nodes_info", { nodeIds, depth: 2 }),
2078
- ...nodeIds.map(
2079
- (id) => sendCommand("get_bound_variables", { nodeId: id }).catch(() => null)
2080
- )
2081
- ]);
2082
- const nodes = (Array.isArray(nodesInfo) ? nodesInfo : []).map((raw, i) => {
2083
- const summary = normalizeNode(raw);
2084
- if (!summary) return null;
2085
- const boundVars = bindings[i]?.boundVariables || null;
2086
- summary.tokenCoverage = calculateTokenCoverage(raw, boundVars);
2087
- if (boundVars) {
2088
- summary.fillToken = extractTokenName(boundVars, "fills");
2089
- summary.strokeToken = extractTokenName(boundVars, "strokes");
2090
- }
2091
- return summary;
2092
- }).filter(Boolean);
2093
- const data = {
2094
- nodes,
2095
- pageInfo: selection.currentPage || { id: "", name: "" }
2096
- };
2097
- const avgCoverage = nodes.length > 0 ? Math.round(nodes.reduce((sum, n) => sum + (n.tokenCoverage || 0), 0) / nodes.length * 100) : 0;
2098
- const warnings = [];
2099
- if (avgCoverage < 50) {
2100
- warnings.push({
2101
- category: "tokens",
2102
- message: `Token coverage is ${avgCoverage}% \u2014 many properties are not bound to design variables`
2103
- });
2104
- }
2105
- return standardResult({
2106
- summary: `Selected ${nodes.length} node(s): ${nodes.map((n) => `'${n.name}' (${n.type})`).join(", ")}. Token coverage: ${avgCoverage}%`,
2107
- data,
2108
- warnings,
2109
- recommended_next: [
2110
- { tool: "analyze_color_usage", reason: "Audit color consistency and token coverage" },
2111
- { tool: "update_node", reason: "Modify selected node properties" },
2112
- { tool: "screenshot", reason: "Visually inspect the selection" }
2113
- ]
2114
- });
2115
- } catch (error) {
2116
- return errorResult2(
2117
- "connection_error",
2118
- `Failed to get selection: ${error instanceof Error ? error.message : String(error)}`,
2119
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2120
- );
2121
- }
2122
- }
2123
- );
2124
- server.tool(
2125
- "get_node_details",
2126
- "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.",
2127
- {
2128
- nodeId: z18.string().describe("The node ID to inspect")
2129
- },
2130
- { readOnlyHint: true },
2131
- async ({ nodeId }) => {
2132
- try {
2133
- const [nodeInfo, boundVars, css] = await Promise.all([
2134
- sendCommand("get_node_info", { nodeId, depth: 2 }),
2135
- sendCommand("get_bound_variables", { nodeId }).catch(() => null),
2136
- sendCommand("get_css", { nodeId }).catch(() => null)
2137
- ]);
2138
- if (!nodeInfo) {
2139
- return errorResult2(
2140
- "invalid_input",
2141
- `Node '${nodeId}' not found`,
2142
- { suggestion: "Use search_nodes to find the correct node ID", tool: "search_nodes" }
2143
- );
2144
- }
2145
- const node = normalizeNode(nodeInfo);
2146
- if (!node) {
2147
- return errorResult2(
2148
- "invalid_input",
2149
- `Could not process node '${nodeId}'`,
2150
- { suggestion: "Try get_node_info directly for raw data", tool: "get_node_info" }
2151
- );
2152
- }
2153
- const bv = boundVars?.boundVariables || null;
2154
- node.tokenCoverage = calculateTokenCoverage(nodeInfo, bv);
2155
- if (bv) {
2156
- node.fillToken = extractTokenName(bv, "fills");
2157
- node.strokeToken = extractTokenName(bv, "strokes");
2158
- }
2159
- const children = nodeInfo.children ? normalizeNodes(nodeInfo.children) : void 0;
2160
- const data = {
2161
- node,
2162
- css: css?.css,
2163
- boundVariables: bv || void 0,
2164
- children
2165
- };
2166
- return standardResult({
2167
- summary: `'${node.name}' (${node.type}, ${node.size.width}\xD7${node.size.height}). Token coverage: ${Math.round((node.tokenCoverage || 0) * 100)}%`,
2168
- data,
2169
- recommended_next: [
2170
- { tool: "update_node", reason: "Modify this node's properties", args: { nodeId } },
2171
- { tool: "analyze_color_usage", reason: "Check color token usage", args: { nodeId } }
2172
- ]
2173
- });
2174
- } catch (error) {
2175
- return errorResult2(
2176
- "connection_error",
2177
- `Failed to get node details: ${error instanceof Error ? error.message : String(error)}`,
2178
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2179
- );
2180
- }
2181
- }
2182
- );
2183
- server.tool(
2184
- "search_nodes",
2185
- "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.",
2186
- {
2187
- query: z18.string().optional().describe("Name substring to search for"),
2188
- types: z18.array(z18.string()).optional().describe("Node types to filter: FRAME, TEXT, COMPONENT, INSTANCE, RECTANGLE, etc."),
2189
- parentId: z18.string().optional().describe("Scope search to children of this node (default: current page)")
2190
- },
2191
- { readOnlyHint: true },
2192
- async ({ query, types, parentId }) => {
2193
- try {
2194
- const result = await sendCommand("find_nodes", {
2195
- name: query,
2196
- types,
2197
- parentId
2198
- });
2199
- const matches = (Array.isArray(result) ? result : []).map((n) => ({
2200
- id: n.id,
2201
- name: n.name,
2202
- type: n.type,
2203
- parentName: n.parent?.name
2204
- }));
2205
- const data = {
2206
- matches,
2207
- total: matches.length
2208
- };
2209
- return standardResult({
2210
- summary: `Found ${matches.length} node(s)${query ? ` matching '${query}'` : ""}${types?.length ? ` of type ${types.join("/")}` : ""}`,
2211
- data,
2212
- recommended_next: matches.length > 0 ? [
2213
- { tool: "get_node_details", reason: "Inspect a specific node", args: { nodeId: matches[0].id } },
2214
- { tool: "navigate_to", reason: "Focus on a found node", args: { nodeId: matches[0].id } }
2215
- ] : [
2216
- { tool: "get_document_overview", reason: "Check document structure" }
2217
- ]
2218
- });
2219
- } catch (error) {
2220
- return errorResult2(
2221
- "connection_error",
2222
- `Search failed: ${error instanceof Error ? error.message : String(error)}`,
2223
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2224
- );
2225
- }
2226
- }
2227
- );
2228
- server.tool(
2229
- "get_design_tokens",
2230
- "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.",
2231
- {},
2232
- { readOnlyHint: true },
2233
- async () => {
2234
- try {
2235
- const [collections, styles] = await Promise.all([
2236
- sendCommand("get_variable_collections"),
2237
- sendCommand("get_styles")
2238
- ]);
2239
- const collectionSummaries = (Array.isArray(collections) ? collections : []).map((c) => ({
2240
- id: c.id,
2241
- name: c.name,
2242
- modes: Array.isArray(c.modes) ? c.modes : [],
2243
- variableCount: Array.isArray(c.variableIds) ? c.variableIds.length : 0
2244
- }));
2245
- const stylesByType = { paint: 0, text: 0, effect: 0, grid: 0 };
2246
- if (Array.isArray(styles)) {
2247
- for (const s of styles) {
2248
- const t = (s.type || "").toLowerCase();
2249
- if (t in stylesByType) stylesByType[t]++;
2250
- }
2251
- }
2252
- const totalVars = collectionSummaries.reduce((sum, c) => sum + c.variableCount, 0);
2253
- const data = {
2254
- collections: collectionSummaries,
2255
- styles: stylesByType
2256
- };
2257
- return standardResult({
2258
- summary: `${collectionSummaries.length} collection(s) with ${totalVars} variables. Styles: ${stylesByType.paint} paint, ${stylesByType.text} text, ${stylesByType.effect} effect, ${stylesByType.grid} grid`,
2259
- data,
2260
- recommended_next: [
2261
- { tool: "manage_variables", reason: "Create or update design tokens" },
2262
- { tool: "analyze_color_usage", reason: "Check token coverage on selected nodes" }
2263
- ]
2264
- });
2265
- } catch (error) {
2266
- return errorResult2(
2267
- "connection_error",
2268
- `Failed to get design tokens: ${error instanceof Error ? error.message : String(error)}`,
2269
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2270
- );
2271
- }
2272
- }
2273
- );
2274
- server.tool(
2275
- "screenshot",
2276
- "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.",
2277
- {
2278
- nodeId: z18.string().optional().describe("Node ID to screenshot (default: current view)")
2279
- },
2280
- { readOnlyHint: true },
2281
- async ({ nodeId }) => {
2282
- try {
2283
- const result = await sendCommand("get_screenshot", nodeId ? { nodeId } : {});
2284
- if (result?.imageData) {
2285
- return {
2286
- content: [
2287
- {
2288
- type: "image",
2289
- data: result.imageData,
2290
- mimeType: "image/png"
2291
- },
2292
- {
2293
- type: "text",
2294
- text: JSON.stringify({
2295
- summary: `Screenshot taken${nodeId ? ` of node ${nodeId}` : " of current view"}`,
2296
- recommended_next: [
2297
- { tool: "get_selection_context", reason: "Get structured data about the selection" },
2298
- { tool: "update_node", reason: "Make changes based on what you see" }
2299
- ]
2300
- })
2301
- }
2302
- ]
2303
- };
2304
- }
2305
- return {
2306
- content: [{ type: "text", text: JSON.stringify(result) }]
2307
- };
2308
- } catch (error) {
2309
- return errorResult2(
2310
- "connection_error",
2311
- `Screenshot failed: ${error instanceof Error ? error.message : String(error)}`,
2312
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2313
- );
2314
- }
2315
- }
2316
- );
2317
- }
2318
- function extractTokenName(boundVars, prefix) {
2319
- for (const [key, value] of Object.entries(boundVars)) {
2320
- if (key.startsWith(prefix) || key === prefix) {
2321
- if (value && typeof value === "object" && "name" in value) {
2322
- return value.name;
2323
- }
2324
- if (typeof value === "string") return value;
2325
- }
2326
- }
2327
- return null;
2328
- }
2329
-
2330
- // src/tools/v2/analysis.ts
2331
- var analysis_exports = {};
2332
- __export(analysis_exports, {
2333
- collectColorIssues: () => collectColorIssues,
2334
- register: () => register18
2335
- });
2336
- import { z as z19 } from "zod";
2337
-
2338
- // src/semantic/health.ts
2339
- var WEIGHTS = { color: 0.3, layout: 0.25, components: 0.2, accessibility: 0.25 };
2340
- var clamp = (n) => Math.max(0, Math.min(100, Math.round(n)));
2341
- function computeHealthScore(inputs) {
2342
- const categories = [];
2343
- if (inputs.color) {
2344
- categories.push({
2345
- category: "color",
2346
- score: clamp(inputs.color.tokenCoverage * 100),
2347
- weight: WEIGHTS.color,
2348
- note: `${Math.round(inputs.color.tokenCoverage * 100)}% of color properties bound to tokens (${inputs.color.unboundCount} unbound)`
2349
- });
2350
- }
2351
- if (inputs.layout) {
2352
- categories.push({
2353
- category: "layout",
2354
- score: clamp(inputs.layout.autoLayoutCoverage * 100 - inputs.layout.issueCount * 3),
2355
- weight: WEIGHTS.layout,
2356
- note: `${Math.round(inputs.layout.autoLayoutCoverage * 100)}% auto-layout coverage, ${inputs.layout.issueCount} issue(s)`
2357
- });
2358
- }
2359
- if (inputs.components) {
2360
- const { totalInstances, detachedCount } = inputs.components;
2361
- categories.push({
2362
- category: "components",
2363
- score: totalInstances > 0 ? clamp((1 - detachedCount / totalInstances) * 100) : 100,
2364
- weight: WEIGHTS.components,
2365
- note: totalInstances > 0 ? `${detachedCount}/${totalInstances} instances look detached` : "no instances in scope"
2366
- });
2367
- }
2368
- if (inputs.accessibility) {
2369
- categories.push({
2370
- category: "accessibility",
2371
- score: clamp(100 - inputs.accessibility.issueCount * 8),
2372
- weight: WEIGHTS.accessibility,
2373
- note: `${inputs.accessibility.issueCount} issue(s) found`
2374
- });
2375
- }
2376
- const totalWeight = categories.reduce((s, c) => s + c.weight, 0);
2377
- const score = totalWeight > 0 ? clamp(categories.reduce((s, c) => s + c.score * c.weight, 0) / totalWeight) : 0;
2378
- const grade = score >= 90 ? "A" : score >= 75 ? "B" : score >= 55 ? "C" : "D";
2379
- return { score, grade, categories };
2380
- }
2381
-
2382
- // src/tools/v2/analysis.ts
2383
- var ASPECT_TOOL = {
2384
- color: "analyze_color_usage",
2385
- layout: "analyze_layout_quality",
2386
- components: "analyze_component_health",
2387
- accessibility: "analyze_accessibility"
2388
- };
2389
- function register18(server, sendCommand) {
2390
- const aspectHandlers = /* @__PURE__ */ new Map();
2391
- const interceptor = new Proxy(server, {
2392
- get(target, prop, receiver) {
2393
- if (prop !== "tool") return Reflect.get(target, prop, receiver);
2394
- return (name, ...rest) => {
2395
- if (name.startsWith("analyze_")) {
2396
- aspectHandlers.set(name, rest[rest.length - 1]);
2397
- return;
2398
- }
2399
- return target.tool.call(target, name, ...rest);
2400
- };
2401
- }
2402
- });
2403
- registerAnalysisTools(interceptor, sendCommand);
2404
- server.tool(
2405
- "analyze_design",
2406
- "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), or overall (runs all four and returns a weighted 0-100 health score with per-category breakdown \u2014 good for audits and reports). Defaults to the current selection.",
2407
- {
2408
- aspect: z19.enum(["color", "layout", "components", "accessibility", "overall"]),
2409
- nodeId: z19.string().optional().describe("Root node to analyze (default: current selection)")
2410
- },
2411
- { readOnlyHint: true },
2412
- async ({ aspect, nodeId }) => {
2413
- if (aspect === "overall") {
2414
- return runOverallAudit(aspectHandlers, nodeId);
2415
- }
2416
- const handler = aspectHandlers.get(ASPECT_TOOL[aspect]);
2417
- if (!handler) throw new Error(`Unknown aspect: ${aspect}`);
2418
- return handler({ nodeId });
2419
- }
2420
- );
2421
- }
2422
- async function runOverallAudit(aspectHandlers, nodeId) {
2423
- const parse = (result) => {
2424
- try {
2425
- const text = result.content?.[0]?.text ?? "";
2426
- const parsed = JSON.parse(text);
2427
- return parsed?.data ?? null;
2428
- } catch {
2429
- return null;
2430
- }
2431
- };
2432
- const run = async (tool) => {
2433
- const handler = aspectHandlers.get(tool);
2434
- if (!handler) return null;
2435
- try {
2436
- return parse(await handler({ nodeId }));
2437
- } catch {
2438
- return null;
2439
- }
2440
- };
2441
- const [color, layout, components, accessibility] = await Promise.all([
2442
- run("analyze_color_usage"),
2443
- run("analyze_layout_quality"),
2444
- run("analyze_component_health"),
2445
- run("analyze_accessibility")
2446
- ]);
2447
- const inputs = {
2448
- color: color ? { tokenCoverage: color.tokenCoverage ?? 0, unboundCount: color.unboundCount ?? 0 } : null,
2449
- layout: layout ? {
2450
- autoLayoutCoverage: layout.autoLayoutCoverage ?? 0,
2451
- issueCount: layout.issues?.length ?? 0
2452
- } : null,
2453
- components: components ? {
2454
- totalInstances: components.totalInstances ?? 0,
2455
- detachedCount: components.detachedCount ?? 0
2456
- } : null,
2457
- accessibility: accessibility ? { issueCount: accessibility.issueCount ?? 0 } : null
2458
- };
2459
- const health = computeHealthScore(inputs);
2460
- const excluded = Object.keys(inputs).filter((k) => !inputs[k]);
2461
- const worst = [...health.categories].sort((a, b) => a.score - b.score)[0];
2462
- return standardResult({
2463
- summary: `Design health: ${health.score}/100 (grade ${health.grade}). Weakest area: ${worst ? `${worst.category} at ${worst.score}` : "n/a"}.`,
2464
- data: {
2465
- ...health,
2466
- ...excluded.length > 0 ? { excluded } : {},
2467
- details: { color, layout, components, accessibility }
2468
- },
2469
- warnings: health.grade === "A" ? [] : health.categories.filter((c) => c.score < 75).map((c) => ({
2470
- category: "general",
2471
- message: `${c.category}: ${c.score}/100 \u2014 ${c.note}`
2472
- })),
2473
- recommended_next: worst ? [
2474
- {
2475
- tool: "analyze_design",
2476
- reason: `Drill into the weakest area with aspect: "${worst.category}"`
2477
- }
2478
- ] : []
2479
- });
2480
- }
2481
- function registerAnalysisTools(server, sendCommand) {
2482
- server.tool(
2483
- "analyze_color_usage",
2484
- "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.",
2485
- {
2486
- nodeId: z19.string().optional().describe("Root node to analyze (default: current selection)")
2487
- },
2488
- { readOnlyHint: true },
2489
- async ({ nodeId }) => {
2490
- try {
2491
- let targetIds;
2492
- if (nodeId) {
2493
- targetIds = [nodeId];
2494
- } else {
2495
- const selection = await sendCommand("get_selection");
2496
- if (!selection?.nodes?.length) return noSelectionError();
2497
- targetIds = selection.nodes.map((n) => n.id);
2498
- }
2499
- const unboundColors = [];
2500
- const counters = { totalProps: 0, boundCount: 0 };
2501
- for (const id of targetIds) {
2502
- const [nodeInfo, boundVars] = await Promise.all([
2503
- sendCommand("get_node_info", { nodeId: id, depth: 2, maxNodes: 200 }),
2504
- sendCommand("get_bound_variables", { nodeId: id }).catch(() => null)
2505
- ]);
2506
- if (!nodeInfo) continue;
2507
- const bv = boundVars?.boundVariables || {};
2508
- collectColorIssues(nodeInfo, bv, unboundColors, counters);
2509
- if (nodeInfo.children) {
2510
- for (const child of nodeInfo.children) {
2511
- const childBv = await sendCommand("get_bound_variables", { nodeId: child.id }).catch(() => null);
2512
- const cbv = childBv?.boundVariables || {};
2513
- collectColorIssues(child, cbv, unboundColors, counters);
2514
- }
2515
- }
2516
- }
2517
- const tokenCoverage = counters.totalProps > 0 ? Math.round(counters.boundCount / counters.totalProps * 100) / 100 : 1;
2518
- const data = {
2519
- totalProperties: counters.totalProps,
2520
- boundCount: counters.boundCount,
2521
- unboundCount: unboundColors.length,
2522
- tokenCoverage,
2523
- unboundColors
2524
- };
2525
- const warnings = unboundColors.length > 0 ? [{ category: "tokens", message: `${unboundColors.length} color(s) not bound to design tokens` }] : [];
2526
- return standardResult({
2527
- summary: `Color audit: ${unboundColors.length} unbound color(s) found. Token coverage: ${Math.round(data.tokenCoverage * 100)}%`,
2528
- data,
2529
- warnings,
2530
- recommended_next: unboundColors.length > 0 ? [
2531
- { tool: "bind_tokens", reason: "Bind unbound colors to design tokens" },
2532
- { tool: "get_design_tokens", reason: "See available tokens for binding" }
2533
- ] : [
2534
- { tool: "analyze_layout_quality", reason: "Check layout quality next" }
2535
- ]
2536
- });
2537
- } catch (error) {
2538
- return errorResult2(
2539
- "connection_error",
2540
- `Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
2541
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2542
- );
2543
- }
2544
- }
2545
- );
2546
- server.tool(
2547
- "analyze_layout_quality",
2548
- "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.",
2549
- {
2550
- nodeId: z19.string().optional().describe("Root node to analyze (default: current selection)")
2551
- },
2552
- { readOnlyHint: true },
2553
- async ({ nodeId }) => {
2554
- try {
2555
- let targetId;
2556
- if (nodeId) {
2557
- targetId = nodeId;
2558
- } else {
2559
- const selection = await sendCommand("get_selection");
2560
- if (!selection?.nodes?.length) return noSelectionError();
2561
- targetId = selection.nodes[0].id;
2562
- }
2563
- const nodeInfo = await sendCommand("get_node_info", { nodeId: targetId, depth: 8, maxNodes: 500 });
2564
- if (!nodeInfo) {
2565
- return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
2566
- }
2567
- const issues = [];
2568
- let totalFrames = 0;
2569
- let autoLayoutCount = 0;
2570
- analyzeLayoutNode(nodeInfo, issues, { totalFrames: 0, autoLayoutCount: 0 });
2571
- const frameNodes = collectFrames(nodeInfo);
2572
- totalFrames = frameNodes.length;
2573
- autoLayoutCount = frameNodes.filter((f) => f.layoutMode && f.layoutMode !== "NONE").length;
2574
- const data = {
2575
- totalFrames,
2576
- autoLayoutCount,
2577
- autoLayoutCoverage: totalFrames > 0 ? Math.round(autoLayoutCount / totalFrames * 100) / 100 : 1,
2578
- issues
2579
- };
2580
- return standardResult({
2581
- summary: `Layout audit: ${autoLayoutCount}/${totalFrames} frames use auto-layout (${Math.round(data.autoLayoutCoverage * 100)}%). ${issues.length} issue(s) found.`,
2582
- data,
2583
- warnings: issues.filter((i) => i.severity === "warning").map((i) => ({
2584
- category: "layout",
2585
- message: i.issue,
2586
- nodeId: i.nodeId
2587
- })),
2588
- 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" }]
2589
- });
2590
- } catch (error) {
2591
- return errorResult2(
2592
- "connection_error",
2593
- `Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
2594
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2595
- );
2596
- }
2597
- }
2598
- );
2599
- server.tool(
2600
- "analyze_component_health",
2601
- "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.",
2602
- {
2603
- nodeId: z19.string().optional().describe("Scope to analyze (default: current page)")
2604
- },
2605
- { readOnlyHint: true },
2606
- async ({ nodeId }) => {
2607
- try {
2608
- const [instances, components] = await Promise.all([
2609
- sendCommand("scan_nodes_by_types", {
2610
- types: ["INSTANCE"],
2611
- parentId: nodeId
2612
- }, 12e4),
2613
- sendCommand("get_local_components")
2614
- ]);
2615
- const instanceList = Array.isArray(instances) ? instances : [];
2616
- const componentList = Array.isArray(components) ? components : [];
2617
- const issues = [];
2618
- let detachedCount = 0;
2619
- for (const inst of instanceList) {
2620
- if (!inst.mainComponent && !inst.componentId) {
2621
- detachedCount++;
2622
- issues.push({
2623
- nodeId: inst.id,
2624
- nodeName: inst.name,
2625
- issue: "Instance appears detached from its main component"
2626
- });
2627
- }
2628
- }
2629
- const data = {
2630
- totalComponents: componentList.length,
2631
- totalInstances: instanceList.length,
2632
- detachedCount,
2633
- issues
2634
- };
2635
- return standardResult({
2636
- summary: `Components: ${componentList.length} defined, ${instanceList.length} instances, ${detachedCount} potentially detached`,
2637
- data,
2638
- recommended_next: [
2639
- { tool: "get_design_tokens", reason: "Review design system completeness" },
2640
- { tool: "search_nodes", reason: "Find specific components by name" }
2641
- ]
2642
- });
2643
- } catch (error) {
2644
- return errorResult2(
2645
- "connection_error",
2646
- `Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
2647
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2648
- );
2649
- }
2650
- }
2651
- );
2652
- server.tool(
2653
- "analyze_accessibility",
2654
- "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.",
2655
- {
2656
- nodeId: z19.string().optional().describe("Root node to analyze (default: current selection)")
2657
- },
2658
- { readOnlyHint: true },
2659
- async ({ nodeId }) => {
2660
- try {
2661
- let targetId;
2662
- if (nodeId) {
2663
- targetId = nodeId;
2664
- } else {
2665
- const selection = await sendCommand("get_selection");
2666
- if (!selection?.nodes?.length) return noSelectionError();
2667
- targetId = selection.nodes[0].id;
2668
- }
2669
- const nodeInfo = await sendCommand("get_node_info", { nodeId: targetId, depth: 8, maxNodes: 500 });
2670
- if (!nodeInfo) {
2671
- return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
2672
- }
2673
- const issues = [];
2674
- checkAccessibility(nodeInfo, null, issues);
2675
- const data = {
2676
- issueCount: issues.length,
2677
- issues
2678
- };
2679
- return standardResult({
2680
- summary: `Accessibility: ${issues.length} issue(s) found`,
2681
- data,
2682
- warnings: issues.map((i) => ({
2683
- category: "accessibility",
2684
- message: i.issue,
2685
- nodeId: i.nodeId
2686
- })),
2687
- recommended_next: issues.length > 0 ? [{ tool: "update_node", reason: "Fix accessibility issues" }] : [{ tool: "validate_design_rules", reason: "Run full design validation" }]
2688
- });
2689
- } catch (error) {
2690
- return errorResult2(
2691
- "connection_error",
2692
- `Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
2693
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2694
- );
2695
- }
2696
- }
2697
- );
2698
- const CHECKPOINT_KEY = "relai.checkpoint";
2699
- server.tool(
2700
- "diff_nodes",
2701
- `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.`,
2702
- {
2703
- nodeIdA: z19.string().describe("First node ID (or the checkpoint target)"),
2704
- nodeIdB: z19.string().optional().describe("Second node ID (omit when using checkpoint)"),
2705
- checkpoint: z19.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now; compare: diff current vs saved snapshot")
2706
- },
2707
- { readOnlyHint: true },
2708
- async ({ nodeIdA, nodeIdB, checkpoint }) => {
2709
- try {
2710
- if (checkpoint === "save") {
2711
- const node = await sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 });
2712
- if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
2713
- const summary = normalizeNode(node);
2714
- await sendCommand("set_plugin_data", {
2715
- nodeId: nodeIdA,
2716
- key: CHECKPOINT_KEY,
2717
- value: JSON.stringify({ ts: Date.now(), name: node.name, summary })
2718
- });
2719
- return standardResult({
2720
- summary: `Checkpoint saved for '${node.name}' (${nodeIdA}). Run diff_nodes with checkpoint:"compare" after editing to see what changed.`,
2721
- data: { nodeId: nodeIdA, saved: true },
2722
- recommended_next: []
2723
- });
2724
- }
2725
- if (checkpoint === "compare") {
2726
- const [node, stored] = await Promise.all([
2727
- sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 }),
2728
- sendCommand("get_plugin_data", { nodeId: nodeIdA, key: CHECKPOINT_KEY })
2729
- ]);
2730
- if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
2731
- const raw = typeof stored === "string" ? stored : stored?.value;
2732
- if (!raw) {
2733
- return errorResult2("invalid_input", `No checkpoint saved on '${nodeIdA}'`, {
2734
- suggestion: 'Save one first with checkpoint:"save"',
2735
- tool: "diff_nodes"
2736
- });
2737
- }
2738
- const saved = JSON.parse(raw);
2739
- const before = saved.summary;
2740
- const after = normalizeNode(node);
2741
- const differences2 = [];
2742
- if (before && after) {
2743
- compareField(differences2, "type", before.type, after.type);
2744
- compareField(differences2, "fill", before.fill, after.fill);
2745
- compareField(differences2, "stroke", before.stroke, after.stroke);
2746
- compareField(differences2, "cornerRadius", before.cornerRadius, after.cornerRadius);
2747
- compareField(differences2, "opacity", before.opacity, after.opacity);
2748
- compareField(differences2, "width", before.size?.width, after.size.width);
2749
- compareField(differences2, "height", before.size?.height, after.size.height);
2750
- compareField(differences2, "layout.mode", before.layout?.mode, after.layout?.mode);
2751
- compareField(differences2, "layout.gap", before.layout?.gap, after.layout?.gap);
2752
- compareField(differences2, "layout.padding", before.layout?.padding, after.layout?.padding);
2753
- }
2754
- const age = Math.round((Date.now() - (saved.ts ?? Date.now())) / 1e3);
2755
- return standardResult({
2756
- 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`,
2757
- data: {
2758
- nodeId: nodeIdA,
2759
- checkpointTs: saved.ts,
2760
- identical: differences2.length === 0,
2761
- differences: differences2
2762
- },
2763
- recommended_next: []
2764
- });
2765
- }
2766
- if (!nodeIdB) {
2767
- return errorResult2("invalid_input", 'Provide nodeIdB, or use checkpoint:"save"/"compare" with nodeIdA alone', {
2768
- suggestion: "Two-node diff needs both ids",
2769
- tool: "diff_nodes"
2770
- });
2771
- }
2772
- const [nodeA, nodeB] = await Promise.all([
2773
- sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 }),
2774
- sendCommand("get_node_info", { nodeId: nodeIdB, depth: 2 })
2775
- ]);
2776
- if (!nodeA) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
2777
- if (!nodeB) return errorResult2("invalid_input", `Node '${nodeIdB}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
2778
- const summaryA = normalizeNode(nodeA);
2779
- const summaryB = normalizeNode(nodeB);
2780
- const differences = [];
2781
- if (summaryA && summaryB) {
2782
- compareField(differences, "type", summaryA.type, summaryB.type);
2783
- compareField(differences, "fill", summaryA.fill, summaryB.fill);
2784
- compareField(differences, "stroke", summaryA.stroke, summaryB.stroke);
2785
- compareField(differences, "cornerRadius", summaryA.cornerRadius, summaryB.cornerRadius);
2786
- compareField(differences, "opacity", summaryA.opacity, summaryB.opacity);
2787
- compareField(differences, "width", summaryA.size.width, summaryB.size.width);
2788
- compareField(differences, "height", summaryA.size.height, summaryB.size.height);
2789
- compareField(differences, "layout.mode", summaryA.layout?.mode, summaryB.layout?.mode);
2790
- compareField(differences, "layout.gap", summaryA.layout?.gap, summaryB.layout?.gap);
2791
- compareField(differences, "layout.padding", summaryA.layout?.padding, summaryB.layout?.padding);
2792
- }
2793
- const data = {
2794
- nodeA: { id: nodeIdA, name: nodeA.name },
2795
- nodeB: { id: nodeIdB, name: nodeB.name },
2796
- identical: differences.length === 0,
2797
- differences
2798
- };
2799
- return standardResult({
2800
- summary: differences.length === 0 ? `'${nodeA.name}' and '${nodeB.name}' are identical in key properties` : `${differences.length} difference(s) between '${nodeA.name}' and '${nodeB.name}'`,
2801
- data,
2802
- recommended_next: differences.length > 0 ? [{ tool: "update_node", reason: "Align properties between nodes" }] : []
2803
- });
2804
- } catch (error) {
2805
- return errorResult2(
2806
- "connection_error",
2807
- `Diff failed: ${error instanceof Error ? error.message : String(error)}`,
2808
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2809
- );
2810
- }
2811
- }
2812
- );
2813
- }
2814
- function collectColorIssues(node, boundVars, issues, counters) {
2815
- if (node.fills?.length > 0) {
2816
- const solidFills = node.fills.filter((f) => f.type === "SOLID" && f.visible !== false);
2817
- if (solidFills.length > 0) {
2818
- counters.totalProps++;
2819
- const hasFillBinding = Object.keys(boundVars).some((k) => k.startsWith("fills"));
2820
- if (hasFillBinding) {
2821
- counters.boundCount++;
2822
- } else {
2823
- for (const fill of solidFills) {
2824
- if (!fill.color) continue;
2825
- const color = typeof fill.color === "string" ? fill.color : rgbaToHex({
2826
- r: fill.color.r,
2827
- g: fill.color.g,
2828
- b: fill.color.b,
2829
- a: fill.color.a ?? 1
2830
- });
2831
- issues.push({
2832
- nodeId: node.id,
2833
- nodeName: node.name,
2834
- property: "fill",
2835
- color
2836
- });
2837
- }
2838
- }
2839
- }
2840
- }
2841
- if (node.strokes?.length > 0) {
2842
- const solidStrokes = node.strokes.filter((s) => s.type === "SOLID" && s.visible !== false);
2843
- if (solidStrokes.length > 0) {
2844
- counters.totalProps++;
2845
- const hasStrokeBinding = Object.keys(boundVars).some((k) => k.startsWith("strokes"));
2846
- if (hasStrokeBinding) {
2847
- counters.boundCount++;
2848
- } else {
2849
- for (const stroke of solidStrokes) {
2850
- if (!stroke.color) continue;
2851
- const color = typeof stroke.color === "string" ? stroke.color : rgbaToHex({
2852
- r: stroke.color.r,
2853
- g: stroke.color.g,
2854
- b: stroke.color.b,
2855
- a: stroke.color.a ?? 1
2856
- });
2857
- issues.push({
2858
- nodeId: node.id,
2859
- nodeName: node.name,
2860
- property: "stroke",
2861
- color
2862
- });
2863
- }
2864
- }
2865
- }
2866
- }
2867
- }
2868
- function collectFrames(node) {
2869
- const frames = [];
2870
- if (node.type === "FRAME" || node.type === "COMPONENT" || node.type === "COMPONENT_SET") {
2871
- frames.push(node);
2872
- }
2873
- if (node.children) {
2874
- for (const child of node.children) {
2875
- frames.push(...collectFrames(child));
2876
- }
2877
- }
2878
- return frames;
2879
- }
2880
- function analyzeLayoutNode(node, issues, _counters) {
2881
- if (node.type === "FRAME" || node.type === "COMPONENT") {
2882
- if (!node.layoutMode || node.layoutMode === "NONE") {
2883
- if (node.children?.length >= 2) {
2884
- issues.push({
2885
- nodeId: node.id,
2886
- nodeName: node.name,
2887
- issue: `Frame '${node.name}' has ${node.children.length} children but no auto-layout`,
2888
- severity: "warning",
2889
- suggestion: "Add auto-layout with set_auto_layout for responsive behavior"
2890
- });
2891
- }
2892
- }
2893
- }
2894
- if (node.children) {
2895
- for (const child of node.children) {
2896
- analyzeLayoutNode(child, issues, _counters);
2897
- }
2898
- }
2899
- }
2900
- function checkAccessibility(node, parentBg, issues) {
2901
- if (node.type === "INSTANCE" || node.name?.toLowerCase().includes("button")) {
2902
- const w = node.width ?? node.absoluteBoundingBox?.width ?? 0;
2903
- const h = node.height ?? node.absoluteBoundingBox?.height ?? 0;
2904
- if (w > 0 && w < 44 || h > 0 && h < 44) {
2905
- issues.push({
2906
- nodeId: node.id,
2907
- nodeName: node.name,
2908
- issue: `Touch target too small: ${Math.round(w)}\xD7${Math.round(h)}px (minimum 44\xD744px)`
2909
- });
2910
- }
2911
- }
2912
- if (node.type === "TEXT") {
2913
- const fontSize = typeof node.fontSize === "number" ? node.fontSize : null;
2914
- const fontWeight = typeof node.fontWeight === "number" ? node.fontWeight : 400;
2915
- if (fontSize !== null && fontSize < 11) {
2916
- issues.push({
2917
- nodeId: node.id,
2918
- nodeName: node.name,
2919
- issue: `Text size ${fontSize}px is below the 11px readability floor`
2920
- });
2921
- }
2922
- const textFill = node.fills?.find((f) => f.type === "SOLID" && f.visible !== false);
2923
- if (textFill?.color && parentBg?.color) {
2924
- if (parentBg.solid) {
2925
- const alpha = (textFill.opacity ?? 1) * (typeof node.opacity === "number" ? node.opacity : 1) * (textFill.color.a ?? 1);
2926
- const effective = blendOver(textFill.color, parentBg.color, alpha);
2927
- const isLarge = fontSize !== null && (fontSize >= 24 || fontSize >= 18.66 && fontWeight >= 700);
2928
- const required = isLarge ? 3 : 4.5;
2929
- const ratio = calculateContrastRatio(effective, parentBg.color);
2930
- if (ratio < required) {
2931
- issues.push({
2932
- nodeId: node.id,
2933
- nodeName: node.name,
2934
- issue: `Low contrast ratio: ${ratio.toFixed(1)}:1 (minimum ${required}:1 for ${isLarge ? "large" : "body"} text${alpha < 1 ? ", opacity included" : ""})`,
2935
- contrastRatio: Math.round(ratio * 10) / 10,
2936
- requiredRatio: required
2937
- });
2938
- }
2939
- } else {
2940
- issues.push({
2941
- nodeId: node.id,
2942
- nodeName: node.name,
2943
- issue: `Text sits on a ${parentBg.kind} background \u2014 contrast cannot be verified automatically; check manually or add a solid scrim`
2944
- });
2945
- }
2946
- }
2947
- }
2948
- let bg = parentBg;
2949
- if (node.type !== "TEXT" && node.fills?.length > 0) {
2950
- const visible = node.fills.filter((f) => f.visible !== false);
2951
- const solid = visible.find((f) => f.type === "SOLID");
2952
- const nonSolid = visible.find((f) => f.type !== "SOLID");
2953
- if (nonSolid) {
2954
- const type = String(nonSolid.type ?? "IMAGE");
2955
- bg = {
2956
- color: solid?.color ?? { r: 0.5, g: 0.5, b: 0.5 },
2957
- solid: false,
2958
- kind: type.startsWith("GRADIENT") ? "gradient" : type.toLowerCase()
2959
- };
2960
- } else if (solid?.color) {
2961
- bg = { color: solid.color, solid: true, kind: "solid" };
2962
- }
2963
- }
2964
- if (node.children) {
2965
- for (const child of node.children) {
2966
- checkAccessibility(child, bg, issues);
2967
- }
2968
- }
2969
- }
2970
- function blendOver(fg, bg, alpha) {
2971
- const a = Math.max(0, Math.min(1, alpha));
2972
- return {
2973
- r: fg.r * a + bg.r * (1 - a),
2974
- g: fg.g * a + bg.g * (1 - a),
2975
- b: fg.b * a + bg.b * (1 - a)
2976
- };
2977
- }
2978
- function calculateContrastRatio(fg, bg) {
2979
- const fgL = relativeLuminance(fg.r ?? 0, fg.g ?? 0, fg.b ?? 0);
2980
- const bgL = relativeLuminance(bg.r ?? 0, bg.g ?? 0, bg.b ?? 0);
2981
- const lighter = Math.max(fgL, bgL);
2982
- const darker = Math.min(fgL, bgL);
2983
- return (lighter + 0.05) / (darker + 0.05);
2984
- }
2985
- function relativeLuminance(r, g, b) {
2986
- const rL = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
2987
- const gL = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
2988
- const bL = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
2989
- return 0.2126 * rL + 0.7152 * gL + 0.0722 * bL;
2990
- }
2991
- function compareField(diffs, field, a, b) {
2992
- if (a !== b) {
2993
- diffs.push({ field, nodeA: a, nodeB: b });
2994
- }
2995
- }
2996
-
2997
- // src/tools/v2/verification.ts
2998
- var verification_exports = {};
2999
- __export(verification_exports, {
3000
- register: () => register19
3001
- });
3002
- import { z as z20 } from "zod";
3003
- function register19(server, sendCommand) {
3004
- server.tool(
3005
- "verify_changes",
3006
- "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.",
3007
- {
3008
- nodeId: z20.string().describe("Node ID to verify"),
3009
- expected: z20.record(z20.string(), z20.unknown()).describe("Expected property values, e.g. { fill: '#3B82F6', cornerRadius: 8 }")
3010
- },
3011
- { readOnlyHint: true },
3012
- async ({ nodeId, expected }) => {
3013
- try {
3014
- const nodeInfo = await sendCommand("get_node_info", { nodeId, depth: 2 });
3015
- if (!nodeInfo) {
3016
- return errorResult2(
3017
- "invalid_input",
3018
- `Node '${nodeId}' not found \u2014 it may have been deleted`,
3019
- { suggestion: "Use search_nodes to find the node", tool: "search_nodes" }
3020
- );
3021
- }
3022
- const normalized = normalizeNode(nodeInfo);
3023
- if (!normalized) {
3024
- return errorResult2(
3025
- "invalid_input",
3026
- `Could not normalize node '${nodeId}'`,
3027
- { suggestion: "Try get_node_details for raw data", tool: "get_node_details", args: { nodeId } }
3028
- );
3029
- }
3030
- const fields = [];
3031
- for (const [key, expectedValue] of Object.entries(expected)) {
3032
- const actual = getNestedValue(normalized, nodeInfo, key);
3033
- const match = deepEqual(actual, expectedValue);
3034
- fields.push({ field: key, expected: expectedValue, actual, match });
3035
- }
3036
- const allMatch = fields.every((f) => f.match);
3037
- const data = {
3038
- nodeId,
3039
- allMatch,
3040
- fields
3041
- };
3042
- return standardResult({
3043
- 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`,
3044
- data,
3045
- recommended_next: allMatch ? [{ tool: "screenshot", reason: "Visually confirm the changes" }] : [{ tool: "update_node", reason: "Re-apply changes that didn't match", args: { nodeId } }]
3046
- });
3047
- } catch (error) {
3048
- return errorResult2(
3049
- "connection_error",
3050
- `Verification failed: ${error instanceof Error ? error.message : String(error)}`,
3051
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
3052
- );
3053
- }
3054
- }
3055
- );
3056
- server.tool(
3057
- "validate_design_rules",
3058
- "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.",
3059
- {
3060
- nodeId: z20.string().optional().describe("Root node to validate (default: current selection)")
3061
- },
3062
- { readOnlyHint: true },
3063
- async ({ nodeId }) => {
3064
- try {
3065
- let targetId;
3066
- if (nodeId) {
3067
- targetId = nodeId;
3068
- } else {
3069
- const selection = await sendCommand("get_selection");
3070
- if (!selection?.nodes?.length) return noSelectionError();
3071
- targetId = selection.nodes[0].id;
3072
- }
3073
- const [nodeInfo, boundVars] = await Promise.all([
3074
- sendCommand("get_node_info", { nodeId: targetId, depth: 2 }),
3075
- sendCommand("get_bound_variables", { nodeId: targetId }).catch(() => null)
3076
- ]);
3077
- if (!nodeInfo) {
3078
- return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
3079
- }
3080
- const bv = boundVars?.boundVariables || {};
3081
- const results = [];
3082
- const coverage = calculateTokenCoverage(nodeInfo, bv);
3083
- results.push({
3084
- rule: "token_coverage",
3085
- passed: coverage >= 0.8,
3086
- severity: coverage >= 0.5 ? "warning" : "error",
3087
- message: `Token coverage: ${Math.round(coverage * 100)}% (target: 80%+)`,
3088
- nodeId: targetId,
3089
- fix: coverage < 0.8 ? { tool: "bind_tokens", reason: "Bind unbound properties to design tokens", args: { nodeId: targetId } } : void 0
3090
- });
3091
- if (nodeInfo.type === "FRAME" || nodeInfo.type === "COMPONENT") {
3092
- const hasAutoLayout = nodeInfo.layoutMode && nodeInfo.layoutMode !== "NONE";
3093
- const hasChildren = nodeInfo.children?.length >= 2;
3094
- results.push({
3095
- rule: "auto_layout",
3096
- passed: hasAutoLayout || !hasChildren,
3097
- severity: "warning",
3098
- message: hasAutoLayout ? `Auto-layout: ${nodeInfo.layoutMode}` : hasChildren ? `No auto-layout on frame with ${nodeInfo.children.length} children` : "No children requiring auto-layout",
3099
- nodeId: targetId,
3100
- fix: !hasAutoLayout && hasChildren ? { tool: "set_auto_layout", reason: "Add auto-layout for responsive behavior", args: { nodeId: targetId } } : void 0
3101
- });
3102
- }
3103
- const hasDefaultName = /^(Frame|Rectangle|Ellipse|Group|Text|Vector|Line|Polygon|Star)\s*\d*$/.test(nodeInfo.name);
3104
- results.push({
3105
- rule: "naming_convention",
3106
- passed: !hasDefaultName,
3107
- severity: "info",
3108
- message: hasDefaultName ? `'${nodeInfo.name}' uses a default Figma name \u2014 rename for clarity` : `Name '${nodeInfo.name}' looks intentional`,
3109
- nodeId: targetId,
3110
- fix: hasDefaultName ? { tool: "update_node", reason: "Rename with a semantic name", args: { nodeId: targetId } } : void 0
3111
- });
3112
- if (nodeInfo.type === "INSTANCE" || nodeInfo.name?.toLowerCase().includes("button")) {
3113
- const w = nodeInfo.width ?? 0;
3114
- const h = nodeInfo.height ?? 0;
3115
- const adequate = w >= 44 && h >= 44;
3116
- results.push({
3117
- rule: "touch_target_size",
3118
- passed: adequate,
3119
- severity: "warning",
3120
- 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)`,
3121
- nodeId: targetId,
3122
- fix: !adequate ? { tool: "update_node", reason: "Increase size to meet touch target minimum", args: { nodeId: targetId } } : void 0
3123
- });
3124
- }
3125
- const passed = results.filter((r) => r.passed).length;
3126
- const failed = results.filter((r) => !r.passed).length;
3127
- const data = {
3128
- rulesChecked: results.length,
3129
- passed,
3130
- failed,
3131
- results
3132
- };
3133
- return standardResult({
3134
- summary: `Design validation: ${passed}/${results.length} rules passed, ${failed} failed`,
3135
- data,
3136
- 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" }]
3137
- });
3138
- } catch (error) {
3139
- return errorResult2(
3140
- "connection_error",
3141
- `Validation failed: ${error instanceof Error ? error.message : String(error)}`,
3142
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
3143
- );
3144
- }
3145
- }
3146
- );
3147
- server.tool(
3148
- "verify_visual",
3149
- "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.",
3150
- {
3151
- nodeId: z20.string().describe("Node to verify"),
3152
- expected: z20.record(z20.string(), z20.unknown()).optional().describe("Expected property values, e.g. { cornerRadius: 8, width: 320 }")
3153
- },
3154
- { readOnlyHint: true },
3155
- async ({ nodeId, expected }) => {
3156
- try {
3157
- const [shot, nodeInfo] = await Promise.all([
3158
- sendCommand("get_screenshot", { nodeId }, 6e4),
3159
- expected ? sendCommand("get_node_info", { nodeId, depth: 2 }) : Promise.resolve(null)
3160
- ]);
3161
- const content = [];
3162
- if (shot?.imageData) {
3163
- content.push({
3164
- type: "image",
3165
- data: shot.imageData,
3166
- mimeType: shot.mimeType ?? "image/png"
3167
- });
3168
- }
3169
- if (expected && nodeInfo) {
3170
- const normalized = normalizeNode(nodeInfo) ?? {};
3171
- const fields = Object.entries(expected).map(([key, expectedValue]) => {
3172
- const actual = getNestedValue(normalized, nodeInfo, key);
3173
- return { field: key, expected: expectedValue, actual, match: deepEqual(actual, expectedValue) };
3174
- });
3175
- const failing = fields.filter((f) => !f.match);
3176
- content.push({
3177
- type: "text",
3178
- text: JSON.stringify(
3179
- {
3180
- summary: failing.length === 0 ? `All ${fields.length} checked properties match \u2014 confirm the screenshot looks right` : `${failing.length}/${fields.length} properties do NOT match`,
3181
- fields
3182
- },
3183
- null,
3184
- 2
3185
- )
3186
- });
3187
- } else if (!shot?.imageData) {
3188
- content.push({ type: "text", text: "Screenshot unavailable for this node." });
3189
- }
3190
- return { content };
3191
- } catch (error) {
3192
- return errorResult2(
3193
- "connection_error",
3194
- `Visual verification failed: ${error instanceof Error ? error.message : String(error)}`,
3195
- { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
3196
- );
3197
- }
3198
- }
3199
- );
3200
- }
3201
- function getNestedValue(normalized, raw, key) {
3202
- if (key in normalized) return normalized[key];
3203
- if (key === "width") return normalized.size?.width;
3204
- if (key === "height") return normalized.size?.height;
3205
- if (key === "x") return normalized.position?.x;
3206
- if (key === "y") return normalized.position?.y;
3207
- return raw[key];
3208
- }
3209
- function deepEqual(a, b) {
3210
- if (a === b) return true;
3211
- if (a == null || b == null) return false;
3212
- if (typeof a === "number" && typeof b === "number") {
3213
- return Math.abs(a - b) < 0.01;
3214
- }
3215
- if (typeof a === "string" && typeof b === "string") {
3216
- return a.toLowerCase() === b.toLowerCase();
3217
- }
3218
- return JSON.stringify(a) === JSON.stringify(b);
3219
- }
3220
-
3221
- // src/tools/index.ts
3222
- var moduleCategories = [
3223
- [context_exports, "context"],
3224
- [analysis_exports, "analysis"],
3225
- [verification_exports, "verification"],
3226
- [read_exports, "read"],
3227
- [create_exports, "create"],
3228
- [properties_exports, "edit"],
3229
- [structure_exports, "edit"],
3230
- [text_exports, "edit"],
3231
- [components_exports, "components"],
3232
- [variables_exports, "design-system"],
3233
- [styles_exports, "design-system"],
3234
- [library_exports, "design-system"],
3235
- [pages_exports, "document"],
3236
- [navigate_exports, "document"],
3237
- [assets_exports, "assets"],
3238
- [annotate_exports, "annotations"],
3239
- [comments_exports, "comments"],
3240
- [batch_exports, "advanced"],
3241
- [execute_exports, "advanced"]
3242
- ];
3243
- function registerRoomTool(server, joinRoom) {
3244
- server.tool(
3245
- "join_room",
3246
- "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.",
3247
- {
3248
- room: z21.string().describe("Room name shown in the Figma plugin")
3249
- },
3250
- async ({ room }) => {
3251
- try {
3252
- if (!room) {
3253
- return {
3254
- content: [{ type: "text", text: "Please provide a room name." }]
3255
- };
3256
- }
3257
- await joinRoom(room);
3258
- return {
3259
- content: [
3260
- { type: "text", text: `Successfully joined room: ${room}` }
3261
- ]
3262
- };
3263
- } catch (error) {
3264
- return {
3265
- content: [
3266
- {
3267
- type: "text",
3268
- text: `Error joining room: ${error instanceof Error ? error.message : String(error)}`
3269
- }
3270
- ]
3271
- };
3272
- }
3273
- }
3274
- );
3275
- }
3276
- function registerEventsTool(server, consumeEvents, getSessionLog2) {
3277
- server.tool(
3278
- "get_events",
3279
- "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.",
3280
- {
3281
- scope: z21.enum(["designer", "agent", "all"]).optional()
3282
- },
3283
- { readOnlyHint: true },
3284
- async ({ scope = "designer" }) => {
3285
- const parts = {};
3286
- if (scope === "designer" || scope === "all") {
3287
- parts.designer_events = consumeEvents();
3288
- }
3289
- if (scope === "agent" || scope === "all") {
3290
- parts.session_log = getSessionLog2();
3291
- }
3292
- const empty = Object.values(parts).every((v) => Array.isArray(v) && v.length === 0);
3293
- return {
3294
- content: [
3295
- {
3296
- type: "text",
3297
- text: empty ? "No activity recorded." : JSON.stringify(parts, null, 2)
3298
- }
3299
- ]
3300
- };
3301
- }
3302
- );
3303
- }
3304
- function registerAllTools(server, sendCommand) {
3305
- for (const [mod] of moduleCategories) {
3306
- mod.register(server, sendCommand);
3307
- }
3308
- }
3309
- function listToolCatalog() {
3310
- const catalog = [
3311
- { name: "join_room", category: "advanced" },
3312
- { name: "get_events", category: "context" }
3313
- ];
3314
- for (const [mod, category] of moduleCategories) {
3315
- const collector = {
3316
- tool: (name) => {
3317
- catalog.push({ name, category });
3318
- }
3319
- };
3320
- mod.register(collector, async () => void 0);
3321
- }
3322
- return catalog;
3323
- }
3324
-
3325
- // src/embedded-relay.ts
3326
- import { createServer as createServer2 } from "http";
3327
- import { WebSocketServer } from "ws";
3328
- function startEmbeddedRelay(port2, version) {
3329
- return new Promise((resolve) => {
3330
- const httpServer = createServer2();
3331
- httpServer.once("error", (err) => {
3332
- if (err.code === "EADDRINUSE") {
3333
- resolve(null);
3334
- } else {
3335
- logger.warn(`Embedded relay failed to start: ${err.message}`);
3336
- resolve(null);
3337
- }
3338
- });
3339
- httpServer.listen(port2, "127.0.0.1", () => {
3340
- const core = new RelayCore({
3341
- version,
3342
- log: (msg) => logger.debug(`[relay] ${msg}`)
3343
- });
3344
- const wss = new WebSocketServer({ server: httpServer });
3345
- wss.on("connection", (ws) => {
3346
- core.handleOpen(ws);
3347
- ws.on("message", (data) => core.handleMessage(ws, data.toString()));
3348
- ws.on("close", () => core.handleClose(ws));
3349
- ws.on("error", () => {
3350
- });
3351
- });
3352
- core.startStaleCleanup();
3353
- logger.info(`Hosting embedded relay on 127.0.0.1:${port2}`);
3354
- resolve({
3355
- close() {
3356
- core.stop();
3357
- wss.close();
3358
- httpServer.close();
3359
- }
3360
- });
3361
- });
3362
- });
3363
- }
3364
-
3365
- // src/state.ts
3366
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
3367
- import { homedir } from "os";
3368
- import { join } from "path";
3369
- function stateDir() {
3370
- return process.env.FIGMA_RELAI_STATE_DIR ?? join(homedir(), ".figma-relai");
3371
- }
3372
- function statePath() {
3373
- return join(stateDir(), "state.json");
3374
- }
3375
- function loadState() {
3376
- try {
3377
- return JSON.parse(readFileSync(statePath(), "utf8"));
3378
- } catch {
3379
- return {};
3380
- }
3381
- }
3382
- function saveState(patch) {
3383
- try {
3384
- mkdirSync(stateDir(), { recursive: true });
3385
- const next = { ...loadState(), ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
3386
- writeFileSync(statePath(), JSON.stringify(next, null, 2) + "\n");
3387
- } catch {
3388
- }
3389
- }
3390
-
3391
- // ../../docs/skills/figma-plugin-api.md
3392
- 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';
3393
-
3394
- // ../../docs/skills/design-tokens-strategy.md
3395
- 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";
3396
-
3397
- // ../../docs/skills/component-architecture-strategy.md
3398
- 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";
3399
-
3400
- // ../../docs/skills/design-audit-strategy.md
3401
- 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";
3402
-
3403
- // ../../docs/skills/token-audit.md
3404
- 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";
3405
-
3406
- // ../../docs/skills/component-spec.md
3407
- 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";
3408
-
3409
- // src/prompts.ts
3410
- var SKILLS = [
3411
- [
3412
- "figma-plugin-api",
3413
- "Cheat sheet for writing execute_figma code: dynamic-page rules, font loading, auto-layout pitfalls, common patterns. Load before non-trivial execute_figma work.",
3414
- figma_plugin_api_default
3415
- ],
3416
- [
3417
- "design-tokens-strategy",
3418
- "4-tier design-token architecture: naming, mode design, tier reference rules. Load before creating or restructuring variables.",
3419
- design_tokens_strategy_default
3420
- ],
3421
- [
3422
- "component-architecture-strategy",
3423
- "Component conventions: token-first properties, state handling, size variants, variant naming. Load before building components.",
3424
- component_architecture_strategy_default
3425
- ],
3426
- [
3427
- "design-audit-strategy",
3428
- "End-to-end design audit workflow: overview \u2192 tokens \u2192 per-aspect analysis \u2192 report.",
3429
- design_audit_strategy_default
3430
- ],
3431
- [
3432
- "token-audit",
3433
- "Token-compliance check: find raw values, tier violations, scope issues, unbound properties.",
3434
- token_audit_default
3435
- ],
3436
- [
3437
- "component-spec",
3438
- "Generate a structured component specification: properties, variants, token bindings, state matrix.",
3439
- component_spec_default
3440
- ]
3441
- ];
3442
- function registerPrompts(server) {
3443
- for (const [name, description, text] of SKILLS) {
3444
- server.prompt(name, description, () => ({
3445
- messages: [
3446
- {
3447
- role: "user",
3448
- content: { type: "text", text }
3449
- }
3450
- ]
3451
- }));
454
+ const { renderToolDoc, renderToolIndex } = await import("./docs-S7VJ2PPR.js");
455
+ const toolName = args[args.indexOf("docs") + 1];
456
+ console.log(
457
+ toolName && !toolName.startsWith("--") ? renderToolDoc(manifest, toolName) : renderToolIndex(manifest)
458
+ );
3452
459
  }
3453
- }
3454
-
3455
- // src/session-log.ts
3456
- var MAX_ENTRIES = 200;
3457
- var entries = [];
3458
- function recordCommand(entry) {
3459
- entries.push(entry);
3460
- if (entries.length > MAX_ENTRIES) entries = entries.slice(-MAX_ENTRIES);
3461
- }
3462
- function getSessionLog() {
3463
- return [...entries];
3464
- }
3465
-
3466
- // src/index.ts
3467
- var VERSION = "0.1.4";
3468
- var args = process.argv.slice(2);
3469
- var serverArg = args.find((arg) => arg.startsWith("--server="));
3470
- var serverUrl = serverArg ? serverArg.split("=")[1] : "localhost";
3471
- var portArg = args.find((arg) => arg.startsWith("--port="));
3472
- var port = portArg ? parseInt(portArg.split("=")[1]) : 9055;
3473
- var roomArg = args.find((arg) => arg.startsWith("--room="));
3474
- if (args.includes("--list-tools")) {
3475
- console.log(JSON.stringify(listToolCatalog(), null, 2));
3476
460
  process.exit(0);
3477
461
  }
3478
462
  async function main() {