From ac1bc47c38f92476e2ec0f681f4b73223369bcf3 Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:48:35 +0530 Subject: [PATCH 01/31] feat(limitless-plugin): complete variable/token authoring verbs in write_variables Wave 1 of the surface + tool expansion. The write_variables batch tool covered create/set/alias/bind/delete but had no way to rename or update existing tokens, rename/remove modes, or delete a whole collection. Adds six verbs to the action union (schema + plugin handler), mirroring the existing action pattern exactly: - rename_variable (variableId, name) - update_variable (scopes / description / hiddenFromPublishing / codeSyntax) - rename_collection (collectionId, name) - delete_collection (collectionId) - rename_mode (collectionId, modeId, name) - remove_mode (collectionId, modeId) All are local Plugin-API ops (no REST, no token). No new top-level tools, so no toolInputSchemas/rpcToArgs/registration churn; write_variables is already in EDIT_REQUEST_TYPES + JOURNALED_TOOLS. Tool description updated. Build gate: server tsc clean, plugin typecheck + vite build clean. Live in-Figma verification pending (manual). Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/plugin/src/main/code.ts | 83 +++++++++++++++++++++ figma-limitless-mcp/server/src/schema.ts | 28 ++++++- figma-limitless-mcp/server/src/tools.ts | 2 +- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/figma-limitless-mcp/plugin/src/main/code.ts b/figma-limitless-mcp/plugin/src/main/code.ts index 918cb92..c5f9eff 100644 --- a/figma-limitless-mcp/plugin/src/main/code.ts +++ b/figma-limitless-mcp/plugin/src/main/code.ts @@ -2687,6 +2687,89 @@ const handleRequest = async ( variable.remove(); return { action: a.action, deleted: name }; } + case "rename_variable": { + if (typeof a.variableId !== "string" || typeof a.name !== "string") { + throw new Error("rename_variable needs variableId and name"); + } + const variable = await figma.variables.getVariableByIdAsync(a.variableId); + if (!variable) throw new Error(`Variable not found: ${a.variableId}`); + variable.name = a.name; + return { action: a.action, variableId: variable.id, name: variable.name }; + } + case "update_variable": { + if (typeof a.variableId !== "string") { + throw new Error("update_variable needs variableId"); + } + const variable = await figma.variables.getVariableByIdAsync(a.variableId); + if (!variable) throw new Error(`Variable not found: ${a.variableId}`); + if (Array.isArray(a.scopes)) { + variable.scopes = a.scopes as VariableScope[]; + } + if (typeof a.description === "string") { + variable.description = a.description; + } + if (typeof a.hiddenFromPublishing === "boolean") { + variable.hiddenFromPublishing = a.hiddenFromPublishing; + } + if (a.codeSyntax && typeof a.codeSyntax === "object") { + for (const [platform, value] of Object.entries( + a.codeSyntax as Record + )) { + if (typeof value === "string") { + variable.setVariableCodeSyntax( + platform as CodeSyntaxPlatform, + value + ); + } + } + } + return { action: a.action, variableId: variable.id }; + } + case "rename_collection": { + if (typeof a.collectionId !== "string" || typeof a.name !== "string") { + throw new Error("rename_collection needs collectionId and name"); + } + const collection = + await figma.variables.getVariableCollectionByIdAsync(a.collectionId); + if (!collection) throw new Error(`Collection not found: ${a.collectionId}`); + collection.name = a.name; + return { action: a.action, collectionId: collection.id, name: collection.name }; + } + case "delete_collection": { + if (typeof a.collectionId !== "string") { + throw new Error("delete_collection needs collectionId"); + } + const collection = + await figma.variables.getVariableCollectionByIdAsync(a.collectionId); + if (!collection) throw new Error(`Collection not found: ${a.collectionId}`); + const name = collection.name; + collection.remove(); + return { action: a.action, deleted: name }; + } + case "rename_mode": { + if ( + typeof a.collectionId !== "string" || + typeof a.modeId !== "string" || + typeof a.name !== "string" + ) { + throw new Error("rename_mode needs collectionId, modeId, and name"); + } + const collection = + await figma.variables.getVariableCollectionByIdAsync(a.collectionId); + if (!collection) throw new Error(`Collection not found: ${a.collectionId}`); + collection.renameMode(a.modeId, a.name); + return { action: a.action, modeId: a.modeId, name: a.name }; + } + case "remove_mode": { + if (typeof a.collectionId !== "string" || typeof a.modeId !== "string") { + throw new Error("remove_mode needs collectionId and modeId"); + } + const collection = + await figma.variables.getVariableCollectionByIdAsync(a.collectionId); + if (!collection) throw new Error(`Collection not found: ${a.collectionId}`); + collection.removeMode(a.modeId); + return { action: a.action, removedModeId: a.modeId }; + } default: throw new Error(`Unknown write_variables action: ${String(a.action)}`); } diff --git a/figma-limitless-mcp/server/src/schema.ts b/figma-limitless-mcp/server/src/schema.ts index 4432bc1..6840a57 100644 --- a/figma-limitless-mcp/server/src/schema.ts +++ b/figma-limitless-mcp/server/src/schema.ts @@ -574,9 +574,20 @@ const variableAction = z.object({ "set_alias", "bind_to_node", "delete_variable", + "rename_variable", + "update_variable", + "rename_collection", + "delete_collection", + "rename_mode", + "remove_mode", ]) .describe("What this step does"), - name: z.string().optional().describe("create_collection/add_mode/create_variable: name"), + name: z + .string() + .optional() + .describe( + "create_collection/add_mode/create_variable: name. Also the NEW name for rename_variable/rename_collection/rename_mode." + ), initialModeName: z .string() .optional() @@ -600,7 +611,20 @@ const variableAction = z.object({ .describe( "create_variable: VariableScope list, e.g. ['FONT_FAMILY'] or ['ALL_SCOPES'] — never leave color tokens on ALL_SCOPES in a design system" ), - description: z.string().optional(), + description: z + .string() + .optional() + .describe("create_variable/update_variable: human-readable description"), + hiddenFromPublishing: z + .boolean() + .optional() + .describe("update_variable: hide this variable from published libraries"), + codeSyntax: z + .record(z.enum(["WEB", "ANDROID", "iOS"]), z.string()) + .optional() + .describe( + "update_variable: platform code-syntax names, e.g. { WEB: '--color-brand', ANDROID: 'color_brand' }" + ), value: z .unknown() .optional() diff --git a/figma-limitless-mcp/server/src/tools.ts b/figma-limitless-mcp/server/src/tools.ts index d7d7d46..e217acf 100644 --- a/figma-limitless-mcp/server/src/tools.ts +++ b/figma-limitless-mcp/server/src/tools.ts @@ -796,7 +796,7 @@ export function registerTools( server.tool( "write_variables", - "Author design tokens — the official MCP has NO variable-write surface. Sequential action batch: create_collection, add_mode, create_variable (with scopes/description/valuesByMode), set_value, set_alias, bind_to_node (node fields or solid-paint colors), delete_variable. Later actions reference earlier results via '$N.' (e.g. collectionId: '$0.collectionId'), so one call builds a whole collection. Stops at the first error by default and reports per-action outcomes.", + "Author design tokens — the official MCP has NO variable-write surface. Sequential action batch: create_collection, rename_collection, delete_collection, add_mode, rename_mode, remove_mode, create_variable (with scopes/description/valuesByMode), rename_variable, update_variable (scopes/description/hiddenFromPublishing/codeSyntax), set_value, set_alias, bind_to_node (node fields or solid-paint colors), delete_variable. Later actions reference earlier results via '$N.' (e.g. collectionId: '$0.collectionId'), so one call builds a whole collection. Stops at the first error by default and reports per-action outcomes.", writeVariablesInput.shape, async ({ actions, stopOnError, fileKey }): Promise => { const params: Record = { actions }; From 2af9a0d5109ec3f49a3554b916959f8c0d5cb9d5 Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:01:34 +0530 Subject: [PATCH 02/31] =?UTF-8?q?feat(limitless-plugin):=20add=20Slots=20s?= =?UTF-8?q?uite=20=E2=80=94=20get=5Fslots,=20reset=5Fslot,=20append=5Fto?= =?UTF-8?q?=5Fslot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 2 of the surface + tool expansion. The plugin could create a slot (create_slot) but couldn't read, reset, or populate one. Adds three first-class tools over the GA'd (June 2026) Figma Slots Plugin API, wired through all four seams (schema + toolInputSchemas + rpcToArgs, tools.ts registration, plugin RequestType + handler, orchestration journaling): - get_slots (read) — enumerate SLOT frames in a node (id, name, childCount, limitViolations) + the owning COMPONENT/COMPONENT_SET's SLOT property defs (key, description, slotSettings) - reset_slot (write) — SlotNode.resetSlot() - append_to_slot (write) — move a scene node into a slot (insertChild/ appendChild); surfaces the Figma platform limit that instance-slot population is blocked All local Plugin-API ops (no REST, no token). Reads/writes guarded the same way as create_slot; writes added to EDIT_REQUEST_TYPES + JOURNALED_TOOLS. Tool count 72 -> 75. Build gate: server tsc clean, plugin typecheck + vite build clean. Live in-Figma verification pending (manual). Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/plugin/src/main/code.ts | 93 +++++++++++++++++++ .../server/src/orchestration.ts | 2 + figma-limitless-mcp/server/src/schema.ts | 34 +++++++ figma-limitless-mcp/server/src/tools.ts | 38 ++++++++ 4 files changed, 167 insertions(+) diff --git a/figma-limitless-mcp/plugin/src/main/code.ts b/figma-limitless-mcp/plugin/src/main/code.ts index c5f9eff..10d12e4 100644 --- a/figma-limitless-mcp/plugin/src/main/code.ts +++ b/figma-limitless-mcp/plugin/src/main/code.ts @@ -62,6 +62,9 @@ type RequestType = | "import_library_asset" | "list_library_variables" | "create_slot" + | "get_slots" + | "reset_slot" + | "append_to_slot" | "dev_resources"; type ServerRequestParams = Record & { @@ -886,6 +889,8 @@ const EDIT_REQUEST_TYPES = new Set([ "create_effect_style", "import_library_asset", "create_slot", + "reset_slot", + "append_to_slot", ]); const requireEditorMode = (toolName: RequestType): void => { @@ -3831,6 +3836,94 @@ const handleRequest = async ( }, }; } + case "get_slots": { + const nodeId = request.nodeIds && request.nodeIds[0]; + if (!nodeId) throw new Error("nodeIds is required for get_slots"); + const node = await figma.getNodeByIdAsync(nodeId); + if (!node) throw new Error(`Node not found: ${nodeId}`); + const slots: SlotNode[] = []; + if (node.type === "SLOT") slots.push(node); + if ("findAll" in node) { + for (const found of (node as ChildrenMixin).findAll( + (n) => n.type === "SLOT" + )) { + slots.push(found as SlotNode); + } + } + const slotData = slots.map((s) => ({ + slotId: s.id, + name: s.name, + childCount: s.children.length, + limitViolations: s.limitViolations, + })); + let slotProperties: + | Array<{ + key: string; + description?: string; + slotSettings?: SlotSettings; + }> + | undefined; + if (node.type === "COMPONENT" || node.type === "COMPONENT_SET") { + slotProperties = Object.entries(node.componentPropertyDefinitions) + .filter(([, def]) => def.type === "SLOT") + .map(([key, def]) => ({ + key, + description: def.description, + slotSettings: def.slotSettings, + })); + } + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, slots: slotData, slotProperties }, + }; + } + case "reset_slot": { + const nodeId = request.nodeIds && request.nodeIds[0]; + if (!nodeId) throw new Error("nodeIds is required for reset_slot"); + const node = await figma.getNodeByIdAsync(nodeId); + if (!node || node.type !== "SLOT") { + throw new Error( + `reset_slot needs a SLOT node, got ${node?.type ?? "missing"}` + ); + } + node.resetSlot(); + return { + type: request.type, + requestId: request.requestId, + data: { slotId: node.id, reset: true }, + }; + } + case "append_to_slot": { + const params = request.params ?? {}; + const slotId = params.slotId; + const childId = params.nodeId; + if (typeof slotId !== "string" || typeof childId !== "string") { + throw new Error("append_to_slot needs slotId and nodeId"); + } + const slot = await figma.getNodeByIdAsync(slotId); + if (!slot || slot.type !== "SLOT") { + throw new Error( + `append_to_slot slotId must be a SLOT, got ${slot?.type ?? "missing"}` + ); + } + const child = await getSceneNodeById(childId); + if (typeof params.index === "number") { + slot.insertChild(params.index, child); + } else { + slot.appendChild(child); + } + return { + type: request.type, + requestId: request.requestId, + data: { + slotId: slot.id, + appended: child.id, + childCount: slot.children.length, + limitViolations: slot.limitViolations, + }, + }; + } case "dev_resources": { const nodeId = request.nodeIds && request.nodeIds[0]; if (!nodeId) throw new Error("nodeIds is required for dev_resources"); diff --git a/figma-limitless-mcp/server/src/orchestration.ts b/figma-limitless-mcp/server/src/orchestration.ts index eaaa5f6..1dfa5df 100644 --- a/figma-limitless-mcp/server/src/orchestration.ts +++ b/figma-limitless-mcp/server/src/orchestration.ts @@ -108,6 +108,8 @@ export const JOURNALED_TOOLS = new Set([ "create_effect_style", "import_library_asset", "create_slot", + "reset_slot", + "append_to_slot", "dev_resources", ]); diff --git a/figma-limitless-mcp/server/src/schema.ts b/figma-limitless-mcp/server/src/schema.ts index 6840a57..784e619 100644 --- a/figma-limitless-mcp/server/src/schema.ts +++ b/figma-limitless-mcp/server/src/schema.ts @@ -971,6 +971,31 @@ export const createSlotInput = z.object({ fileKey: fileKeyField, }); +export const getSlotsInput = z.object({ + nodeId: createFigmaNodeIdSchema().describe( + "Node to search for SLOT frames — a COMPONENT, COMPONENT_SET, INSTANCE, or any frame subtree" + ), + fileKey: fileKeyField, +}); + +export const resetSlotInput = z.object({ + nodeId: createFigmaNodeIdSchema().describe( + "The SLOT node to reset to its empty/default state (discover via get_slots)" + ), + fileKey: fileKeyField, +}); + +export const appendToSlotInput = z.object({ + slotId: createFigmaNodeIdSchema().describe("The SLOT node to populate (must be type SLOT)"), + nodeId: createFigmaNodeIdSchema().describe("The scene node to move into the slot"), + index: z + .number() + .min(0) + .optional() + .describe("Insert position among the slot's children (default: append to end)"), + fileKey: fileKeyField, +}); + export const devResourcesShape = z.object({ nodeId: createFigmaNodeIdSchema().describe("Target node"), action: z.enum(["get", "add", "edit", "delete"]), @@ -1615,6 +1640,12 @@ export const toolInputSchemas = { create_slot: createSlotInput, + get_slots: getSlotsInput, + + reset_slot: resetSlotInput, + + append_to_slot: appendToSlotInput, + dev_resources: devResourcesInput, set_code_mapping: setCodeMappingInput, @@ -1725,6 +1756,9 @@ const rpcToArgs: Record< import_library_asset: (_nodeIds, params) => ({ ...params }), list_library_variables: (_nodeIds, params) => ({ ...params }), create_slot: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), + get_slots: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), + reset_slot: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), + append_to_slot: (_nodeIds, params) => ({ ...params }), dev_resources: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), set_code_mapping: (_nodeIds, params) => ({ ...params }), get_code_mappings: (_nodeIds, params) => ({ ...params }), diff --git a/figma-limitless-mcp/server/src/tools.ts b/figma-limitless-mcp/server/src/tools.ts index e217acf..3f7ede4 100644 --- a/figma-limitless-mcp/server/src/tools.ts +++ b/figma-limitless-mcp/server/src/tools.ts @@ -69,6 +69,9 @@ import { importLibraryAssetInput, listLibraryVariablesInput, createSlotInput, + getSlotsInput, + resetSlotInput, + appendToSlotInput, devResourcesShape, devResourcesInput, setCodeMappingShape, @@ -1091,6 +1094,41 @@ export function registerTools( } ); + server.tool( + "get_slots", + "List the SLOT frames within a node (Slots, GA June 2026). Returns each slot's id, name, child count, and limitViolations, plus — when the node is a COMPONENT/COMPONENT_SET — its SLOT component-property definitions (key, description, slotSettings). Read-only.", + getSlotsInput.shape, + async ({ nodeId, fileKey }): Promise => { + return renderResponse(() => + node.sendWithParams("get_slots", [nodeId], undefined, fileKey) + ); + } + ); + + server.tool( + "reset_slot", + "Reset a SLOT node to its empty/default state, clearing content added into it. nodeId must be a SLOT (discover via get_slots).", + resetSlotInput.shape, + async ({ nodeId, fileKey }): Promise => { + return renderResponse(() => + node.sendWithParams("reset_slot", [nodeId], undefined, fileKey) + ); + } + ); + + server.tool( + "append_to_slot", + "Move a scene node into a SLOT frame to populate it, on a COMPONENT definition. Note: Figma blocks appending into a slot that lives inside an INSTANCE (a platform limit) — populate the master component instead. Returns the slot's limitViolations after insertion.", + appendToSlotInput.shape, + async ({ slotId, nodeId, index, fileKey }): Promise => { + const params: Record = { slotId, nodeId }; + if (index !== undefined) params.index = index; + return renderResponse(() => + node.sendWithParams("append_to_slot", undefined, params, fileKey) + ); + } + ); + server.tool( "dev_resources", "Read/add/edit/delete dev resources (links to tickets, docs, storybook) attached to nodes — the Dev-Mode handoff surface, editable here without a Dev seat's UI.", From 90acdbf8563210c7739dd2500fb0869342579a07 Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:12:26 +0530 Subject: [PATCH 03/31] feat(limitless-plugin): enable FigJam/Slides/Buzz surfaces + Dev-Mode companion manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 3 — multi-surface foundation. Figma's manifest validator forbids only one editorType combination: ["figjam","dev"]. So a single manifest can't carry both FigJam and Dev Mode. Solution: one codebase, two manifests. - manifest.json -> editorType ["figma","figjam","slides","buzz"] (drops "dev" + the dev-only capabilities:["inspect"]) - manifest.dev.json -> editorType ["figma","dev"] + capabilities:["inspect"], NEW optional companion pointing at the same dist/ + the same server. Import it too for Dev-Mode inspection. Both entries share one build and one MCP server on :1994; each activates only in its declared surfaces and the plugin branches on figma.editorType. requireEditorMode generalized: design-canvas write tools now give a clear, editor-specific error in FigJam (pointing to execute_code / FigJam-native creation) instead of a cryptic Figma runtime error; Dev-Mode message clarified. execute_code + reads + screenshots already work in every enabled surface today; dedicated FigJam/Slides/Buzz tool adapters land in the next waves. Build gate: plugin typecheck + vite build clean; both manifests valid JSON. Live in-Figma verification pending (manual) — incl. documentAccess:dynamic-page across figjam/slides/buzz at import time. Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/plugin/manifest.dev.json | 24 ++++++++++++++++++++ figma-limitless-mcp/plugin/manifest.json | 7 +++--- figma-limitless-mcp/plugin/src/main/code.ts | 15 ++++++++---- 3 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 figma-limitless-mcp/plugin/manifest.dev.json diff --git a/figma-limitless-mcp/plugin/manifest.dev.json b/figma-limitless-mcp/plugin/manifest.dev.json new file mode 100644 index 0000000..346be36 --- /dev/null +++ b/figma-limitless-mcp/plugin/manifest.dev.json @@ -0,0 +1,24 @@ +{ + "name": "Figma Limitless MCP (Dev Mode)", + "id": "figma-limitless-mcp-dev", + "api": "1.0.0", + "main": "dist/code.js", + "ui": "dist/index.html", + "permissions": [ + "teamlibrary" + ], + "networkAccess": { + "allowedDomains": [ + "ws://localhost:1994" + ], + "reasoning": "Connects to the local figma-limitless-mcp server via WebSocket to stream Figma document data to AI tools" + }, + "documentAccess": "dynamic-page", + "editorType": [ + "figma", + "dev" + ], + "capabilities": [ + "inspect" + ] +} diff --git a/figma-limitless-mcp/plugin/manifest.json b/figma-limitless-mcp/plugin/manifest.json index 5c8782f..3a1cee8 100644 --- a/figma-limitless-mcp/plugin/manifest.json +++ b/figma-limitless-mcp/plugin/manifest.json @@ -16,9 +16,8 @@ "documentAccess": "dynamic-page", "editorType": [ "figma", - "dev" - ], - "capabilities": [ - "inspect" + "figjam", + "slides", + "buzz" ] } diff --git a/figma-limitless-mcp/plugin/src/main/code.ts b/figma-limitless-mcp/plugin/src/main/code.ts index 10d12e4..52d54e2 100644 --- a/figma-limitless-mcp/plugin/src/main/code.ts +++ b/figma-limitless-mcp/plugin/src/main/code.ts @@ -894,11 +894,18 @@ const EDIT_REQUEST_TYPES = new Set([ ]); const requireEditorMode = (toolName: RequestType): void => { - // Dev Mode is read-only — every figma.create*/setter throws at runtime there, - // and the resulting errors are confusing. Reject up front with a clear hint. - if (figma.editorType === "dev") { + // The plugin runs across multiple editors (figma/figjam/slides/buzz/dev), but + // design-canvas write tools only apply to the design-style surfaces. Reject up + // front with a clear, editor-specific hint instead of a confusing runtime error. + const editor = figma.editorType; + if (editor === "dev") { throw new Error( - `${toolName} requires the plugin to be opened in Figma's design editor (Dev Mode is read-only). Switch to the design editor and re-run.` + `${toolName} requires an editable surface — Dev Mode is read-only. Switch to the design editor and re-run.` + ); + } + if (editor === "figjam") { + throw new Error( + `${toolName} is a design-canvas tool and isn't available in FigJam. Use FigJam-native creation (figma.createSticky/createConnector/createSection/…) via execute_code, or run this tool in a Figma design file.` ); } }; From 67b915b12ec2e3b0e3898f856b7e1ab40aaa6dec Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:29:28 +0530 Subject: [PATCH 04/31] =?UTF-8?q?feat(limitless-plugin):=20FigJam=20author?= =?UTF-8?q?ing=20adapter=20=E2=80=94=207=20tools=20(Wave=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the FigJam surface (enabled in Wave 3) genuinely useful with native FigJam creation tools, wired through all four seams: - create_sticky (text / fill / wide / xy) - create_shape_with_text (30 flowchart shapes + text/fill/size) - create_connector (node-attached via magnets or free points; label, ELBOWED/STRAIGHT/CURVED, stroke caps) - create_section (titled grouping + size) - create_table (rows/cols + row-major cell text) - create_code_block (17 languages) - create_gif (by existing media hash; local-only, no URL import) Mechanism: - New FIGJAM_REQUEST_TYPES set + requireFigJam guard: these tools throw a clear "FigJam only" error outside FigJam, and the design-canvas guard (requireEditorMode) already blocks design tools inside FigJam — so each surface advertises the full tool list but only its valid tools execute. - FigJam text lives in TextSublayerNode; setSublayerText loads the sublayer font (Inter Medium default) before writing characters. - Dispatch serializes FigJam writes via the mutationChain; all 7 journaled. Honest scope: stamp/washi-tape/highlight have NO Plugin-API factory (user-only) and createLinkPreviewAsync needs network (blocked by our localhost-only manifest), so they're intentionally omitted. Tool count 75 -> 82. Build gate: server tsc + plugin typecheck + vite build all clean (first pass). Live in-Figma verification pending (manual). Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/plugin/src/main/code.ts | 228 +++++++++++++++++- .../server/src/orchestration.ts | 7 + figma-limitless-mcp/server/src/schema.ts | 145 +++++++++++ figma-limitless-mcp/server/src/tools.ts | 84 +++++++ 4 files changed, 463 insertions(+), 1 deletion(-) diff --git a/figma-limitless-mcp/plugin/src/main/code.ts b/figma-limitless-mcp/plugin/src/main/code.ts index 52d54e2..d3d14d4 100644 --- a/figma-limitless-mcp/plugin/src/main/code.ts +++ b/figma-limitless-mcp/plugin/src/main/code.ts @@ -65,6 +65,13 @@ type RequestType = | "get_slots" | "reset_slot" | "append_to_slot" + | "create_sticky" + | "create_shape_with_text" + | "create_connector" + | "create_section" + | "create_table" + | "create_code_block" + | "create_gif" | "dev_resources"; type ServerRequestParams = Record & { @@ -910,6 +917,44 @@ const requireEditorMode = (toolName: RequestType): void => { } }; +const FIGJAM_REQUEST_TYPES = new Set([ + "create_sticky", + "create_shape_with_text", + "create_connector", + "create_section", + "create_table", + "create_code_block", + "create_gif", +]); + +const requireFigJam = (toolName: RequestType): void => { + if (figma.editorType !== "figjam") { + throw new Error( + `${toolName} is a FigJam tool and only runs in a FigJam file (current editor: ${figma.editorType}). Open a FigJam board and re-run.` + ); + } +}; + +const solidPaintFromHex = (hex: string): SolidPaint => ({ + type: "SOLID", + color: parseHexColor(hex), +}); + +// FigJam text lives in a TextSublayerNode; setting characters requires the +// sublayer's font loaded first (Inter Medium is the FigJam default). +const setSublayerText = async ( + sublayer: TextSublayerNode, + text: string +): Promise => { + const font = sublayer.fontName; + if (typeof font === "symbol") { + await figma.loadFontAsync({ family: "Inter", style: "Medium" }); + } else { + await figma.loadFontAsync(font); + } + sublayer.characters = text; +}; + const handleRequest = async ( request: ServerRequest ): Promise => { @@ -917,6 +962,9 @@ const handleRequest = async ( if (EDIT_REQUEST_TYPES.has(request.type)) { requireEditorMode(request.type); } + if (FIGJAM_REQUEST_TYPES.has(request.type)) { + requireFigJam(request.type); + } switch (request.type) { case "get_document": return { @@ -3931,6 +3979,180 @@ const handleRequest = async ( }, }; } + case "create_sticky": { + const params = request.params ?? {}; + const sticky = figma.createSticky(); + if (params.wide === true) sticky.isWideWidth = true; + if (typeof params.fillHex === "string") { + sticky.fills = [solidPaintFromHex(params.fillHex)]; + } + if (typeof params.text === "string") { + await setSublayerText(sticky.text, params.text); + } + positionNode(sticky, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: sticky.id, nodeName: sticky.name }, + }; + } + case "create_shape_with_text": { + const params = request.params ?? {}; + const shape = figma.createShapeWithText(); + if (typeof params.shapeType === "string") { + shape.shapeType = params.shapeType as ShapeWithTextNode["shapeType"]; + } + if (typeof params.fillHex === "string") { + shape.fills = [solidPaintFromHex(params.fillHex)]; + } + if (typeof params.text === "string") { + await setSublayerText(shape.text, params.text); + } + if (typeof params.width === "number" && typeof params.height === "number") { + shape.resize(params.width, params.height); + } + positionNode(shape, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: shape.id, shapeType: shape.shapeType }, + }; + } + case "create_connector": { + const params = request.params ?? {}; + const connector = figma.createConnector(); + const makeEndpoint = ( + nodeId: unknown, + x: unknown, + y: unknown, + magnet: unknown + ): ConnectorEndpoint => { + if (typeof nodeId === "string") { + return { + endpointNodeId: nodeId, + magnet: (typeof magnet === "string" ? magnet : "AUTO") as + | "NONE" + | "AUTO" + | "TOP" + | "LEFT" + | "BOTTOM" + | "RIGHT" + | "CENTER", + }; + } + return { + position: { + x: typeof x === "number" ? x : 0, + y: typeof y === "number" ? y : 0, + }, + }; + }; + connector.connectorStart = makeEndpoint( + params.startNodeId, + params.startX, + params.startY, + params.startMagnet + ); + connector.connectorEnd = makeEndpoint( + params.endNodeId, + params.endX, + params.endY, + params.endMagnet + ); + if (typeof params.lineType === "string") { + connector.connectorLineType = params.lineType as + | "ELBOWED" + | "STRAIGHT" + | "CURVED"; + } + if (typeof params.startCap === "string") { + connector.connectorStartStrokeCap = params.startCap as ConnectorStrokeCap; + } + if (typeof params.endCap === "string") { + connector.connectorEndStrokeCap = params.endCap as ConnectorStrokeCap; + } + if (typeof params.text === "string") { + await setSublayerText(connector.text, params.text); + } + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: connector.id }, + }; + } + case "create_section": { + const params = request.params ?? {}; + const section = figma.createSection(); + if (typeof params.name === "string") section.name = params.name; + if (typeof params.width === "number" && typeof params.height === "number") { + section.resizeWithoutConstraints(params.width, params.height); + } + positionNode(section, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: section.id, nodeName: section.name }, + }; + } + case "create_table": { + const params = request.params ?? {}; + const rows = typeof params.rows === "number" ? params.rows : 2; + const columns = typeof params.columns === "number" ? params.columns : 2; + const table = figma.createTable(rows, columns); + if (Array.isArray(params.cells)) { + const cellRows = params.cells as unknown[]; + for (let r = 0; r < cellRows.length && r < table.numRows; r++) { + const row = cellRows[r]; + if (!Array.isArray(row)) continue; + for (let c = 0; c < row.length && c < table.numColumns; c++) { + const value = row[c]; + if (typeof value === "string" && value.length > 0) { + await setSublayerText(table.cellAt(r, c).text, value); + } + } + } + } + positionNode(table, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { + nodeId: table.id, + numRows: table.numRows, + numColumns: table.numColumns, + }, + }; + } + case "create_code_block": { + const params = request.params ?? {}; + if (typeof params.code !== "string") { + throw new Error("create_code_block needs code"); + } + const block = figma.createCodeBlock(); + block.code = params.code; + if (typeof params.language === "string") { + block.codeLanguage = params.language as CodeBlockNode["codeLanguage"]; + } + positionNode(block, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: block.id, codeLanguage: block.codeLanguage }, + }; + } + case "create_gif": { + const params = request.params ?? {}; + if (typeof params.hash !== "string") { + throw new Error("create_gif needs a media hash"); + } + const gif = figma.createGif(params.hash); + positionNode(gif, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: gif.id }, + }; + } case "dev_resources": { const nodeId = request.nodeIds && request.nodeIds[0]; if (!nodeId) throw new Error("nodeIds is required for dev_resources"); @@ -4128,7 +4350,11 @@ figma.ui.onmessage = async (message) => { } }; - if (EDIT_REQUEST_TYPES.has(request.type) || request.type === "execute_code") { + if ( + EDIT_REQUEST_TYPES.has(request.type) || + FIGJAM_REQUEST_TYPES.has(request.type) || + request.type === "execute_code" + ) { mutationChain = mutationChain.then(run, run); } else { void run(); diff --git a/figma-limitless-mcp/server/src/orchestration.ts b/figma-limitless-mcp/server/src/orchestration.ts index 1dfa5df..922049a 100644 --- a/figma-limitless-mcp/server/src/orchestration.ts +++ b/figma-limitless-mcp/server/src/orchestration.ts @@ -110,6 +110,13 @@ export const JOURNALED_TOOLS = new Set([ "create_slot", "reset_slot", "append_to_slot", + "create_sticky", + "create_shape_with_text", + "create_connector", + "create_section", + "create_table", + "create_code_block", + "create_gif", "dev_resources", ]); diff --git a/figma-limitless-mcp/server/src/schema.ts b/figma-limitless-mcp/server/src/schema.ts index 784e619..3e006a5 100644 --- a/figma-limitless-mcp/server/src/schema.ts +++ b/figma-limitless-mcp/server/src/schema.ts @@ -996,6 +996,130 @@ export const appendToSlotInput = z.object({ fileKey: fileKeyField, }); +// --- FigJam authoring (editorType: figjam) --- + +export const createStickyInput = z.object({ + text: z.string().optional().describe("Sticky note text"), + fillHex: createHexColorSchema().optional().describe("Background color, e.g. '#FFD966'"), + wide: z + .boolean() + .optional() + .describe("Use the wide rectangular sticky shape instead of square"), + x: z.number().optional().describe("X position on the FigJam board"), + y: z.number().optional().describe("Y position on the FigJam board"), + fileKey: fileKeyField, +}); + +export const createShapeWithTextInput = z.object({ + shapeType: z + .enum([ + "SQUARE", "ELLIPSE", "ROUNDED_RECTANGLE", "DIAMOND", "TRIANGLE_UP", + "TRIANGLE_DOWN", "PARALLELOGRAM_RIGHT", "PARALLELOGRAM_LEFT", "ENG_DATABASE", + "ENG_QUEUE", "ENG_FILE", "ENG_FOLDER", "TRAPEZOID", "PREDEFINED_PROCESS", + "SHIELD", "DOCUMENT_SINGLE", "DOCUMENT_MULTIPLE", "MANUAL_INPUT", "HEXAGON", + "CHEVRON", "PENTAGON", "OCTAGON", "STAR", "PLUS", "ARROW_LEFT", "ARROW_RIGHT", + "SUMMING_JUNCTION", "OR", "SPEECH_BUBBLE", "INTERNAL_STORAGE", + ]) + .describe("FigJam shape kind (flowchart/diagram shapes)"), + text: z.string().optional().describe("Text inside the shape"), + fillHex: createHexColorSchema().optional().describe("Fill color"), + width: z.number().min(0.01).optional().describe("Shape width"), + height: z.number().min(0.01).optional().describe("Shape height"), + x: z.number().optional().describe("X position"), + y: z.number().optional().describe("Y position"), + fileKey: fileKeyField, +}); + +export const createConnectorInput = z.object({ + startNodeId: createFigmaNodeIdSchema() + .optional() + .describe("Node to connect FROM (magnet-attached). Omit to use a free startX/startY."), + endNodeId: createFigmaNodeIdSchema() + .optional() + .describe("Node to connect TO (magnet-attached). Omit to use a free endX/endY."), + startX: z.number().optional().describe("Free start X (used when startNodeId is omitted)"), + startY: z.number().optional().describe("Free start Y"), + endX: z.number().optional().describe("Free end X (used when endNodeId is omitted)"), + endY: z.number().optional().describe("Free end Y"), + text: z.string().optional().describe("Connector label"), + lineType: z + .enum(["ELBOWED", "STRAIGHT", "CURVED"]) + .optional() + .describe("Path style (default ELBOWED)"), + startMagnet: z + .enum(["NONE", "AUTO", "TOP", "LEFT", "BOTTOM", "RIGHT", "CENTER"]) + .optional() + .describe("Attachment point on the start node (default AUTO)"), + endMagnet: z + .enum(["NONE", "AUTO", "TOP", "LEFT", "BOTTOM", "RIGHT", "CENTER"]) + .optional() + .describe("Attachment point on the end node (default AUTO)"), + startCap: z + .enum([ + "NONE", "ARROW_EQUILATERAL", "ARROW_LINES", "TRIANGLE_FILLED", + "DIAMOND_FILLED", "CIRCLE_FILLED", "ERD_ZERO_OR_ONE", "ERD_EXACTLY_ONE", + "ERD_ZERO_OR_MORE", "ERD_ONE_OR_MORE", "ERD_ONE", "ERD_MANY", + ]) + .optional() + .describe("Start stroke cap"), + endCap: z + .enum([ + "NONE", "ARROW_EQUILATERAL", "ARROW_LINES", "TRIANGLE_FILLED", + "DIAMOND_FILLED", "CIRCLE_FILLED", "ERD_ZERO_OR_ONE", "ERD_EXACTLY_ONE", + "ERD_ZERO_OR_MORE", "ERD_ONE_OR_MORE", "ERD_ONE", "ERD_MANY", + ]) + .optional() + .describe("End stroke cap (e.g. ARROW_LINES for a directed arrow)"), + fileKey: fileKeyField, +}); + +export const createSectionInput = z.object({ + name: z.string().optional().describe("Section title"), + width: z.number().min(0.01).optional().describe("Section width"), + height: z.number().min(0.01).optional().describe("Section height"), + x: z.number().optional().describe("X position"), + y: z.number().optional().describe("Y position"), + fileKey: fileKeyField, +}); + +export const createTableInput = z.object({ + rows: z.number().int().min(1).max(100).optional().describe("Number of rows (default 2)"), + columns: z.number().int().min(1).max(100).optional().describe("Number of columns (default 2)"), + cells: z + .array(z.array(z.string())) + .optional() + .describe("Row-major cell text, e.g. [['Name','Role'],['Ada','Eng']]"), + x: z.number().optional().describe("X position"), + y: z.number().optional().describe("Y position"), + fileKey: fileKeyField, +}); + +export const createCodeBlockInput = z.object({ + code: z.string().describe("Code contents"), + language: z + .enum([ + "TYPESCRIPT", "CPP", "RUBY", "CSS", "JAVASCRIPT", "HTML", "JSON", "GRAPHQL", + "PYTHON", "GO", "SQL", "SWIFT", "KOTLIN", "RUST", "BASH", "PLAINTEXT", "DART", + ]) + .optional() + .describe("Syntax-highlighting language (default PLAINTEXT)"), + x: z.number().optional().describe("X position"), + y: z.number().optional().describe("Y position"), + fileKey: fileKeyField, +}); + +export const createGifInput = z.object({ + hash: z + .string() + .min(1) + .describe( + "Media hash of a GIF already present in the document. Note: this MCP is local-only, so importing new GIFs from a URL is not supported." + ), + x: z.number().optional().describe("X position"), + y: z.number().optional().describe("Y position"), + fileKey: fileKeyField, +}); + export const devResourcesShape = z.object({ nodeId: createFigmaNodeIdSchema().describe("Target node"), action: z.enum(["get", "add", "edit", "delete"]), @@ -1646,6 +1770,20 @@ export const toolInputSchemas = { append_to_slot: appendToSlotInput, + create_sticky: createStickyInput, + + create_shape_with_text: createShapeWithTextInput, + + create_connector: createConnectorInput, + + create_section: createSectionInput, + + create_table: createTableInput, + + create_code_block: createCodeBlockInput, + + create_gif: createGifInput, + dev_resources: devResourcesInput, set_code_mapping: setCodeMappingInput, @@ -1759,6 +1897,13 @@ const rpcToArgs: Record< get_slots: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), reset_slot: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), append_to_slot: (_nodeIds, params) => ({ ...params }), + create_sticky: (_nodeIds, params) => ({ ...params }), + create_shape_with_text: (_nodeIds, params) => ({ ...params }), + create_connector: (_nodeIds, params) => ({ ...params }), + create_section: (_nodeIds, params) => ({ ...params }), + create_table: (_nodeIds, params) => ({ ...params }), + create_code_block: (_nodeIds, params) => ({ ...params }), + create_gif: (_nodeIds, params) => ({ ...params }), dev_resources: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), set_code_mapping: (_nodeIds, params) => ({ ...params }), get_code_mappings: (_nodeIds, params) => ({ ...params }), diff --git a/figma-limitless-mcp/server/src/tools.ts b/figma-limitless-mcp/server/src/tools.ts index 3f7ede4..b92c7c7 100644 --- a/figma-limitless-mcp/server/src/tools.ts +++ b/figma-limitless-mcp/server/src/tools.ts @@ -72,6 +72,13 @@ import { getSlotsInput, resetSlotInput, appendToSlotInput, + createStickyInput, + createShapeWithTextInput, + createConnectorInput, + createSectionInput, + createTableInput, + createCodeBlockInput, + createGifInput, devResourcesShape, devResourcesInput, setCodeMappingShape, @@ -1129,6 +1136,83 @@ export function registerTools( } ); + server.tool( + "create_sticky", + "FigJam: create a sticky note with optional text, background color, and wide shape. Runs only in a FigJam file.", + createStickyInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("create_sticky", undefined, params, fileKey) + ); + } + ); + + server.tool( + "create_shape_with_text", + "FigJam: create a flowchart/diagram shape (square, diamond, ellipse, arrows, ENG_* shapes, …) with optional text, fill, and size. FigJam only.", + createShapeWithTextInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("create_shape_with_text", undefined, params, fileKey) + ); + } + ); + + server.tool( + "create_connector", + "FigJam: draw a connector between two nodes (magnet-attached via startNodeId/endNodeId) or between free points, with an optional label, line type, and stroke caps. FigJam only.", + createConnectorInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("create_connector", undefined, params, fileKey) + ); + } + ); + + server.tool( + "create_section", + "FigJam: create a titled section to group board content, with optional name and size. FigJam only.", + createSectionInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("create_section", undefined, params, fileKey) + ); + } + ); + + server.tool( + "create_table", + "FigJam: create a table with the given rows/columns, optionally pre-filling cell text (row-major). FigJam only.", + createTableInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("create_table", undefined, params, fileKey) + ); + } + ); + + server.tool( + "create_code_block", + "FigJam: create a syntax-highlighted code block with the given code and language. FigJam only.", + createCodeBlockInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("create_code_block", undefined, params, fileKey) + ); + } + ); + + server.tool( + "create_gif", + "FigJam: place a GIF by its existing media hash. Note: local-only — importing new GIFs from URLs is not supported. FigJam only.", + createGifInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("create_gif", undefined, params, fileKey) + ); + } + ); + server.tool( "dev_resources", "Read/add/edit/delete dev resources (links to tickets, docs, storybook) attached to nodes — the Dev-Mode handoff surface, editable here without a Dev seat's UI.", From a88b443f1cae86e308e5a20537fc41af440829ba Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:09:17 +0530 Subject: [PATCH 05/31] =?UTF-8?q?feat(limitless-plugin):=20Figma=20Slides?= =?UTF-8?q?=20authoring=20adapter=20=E2=80=94=207=20tools=20(Wave=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Slides tools for the slides surface (enabled Wave 3), wired through all four seams and guarded by requireSlides (editorType === "slides"): - create_slide (grid row/col + optional background color) - create_slide_row (optional insert index) - set_slide_transition (23 styles, duration, 8 easing curves, on-click/ after-delay timing; only passed fields change) - set_slide_skip (isSkippedSlide) - focus_slide (currentPage.focusedSlide) - get_slide_grid (read the deck order as a 2D id/name grid) - set_slide_grid (reorder by passing the full grid back) Mechanism mirrors the FigJam adapter: SLIDES_REQUEST_TYPES + requireSlides; writes serialize via the mutationChain; the 5 content mutations (create_slide/create_slide_row/set_slide_transition/set_slide_skip/ set_slide_grid) are journaled, focus_slide + get_slide_grid are not. Honest scope: figma.viewport.slidesMode is NOT in the pinned @figma/plugin-typings 1.130, so a set-view-mode tool is intentionally omitted rather than shipped against an unverified API. Tool count 82 -> 89. Build gate: server tsc + plugin typecheck + vite build all clean (first pass). Live in-Figma verification pending (manual). Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/plugin/src/main/code.ts | 173 ++++++++++++++++++ .../server/src/orchestration.ts | 5 + figma-limitless-mcp/server/src/schema.ts | 85 +++++++++ figma-limitless-mcp/server/src/tools.ts | 84 +++++++++ 4 files changed, 347 insertions(+) diff --git a/figma-limitless-mcp/plugin/src/main/code.ts b/figma-limitless-mcp/plugin/src/main/code.ts index d3d14d4..b164083 100644 --- a/figma-limitless-mcp/plugin/src/main/code.ts +++ b/figma-limitless-mcp/plugin/src/main/code.ts @@ -72,6 +72,13 @@ type RequestType = | "create_table" | "create_code_block" | "create_gif" + | "create_slide" + | "create_slide_row" + | "set_slide_transition" + | "set_slide_skip" + | "focus_slide" + | "get_slide_grid" + | "set_slide_grid" | "dev_resources"; type ServerRequestParams = Record & { @@ -935,6 +942,24 @@ const requireFigJam = (toolName: RequestType): void => { } }; +const SLIDES_REQUEST_TYPES = new Set([ + "create_slide", + "create_slide_row", + "set_slide_transition", + "set_slide_skip", + "focus_slide", + "get_slide_grid", + "set_slide_grid", +]); + +const requireSlides = (toolName: RequestType): void => { + if (figma.editorType !== "slides") { + throw new Error( + `${toolName} is a Figma Slides tool and only runs in a Slides file (current editor: ${figma.editorType}). Open a Slides file and re-run.` + ); + } +}; + const solidPaintFromHex = (hex: string): SolidPaint => ({ type: "SOLID", color: parseHexColor(hex), @@ -965,6 +990,9 @@ const handleRequest = async ( if (FIGJAM_REQUEST_TYPES.has(request.type)) { requireFigJam(request.type); } + if (SLIDES_REQUEST_TYPES.has(request.type)) { + requireSlides(request.type); + } switch (request.type) { case "get_document": return { @@ -4153,6 +4181,150 @@ const handleRequest = async ( data: { nodeId: gif.id }, }; } + case "create_slide": { + const params = request.params ?? {}; + const row = typeof params.row === "number" ? params.row : undefined; + const col = typeof params.col === "number" ? params.col : undefined; + const slide = + row !== undefined && col !== undefined + ? figma.createSlide(row, col) + : row !== undefined + ? figma.createSlide(row) + : figma.createSlide(); + if (typeof params.backgroundHex === "string") { + slide.fills = [solidPaintFromHex(params.backgroundHex)]; + } + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: slide.id, nodeName: slide.name }, + }; + } + case "create_slide_row": { + const params = request.params ?? {}; + const row = typeof params.row === "number" ? params.row : undefined; + const slideRow = + row !== undefined ? figma.createSlideRow(row) : figma.createSlideRow(); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: slideRow.id }, + }; + } + case "set_slide_transition": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string") { + throw new Error("set_slide_transition needs a slide nodeId"); + } + const node = await figma.getNodeByIdAsync(params.nodeId); + if (!node || node.type !== "SLIDE") { + throw new Error( + `set_slide_transition needs a SLIDE node, got ${node?.type ?? "missing"}` + ); + } + const current = node.getSlideTransition(); + const transition: SlideTransition = { + style: (typeof params.style === "string" + ? params.style + : current.style) as SlideTransition["style"], + duration: + typeof params.duration === "number" ? params.duration : current.duration, + curve: (typeof params.curve === "string" + ? params.curve + : current.curve) as SlideTransition["curve"], + timing: { + type: (typeof params.timingType === "string" + ? params.timingType + : current.timing.type) as "ON_CLICK" | "AFTER_DELAY", + delay: + typeof params.delay === "number" ? params.delay : current.timing.delay, + }, + }; + node.setSlideTransition(transition); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, transition }, + }; + } + case "set_slide_skip": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string") { + throw new Error("set_slide_skip needs a slide nodeId"); + } + const node = await figma.getNodeByIdAsync(params.nodeId); + if (!node || node.type !== "SLIDE") { + throw new Error( + `set_slide_skip needs a SLIDE node, got ${node?.type ?? "missing"}` + ); + } + node.isSkippedSlide = params.skip === true; + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, isSkippedSlide: node.isSkippedSlide }, + }; + } + case "focus_slide": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string") { + throw new Error("focus_slide needs a slide nodeId"); + } + const node = await figma.getNodeByIdAsync(params.nodeId); + if (!node || node.type !== "SLIDE") { + throw new Error( + `focus_slide needs a SLIDE node, got ${node?.type ?? "missing"}` + ); + } + figma.currentPage.focusedSlide = node; + return { + type: request.type, + requestId: request.requestId, + data: { focusedSlideId: node.id }, + }; + } + case "get_slide_grid": { + const grid = figma.getSlideGrid(); + const idGrid = grid.map((rowArr) => + rowArr.map((s) => ({ id: s.id, name: s.name })) + ); + return { + type: request.type, + requestId: request.requestId, + data: { grid: idGrid, rows: idGrid.length }, + }; + } + case "set_slide_grid": { + const params = request.params ?? {}; + if (!Array.isArray(params.grid)) { + throw new Error("set_slide_grid needs grid (a 2D array of slide ids)"); + } + const inputRows = params.grid as unknown[]; + const slideGrid: SlideNode[][] = []; + for (const row of inputRows) { + if (!Array.isArray(row)) { + throw new Error("set_slide_grid: grid must be a 2D array"); + } + const slideRow: SlideNode[] = []; + for (const id of row) { + if (typeof id !== "string") { + throw new Error("set_slide_grid: each cell must be a slide id string"); + } + const n = await figma.getNodeByIdAsync(id); + if (!n || n.type !== "SLIDE") { + throw new Error(`set_slide_grid: not a SLIDE node: ${id}`); + } + slideRow.push(n); + } + slideGrid.push(slideRow); + } + figma.setSlideGrid(slideGrid); + return { + type: request.type, + requestId: request.requestId, + data: { rows: slideGrid.length }, + }; + } case "dev_resources": { const nodeId = request.nodeIds && request.nodeIds[0]; if (!nodeId) throw new Error("nodeIds is required for dev_resources"); @@ -4353,6 +4525,7 @@ figma.ui.onmessage = async (message) => { if ( EDIT_REQUEST_TYPES.has(request.type) || FIGJAM_REQUEST_TYPES.has(request.type) || + SLIDES_REQUEST_TYPES.has(request.type) || request.type === "execute_code" ) { mutationChain = mutationChain.then(run, run); diff --git a/figma-limitless-mcp/server/src/orchestration.ts b/figma-limitless-mcp/server/src/orchestration.ts index 922049a..285e308 100644 --- a/figma-limitless-mcp/server/src/orchestration.ts +++ b/figma-limitless-mcp/server/src/orchestration.ts @@ -117,6 +117,11 @@ export const JOURNALED_TOOLS = new Set([ "create_table", "create_code_block", "create_gif", + "create_slide", + "create_slide_row", + "set_slide_transition", + "set_slide_skip", + "set_slide_grid", "dev_resources", ]); diff --git a/figma-limitless-mcp/server/src/schema.ts b/figma-limitless-mcp/server/src/schema.ts index 3e006a5..e98b883 100644 --- a/figma-limitless-mcp/server/src/schema.ts +++ b/figma-limitless-mcp/server/src/schema.ts @@ -1120,6 +1120,70 @@ export const createGifInput = z.object({ fileKey: fileKeyField, }); +// --- Figma Slides authoring (editorType: slides) --- + +export const createSlideInput = z.object({ + row: z.number().int().min(0).optional().describe("Grid row to place the new slide"), + col: z.number().int().min(0).optional().describe("Grid column (used with row)"), + backgroundHex: createHexColorSchema().optional().describe("Slide background color"), + fileKey: fileKeyField, +}); + +export const createSlideRowInput = z.object({ + row: z.number().int().min(0).optional().describe("Index at which to insert the new slide row"), + fileKey: fileKeyField, +}); + +export const setSlideTransitionInput = z.object({ + nodeId: createFigmaNodeIdSchema().describe("The SLIDE node"), + style: z + .enum([ + "NONE", "DISSOLVE", "SLIDE_FROM_LEFT", "SLIDE_FROM_RIGHT", "SLIDE_FROM_BOTTOM", + "SLIDE_FROM_TOP", "PUSH_FROM_LEFT", "PUSH_FROM_RIGHT", "PUSH_FROM_BOTTOM", + "PUSH_FROM_TOP", "MOVE_FROM_LEFT", "MOVE_FROM_RIGHT", "MOVE_FROM_TOP", + "MOVE_FROM_BOTTOM", "SLIDE_OUT_TO_LEFT", "SLIDE_OUT_TO_RIGHT", "SLIDE_OUT_TO_TOP", + "SLIDE_OUT_TO_BOTTOM", "MOVE_OUT_TO_LEFT", "MOVE_OUT_TO_RIGHT", "MOVE_OUT_TO_TOP", + "MOVE_OUT_TO_BOTTOM", "SMART_ANIMATE", + ]) + .optional() + .describe("Transition style (unchanged if omitted)"), + duration: z.number().min(0).optional().describe("Duration in seconds"), + curve: z + .enum(["EASE_IN", "EASE_OUT", "EASE_IN_AND_OUT", "LINEAR", "GENTLE", "QUICK", "BOUNCY", "SLOW"]) + .optional() + .describe("Easing curve"), + timingType: z + .enum(["ON_CLICK", "AFTER_DELAY"]) + .optional() + .describe("Advance on click or automatically after a delay"), + delay: z.number().min(0).optional().describe("Delay in seconds (for AFTER_DELAY)"), + fileKey: fileKeyField, +}); + +export const setSlideSkipInput = z.object({ + nodeId: createFigmaNodeIdSchema().describe("The SLIDE node"), + skip: z.boolean().describe("Whether to skip this slide during presentation"), + fileKey: fileKeyField, +}); + +export const focusSlideInput = z.object({ + nodeId: createFigmaNodeIdSchema().describe("The SLIDE node to focus in the editor"), + fileKey: fileKeyField, +}); + +export const getSlideGridInput = z.object({ + fileKey: fileKeyField, +}); + +export const setSlideGridInput = z.object({ + grid: z + .array(z.array(createFigmaNodeIdSchema())) + .describe( + "2D array of slide ids in the new row/column order. IMPORTANT: must contain EVERY slide currently in the grid — call get_slide_grid first, reorder, and pass all of them back." + ), + fileKey: fileKeyField, +}); + export const devResourcesShape = z.object({ nodeId: createFigmaNodeIdSchema().describe("Target node"), action: z.enum(["get", "add", "edit", "delete"]), @@ -1784,6 +1848,20 @@ export const toolInputSchemas = { create_gif: createGifInput, + create_slide: createSlideInput, + + create_slide_row: createSlideRowInput, + + set_slide_transition: setSlideTransitionInput, + + set_slide_skip: setSlideSkipInput, + + focus_slide: focusSlideInput, + + get_slide_grid: getSlideGridInput, + + set_slide_grid: setSlideGridInput, + dev_resources: devResourcesInput, set_code_mapping: setCodeMappingInput, @@ -1904,6 +1982,13 @@ const rpcToArgs: Record< create_table: (_nodeIds, params) => ({ ...params }), create_code_block: (_nodeIds, params) => ({ ...params }), create_gif: (_nodeIds, params) => ({ ...params }), + create_slide: (_nodeIds, params) => ({ ...params }), + create_slide_row: (_nodeIds, params) => ({ ...params }), + set_slide_transition: (_nodeIds, params) => ({ ...params }), + set_slide_skip: (_nodeIds, params) => ({ ...params }), + focus_slide: (_nodeIds, params) => ({ ...params }), + get_slide_grid: (_nodeIds, params) => ({ ...params }), + set_slide_grid: (_nodeIds, params) => ({ ...params }), dev_resources: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), set_code_mapping: (_nodeIds, params) => ({ ...params }), get_code_mappings: (_nodeIds, params) => ({ ...params }), diff --git a/figma-limitless-mcp/server/src/tools.ts b/figma-limitless-mcp/server/src/tools.ts index b92c7c7..6e0b4c1 100644 --- a/figma-limitless-mcp/server/src/tools.ts +++ b/figma-limitless-mcp/server/src/tools.ts @@ -79,6 +79,13 @@ import { createTableInput, createCodeBlockInput, createGifInput, + createSlideInput, + createSlideRowInput, + setSlideTransitionInput, + setSlideSkipInput, + focusSlideInput, + getSlideGridInput, + setSlideGridInput, devResourcesShape, devResourcesInput, setCodeMappingShape, @@ -1213,6 +1220,83 @@ export function registerTools( } ); + server.tool( + "create_slide", + "Figma Slides: create a new 1920×1080 slide (optionally at a grid row/col) with an optional background color. Slides only.", + createSlideInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("create_slide", undefined, params, fileKey) + ); + } + ); + + server.tool( + "create_slide_row", + "Figma Slides: create a new slide row (optionally at an index). Slides only.", + createSlideRowInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("create_slide_row", undefined, params, fileKey) + ); + } + ); + + server.tool( + "set_slide_transition", + "Figma Slides: set a slide's transition — style, duration, easing curve, and on-click/after-delay timing. Only the fields you pass change. Slides only.", + setSlideTransitionInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("set_slide_transition", undefined, params, fileKey) + ); + } + ); + + server.tool( + "set_slide_skip", + "Figma Slides: skip or unskip a slide during presentation. Slides only.", + setSlideSkipInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("set_slide_skip", undefined, params, fileKey) + ); + } + ); + + server.tool( + "focus_slide", + "Figma Slides: focus a slide in the editor (sets currentPage.focusedSlide). Slides only.", + focusSlideInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("focus_slide", undefined, params, fileKey) + ); + } + ); + + server.tool( + "get_slide_grid", + "Figma Slides: read the slide grid as a 2D array of slide ids/names (row-major presentation order). Read-only. Slides only.", + getSlideGridInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("get_slide_grid", undefined, params, fileKey) + ); + } + ); + + server.tool( + "set_slide_grid", + "Figma Slides: reorder the deck by passing the full 2D grid of slide ids in the desired order. Must include EVERY current slide (get_slide_grid first). Slides only.", + setSlideGridInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("set_slide_grid", undefined, params, fileKey) + ); + } + ); + server.tool( "dev_resources", "Read/add/edit/delete dev resources (links to tickets, docs, storybook) attached to nodes — the Dev-Mode handoff surface, editable here without a Dev seat's UI.", From 3a03d976b6029432a2ef336bab3a9df94ef85a1b Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:19:43 +0530 Subject: [PATCH 06/31] =?UTF-8?q?feat(limitless-plugin):=20Figma=20Buzz=20?= =?UTF-8?q?authoring=20adapter=20=E2=80=94=205=20tools=20(Wave=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the multi-surface expansion with native figma.buzz.* tools, guarded by requireBuzz (editorType === "buzz"): - create_buzz_frame (canvas-grid frame + platform asset type + bg) - set_buzz_asset_type (43 platform types: LINKEDIN_POST, INSTA_STORY, …) - get_buzz_content (read dynamic text + media fields; read-only) - set_buzz_text (positional bulk-fill of text fields — the core data-driven marketing-at-scale workflow) - buzz_smart_resize (layout-aware reflow across platform sizes) Mechanism mirrors the FigJam/Slides adapters: BUZZ_REQUEST_TYPES + requireBuzz; writes serialize via the mutationChain; the 4 mutations are journaled, get_buzz_content is not. set_buzz_text uses BuzzTextField setValueAsync. Honest scope: setting media (BuzzMediaField.setMediaAsync) needs an ImagePaint/VideoPaint hash, which requires a media pipeline unavailable in the local-only sandbox, so only media READ is exposed. All 4 authoring surfaces (figma+draw, figjam, slides, buzz) now have native tool adapters. Tool count 89 -> 94. Build gate: server tsc + plugin typecheck + vite build all clean (first pass). Live verification pending. Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/plugin/src/main/code.ts | 136 ++++++++++++++++++ .../server/src/orchestration.ts | 4 + figma-limitless-mcp/server/src/schema.ts | 68 +++++++++ figma-limitless-mcp/server/src/tools.ts | 60 ++++++++ 4 files changed, 268 insertions(+) diff --git a/figma-limitless-mcp/plugin/src/main/code.ts b/figma-limitless-mcp/plugin/src/main/code.ts index b164083..895c7d9 100644 --- a/figma-limitless-mcp/plugin/src/main/code.ts +++ b/figma-limitless-mcp/plugin/src/main/code.ts @@ -79,6 +79,11 @@ type RequestType = | "focus_slide" | "get_slide_grid" | "set_slide_grid" + | "create_buzz_frame" + | "set_buzz_asset_type" + | "get_buzz_content" + | "set_buzz_text" + | "buzz_smart_resize" | "dev_resources"; type ServerRequestParams = Record & { @@ -960,6 +965,22 @@ const requireSlides = (toolName: RequestType): void => { } }; +const BUZZ_REQUEST_TYPES = new Set([ + "create_buzz_frame", + "set_buzz_asset_type", + "get_buzz_content", + "set_buzz_text", + "buzz_smart_resize", +]); + +const requireBuzz = (toolName: RequestType): void => { + if (figma.editorType !== "buzz") { + throw new Error( + `${toolName} is a Figma Buzz tool and only runs in a Buzz file (current editor: ${figma.editorType}). Open a Buzz file and re-run.` + ); + } +}; + const solidPaintFromHex = (hex: string): SolidPaint => ({ type: "SOLID", color: parseHexColor(hex), @@ -993,6 +1014,9 @@ const handleRequest = async ( if (SLIDES_REQUEST_TYPES.has(request.type)) { requireSlides(request.type); } + if (BUZZ_REQUEST_TYPES.has(request.type)) { + requireBuzz(request.type); + } switch (request.type) { case "get_document": return { @@ -4325,6 +4349,117 @@ const handleRequest = async ( data: { rows: slideGrid.length }, }; } + case "create_buzz_frame": { + const params = request.params ?? {}; + const row = typeof params.row === "number" ? params.row : undefined; + const col = typeof params.col === "number" ? params.col : undefined; + const frame = + row !== undefined && col !== undefined + ? figma.buzz.createFrame(row, col) + : row !== undefined + ? figma.buzz.createFrame(row) + : figma.buzz.createFrame(); + if (typeof params.assetType === "string") { + figma.buzz.setBuzzAssetTypeForNode(frame, params.assetType as BuzzAssetType); + } + if (typeof params.backgroundHex === "string") { + frame.fills = [solidPaintFromHex(params.backgroundHex)]; + } + return { + type: request.type, + requestId: request.requestId, + data: { + nodeId: frame.id, + nodeName: frame.name, + assetType: figma.buzz.getBuzzAssetTypeForNode(frame), + }, + }; + } + case "set_buzz_asset_type": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string" || typeof params.assetType !== "string") { + throw new Error("set_buzz_asset_type needs nodeId and assetType"); + } + const node = await getSceneNodeById(params.nodeId); + figma.buzz.setBuzzAssetTypeForNode(node, params.assetType as BuzzAssetType); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, assetType: params.assetType }, + }; + } + case "get_buzz_content": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string") { + throw new Error("get_buzz_content needs nodeId"); + } + const node = await getSceneNodeById(params.nodeId); + const textFields = figma.buzz.getTextContent(node).map((f, i) => ({ + index: i, + value: f.value, + nodeId: f.node ? f.node.id : null, + })); + const mediaFields = figma.buzz.getMediaContent(node).map((f, i) => ({ + index: i, + type: f.type, + hash: f.hash, + nodeId: f.node ? f.node.id : null, + })); + return { + type: request.type, + requestId: request.requestId, + data: { + nodeId: node.id, + assetType: figma.buzz.getBuzzAssetTypeForNode(node), + textFields, + mediaFields, + }, + }; + } + case "set_buzz_text": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string") { + throw new Error("set_buzz_text needs nodeId"); + } + if (!Array.isArray(params.values)) { + throw new Error( + "set_buzz_text needs values (array of strings applied positionally to the asset's text fields)" + ); + } + const node = await getSceneNodeById(params.nodeId); + const fields = figma.buzz.getTextContent(node); + const values = params.values as unknown[]; + let updated = 0; + for (let i = 0; i < values.length && i < fields.length; i++) { + const value = values[i]; + if (typeof value === "string") { + await fields[i].setValueAsync(value); + updated++; + } + } + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, updated, totalFields: fields.length }, + }; + } + case "buzz_smart_resize": { + const params = request.params ?? {}; + if ( + typeof params.nodeId !== "string" || + typeof params.width !== "number" || + typeof params.height !== "number" + ) { + throw new Error("buzz_smart_resize needs nodeId, width, and height"); + } + const node = await getSceneNodeById(params.nodeId); + figma.buzz.smartResize(node, params.width, params.height); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, width: params.width, height: params.height }, + }; + } case "dev_resources": { const nodeId = request.nodeIds && request.nodeIds[0]; if (!nodeId) throw new Error("nodeIds is required for dev_resources"); @@ -4526,6 +4661,7 @@ figma.ui.onmessage = async (message) => { EDIT_REQUEST_TYPES.has(request.type) || FIGJAM_REQUEST_TYPES.has(request.type) || SLIDES_REQUEST_TYPES.has(request.type) || + BUZZ_REQUEST_TYPES.has(request.type) || request.type === "execute_code" ) { mutationChain = mutationChain.then(run, run); diff --git a/figma-limitless-mcp/server/src/orchestration.ts b/figma-limitless-mcp/server/src/orchestration.ts index 285e308..c9e2b08 100644 --- a/figma-limitless-mcp/server/src/orchestration.ts +++ b/figma-limitless-mcp/server/src/orchestration.ts @@ -122,6 +122,10 @@ export const JOURNALED_TOOLS = new Set([ "set_slide_transition", "set_slide_skip", "set_slide_grid", + "create_buzz_frame", + "set_buzz_asset_type", + "set_buzz_text", + "buzz_smart_resize", "dev_resources", ]); diff --git a/figma-limitless-mcp/server/src/schema.ts b/figma-limitless-mcp/server/src/schema.ts index e98b883..196d7e8 100644 --- a/figma-limitless-mcp/server/src/schema.ts +++ b/figma-limitless-mcp/server/src/schema.ts @@ -1184,6 +1184,59 @@ export const setSlideGridInput = z.object({ fileKey: fileKeyField, }); +// --- Figma Buzz authoring (editorType: buzz) --- + +const buzzAssetType = z.enum([ + "CUSTOM", "TWITTER_POST", "LINKEDIN_POST", "INSTA_POST_SQUARE", "INSTA_POST_PORTRAIT", + "INSTA_STORY", "INSTA_AD", "FACEBOOK_POST", "FACEBOOK_COVER_PHOTO", "FACEBOOK_EVENT_COVER", + "FACEBOOK_AD_PORTRAIT", "FACEBOOK_AD_SQUARE", "PINTEREST_AD_PIN", "TWITTER_BANNER", + "LINKEDIN_POST_SQUARE", "LINKEDIN_POST_PORTRAIT", "LINKEDIN_POST_LANDSCAPE", + "LINKEDIN_PROFILE_BANNER", "LINKEDIN_ARTICLE_BANNER", "LINKEDIN_AD_LANDSCAPE", + "LINKEDIN_AD_SQUARE", "LINKEDIN_AD_VERTICAL", "YOUTUBE_THUMBNAIL", "YOUTUBE_BANNER", + "YOUTUBE_AD", "TWITCH_BANNER", "GOOGLE_LEADERBOARD_AD", "GOOGLE_LARGE_AD", "GOOGLE_MED_AD", + "GOOGLE_MOBILE_BANNER_AD", "GOOGLE_SKYSCRAPER_AD", "CARD_HORIZONTAL", "CARD_VERTICAL", + "PRINT_US_LETTER", "POSTER", "BANNER_STANDARD", "BANNER_WIDE", "BANNER_ULTRAWIDE", + "NAME_TAG_PORTRAIT", "NAME_TAG_LANDSCAPE", "INSTA_REEL_COVER", "ZOOM_BACKGROUND", +]); + +export const createBuzzFrameInput = z.object({ + row: z.number().int().min(0).optional().describe("Canvas-grid row (appends to the end if omitted)"), + col: z.number().int().min(0).optional().describe("Canvas-grid column (used with row)"), + assetType: buzzAssetType + .optional() + .describe("Platform asset type/size to apply (e.g. LINKEDIN_POST, INSTA_STORY)"), + backgroundHex: createHexColorSchema().optional().describe("Frame background color"), + fileKey: fileKeyField, +}); + +export const setBuzzAssetTypeInput = z.object({ + nodeId: createFigmaNodeIdSchema().describe("The Buzz asset node"), + assetType: buzzAssetType.describe("Platform asset type/size to assign"), + fileKey: fileKeyField, +}); + +export const getBuzzContentInput = z.object({ + nodeId: createFigmaNodeIdSchema().describe("The Buzz asset node to read text + media fields from"), + fileKey: fileKeyField, +}); + +export const setBuzzTextInput = z.object({ + nodeId: createFigmaNodeIdSchema().describe("The Buzz asset node"), + values: z + .array(z.string()) + .describe( + "Text values applied positionally to the asset's text fields (values[i] -> field i). Read current fields with get_buzz_content first." + ), + fileKey: fileKeyField, +}); + +export const buzzSmartResizeInput = z.object({ + nodeId: createFigmaNodeIdSchema().describe("The Buzz node to resize"), + width: z.number().min(1).describe("Target width in pixels"), + height: z.number().min(1).describe("Target height in pixels"), + fileKey: fileKeyField, +}); + export const devResourcesShape = z.object({ nodeId: createFigmaNodeIdSchema().describe("Target node"), action: z.enum(["get", "add", "edit", "delete"]), @@ -1862,6 +1915,16 @@ export const toolInputSchemas = { set_slide_grid: setSlideGridInput, + create_buzz_frame: createBuzzFrameInput, + + set_buzz_asset_type: setBuzzAssetTypeInput, + + get_buzz_content: getBuzzContentInput, + + set_buzz_text: setBuzzTextInput, + + buzz_smart_resize: buzzSmartResizeInput, + dev_resources: devResourcesInput, set_code_mapping: setCodeMappingInput, @@ -1989,6 +2052,11 @@ const rpcToArgs: Record< focus_slide: (_nodeIds, params) => ({ ...params }), get_slide_grid: (_nodeIds, params) => ({ ...params }), set_slide_grid: (_nodeIds, params) => ({ ...params }), + create_buzz_frame: (_nodeIds, params) => ({ ...params }), + set_buzz_asset_type: (_nodeIds, params) => ({ ...params }), + get_buzz_content: (_nodeIds, params) => ({ ...params }), + set_buzz_text: (_nodeIds, params) => ({ ...params }), + buzz_smart_resize: (_nodeIds, params) => ({ ...params }), dev_resources: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), set_code_mapping: (_nodeIds, params) => ({ ...params }), get_code_mappings: (_nodeIds, params) => ({ ...params }), diff --git a/figma-limitless-mcp/server/src/tools.ts b/figma-limitless-mcp/server/src/tools.ts index 6e0b4c1..6ad0f20 100644 --- a/figma-limitless-mcp/server/src/tools.ts +++ b/figma-limitless-mcp/server/src/tools.ts @@ -86,6 +86,11 @@ import { focusSlideInput, getSlideGridInput, setSlideGridInput, + createBuzzFrameInput, + setBuzzAssetTypeInput, + getBuzzContentInput, + setBuzzTextInput, + buzzSmartResizeInput, devResourcesShape, devResourcesInput, setCodeMappingShape, @@ -1297,6 +1302,61 @@ export function registerTools( } ); + server.tool( + "create_buzz_frame", + "Figma Buzz: create a marketing-asset frame on the canvas grid, optionally setting its platform asset type (LINKEDIN_POST, INSTA_STORY, …) and background. Buzz only.", + createBuzzFrameInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("create_buzz_frame", undefined, params, fileKey) + ); + } + ); + + server.tool( + "set_buzz_asset_type", + "Figma Buzz: set a node's platform asset type/size (drives Buzz's per-platform dimensions). Buzz only.", + setBuzzAssetTypeInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("set_buzz_asset_type", undefined, params, fileKey) + ); + } + ); + + server.tool( + "get_buzz_content", + "Figma Buzz: read a Buzz asset's dynamic text and media fields (index, value/type/hash, backing node id) plus its asset type. Read-only. Buzz only.", + getBuzzContentInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("get_buzz_content", undefined, params, fileKey) + ); + } + ); + + server.tool( + "set_buzz_text", + "Figma Buzz: fill an asset's text fields from an array applied positionally (values[i] -> field i) — the core data-driven, bulk-content workflow. Read fields first with get_buzz_content. Buzz only.", + setBuzzTextInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("set_buzz_text", undefined, params, fileKey) + ); + } + ); + + server.tool( + "buzz_smart_resize", + "Figma Buzz: intelligently resize a node to target dimensions while preserving layout/aspect (for reflowing an asset across platform sizes). Buzz only.", + buzzSmartResizeInput.shape, + async ({ fileKey, ...params }): Promise => { + return renderResponse(() => + node.sendWithParams("buzz_smart_resize", undefined, params, fileKey) + ); + } + ); + server.tool( "dev_resources", "Read/add/edit/delete dev resources (links to tickets, docs, storybook) attached to nodes — the Dev-Mode handoff surface, editable here without a Dev seat's UI.", From 0d13da08b17c7e13a7aea890c99a458223e6c56a Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:33:22 +0530 Subject: [PATCH 07/31] fix(limitless-plugin): move Dev-Mode companion to dev-mode/manifest.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Figma's "Import from manifest" only recognizes a file named exactly manifest.json, so the top-level manifest.dev.json was never selectable in the picker. Move it to plugin/dev-mode/manifest.json (a real manifest.json) pointing at the shared build via ../dist/code.js + ../dist/index.html — no duplicated bundle. Import plugin/manifest.json for the 4 authoring surfaces and plugin/dev-mode/manifest.json for the Dev-Mode companion. Co-Authored-By: Claude Opus 4.8 --- .../plugin/{manifest.dev.json => dev-mode/manifest.json} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename figma-limitless-mcp/plugin/{manifest.dev.json => dev-mode/manifest.json} (88%) diff --git a/figma-limitless-mcp/plugin/manifest.dev.json b/figma-limitless-mcp/plugin/dev-mode/manifest.json similarity index 88% rename from figma-limitless-mcp/plugin/manifest.dev.json rename to figma-limitless-mcp/plugin/dev-mode/manifest.json index 346be36..afe52c8 100644 --- a/figma-limitless-mcp/plugin/manifest.dev.json +++ b/figma-limitless-mcp/plugin/dev-mode/manifest.json @@ -2,8 +2,8 @@ "name": "Figma Limitless MCP (Dev Mode)", "id": "figma-limitless-mcp-dev", "api": "1.0.0", - "main": "dist/code.js", - "ui": "dist/index.html", + "main": "../dist/code.js", + "ui": "../dist/index.html", "permissions": [ "teamlibrary" ], From 14692aaa18f8143620bd5c85f6f78d643b022dff Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:41:00 +0530 Subject: [PATCH 08/31] fix(limitless-plugin): make Dev-Mode companion self-contained (Figma path rule) Figma rejects a manifest whose "main"/"ui" points at a parent dir: Manifest "main" file must be located in same directory, or a subdirectory, as manifest So ../dist/code.js failed to load. Fix: dev-mode/manifest.json now references code.js + index.html in its OWN directory, and a post-build step (plugin/scripts/sync-dev-mode.mjs, wired into `pnpm build`) copies the built bundle from dist/ into dev-mode/ on every build so the two stay in lockstep. The copies are gitignored; only dev-mode/manifest.json is tracked. Import plugin/dev-mode/manifest.json for the Dev-Mode companion; it shares the exact same build as plugin/manifest.json. Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/.gitignore | 5 +++++ .../plugin/dev-mode/manifest.json | 4 ++-- figma-limitless-mcp/plugin/package.json | 2 +- .../plugin/scripts/sync-dev-mode.mjs | 21 +++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 figma-limitless-mcp/plugin/scripts/sync-dev-mode.mjs diff --git a/figma-limitless-mcp/.gitignore b/figma-limitless-mcp/.gitignore index 048e21e..8e2110d 100644 --- a/figma-limitless-mcp/.gitignore +++ b/figma-limitless-mcp/.gitignore @@ -176,6 +176,11 @@ binaries/ # Server build output server/dist/ +# Dev-Mode companion: copies of the built bundle (kept in sync by +# plugin/scripts/sync-dev-mode.mjs). The manifest.json itself IS tracked. +plugin/dev-mode/code.js +plugin/dev-mode/index.html + # Editor/IDE .idea/ .vscode/ diff --git a/figma-limitless-mcp/plugin/dev-mode/manifest.json b/figma-limitless-mcp/plugin/dev-mode/manifest.json index afe52c8..5171d31 100644 --- a/figma-limitless-mcp/plugin/dev-mode/manifest.json +++ b/figma-limitless-mcp/plugin/dev-mode/manifest.json @@ -2,8 +2,8 @@ "name": "Figma Limitless MCP (Dev Mode)", "id": "figma-limitless-mcp-dev", "api": "1.0.0", - "main": "../dist/code.js", - "ui": "../dist/index.html", + "main": "code.js", + "ui": "index.html", "permissions": [ "teamlibrary" ], diff --git a/figma-limitless-mcp/plugin/package.json b/figma-limitless-mcp/plugin/package.json index 6ec1bd5..7d64c99 100644 --- a/figma-limitless-mcp/plugin/package.json +++ b/figma-limitless-mcp/plugin/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "concurrently \"vite build --watch\" \"vite build --watch -c vite.config.main.ts\"", - "build": "vite build && vite build -c vite.config.main.ts", + "build": "vite build && vite build -c vite.config.main.ts && node scripts/sync-dev-mode.mjs", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/figma-limitless-mcp/plugin/scripts/sync-dev-mode.mjs b/figma-limitless-mcp/plugin/scripts/sync-dev-mode.mjs new file mode 100644 index 0000000..9f1bdbb --- /dev/null +++ b/figma-limitless-mcp/plugin/scripts/sync-dev-mode.mjs @@ -0,0 +1,21 @@ +// Copies the built plugin bundle into dev-mode/ so the Dev-Mode companion +// manifest can reference its code/ui in the SAME directory — Figma forbids a +// manifest's "main"/"ui" from pointing at a parent dir (../dist). The two +// manifests (plugin/manifest.json + plugin/dev-mode/manifest.json) share one +// codebase; this keeps dev-mode/ in lockstep with dist/ on every build. +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pluginDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const devDir = join(pluginDir, "dev-mode"); +mkdirSync(devDir, { recursive: true }); + +for (const [from, to] of [ + ["dist/code.js", "code.js"], + ["dist/index.html", "index.html"], +]) { + copyFileSync(join(pluginDir, from), join(devDir, to)); +} + +console.log("synced dist/ -> dev-mode/ (code.js, index.html)"); From 6115ba3a6804f775d91607331b6a3b0ecde96293 Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:22:53 +0530 Subject: [PATCH 09/31] refactor(limitless-plugin): make canvas + Dev Mode the flagship manifest Reprioritized per real usage: most people work in the Figma canvas and use Dev Mode for handoff, so that pairing is now the MAIN plugin; the niche surfaces move to one optional companion. - plugin/manifest.json -> editorType ["figma","dev"] + capabilities ["inspect"] (flagship: canvas + Dev Mode) - plugin/extras/manifest.json -> editorType ["figjam","slides","buzz"] (one optional companion; self-contained, synced from dist/ by scripts/sync-extras.mjs) Replaces the previous dev-mode companion. ["figjam","dev"] remains the only illegal combo, and neither manifest contains it. Import plugin/manifest.json for canvas + Dev Mode; import plugin/extras/manifest.json when working in FigJam / Slides / Buzz. Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/.gitignore | 8 +++---- .../plugin/{dev-mode => extras}/manifest.json | 12 +++++----- figma-limitless-mcp/plugin/manifest.json | 7 +++--- figma-limitless-mcp/plugin/package.json | 2 +- .../plugin/scripts/sync-dev-mode.mjs | 21 ------------------ .../plugin/scripts/sync-extras.mjs | 22 +++++++++++++++++++ 6 files changed, 36 insertions(+), 36 deletions(-) rename figma-limitless-mcp/plugin/{dev-mode => extras}/manifest.json (71%) delete mode 100644 figma-limitless-mcp/plugin/scripts/sync-dev-mode.mjs create mode 100644 figma-limitless-mcp/plugin/scripts/sync-extras.mjs diff --git a/figma-limitless-mcp/.gitignore b/figma-limitless-mcp/.gitignore index 8e2110d..c6417ff 100644 --- a/figma-limitless-mcp/.gitignore +++ b/figma-limitless-mcp/.gitignore @@ -176,10 +176,10 @@ binaries/ # Server build output server/dist/ -# Dev-Mode companion: copies of the built bundle (kept in sync by -# plugin/scripts/sync-dev-mode.mjs). The manifest.json itself IS tracked. -plugin/dev-mode/code.js -plugin/dev-mode/index.html +# Extras companion (FigJam/Slides/Buzz): copies of the built bundle (kept in +# sync by plugin/scripts/sync-extras.mjs). The manifest.json itself IS tracked. +plugin/extras/code.js +plugin/extras/index.html # Editor/IDE .idea/ diff --git a/figma-limitless-mcp/plugin/dev-mode/manifest.json b/figma-limitless-mcp/plugin/extras/manifest.json similarity index 71% rename from figma-limitless-mcp/plugin/dev-mode/manifest.json rename to figma-limitless-mcp/plugin/extras/manifest.json index 5171d31..547d733 100644 --- a/figma-limitless-mcp/plugin/dev-mode/manifest.json +++ b/figma-limitless-mcp/plugin/extras/manifest.json @@ -1,6 +1,6 @@ { - "name": "Figma Limitless MCP (Dev Mode)", - "id": "figma-limitless-mcp-dev", + "name": "Figma Limitless MCP — Extras (FigJam / Slides / Buzz)", + "id": "figma-limitless-mcp-extras", "api": "1.0.0", "main": "code.js", "ui": "index.html", @@ -15,10 +15,8 @@ }, "documentAccess": "dynamic-page", "editorType": [ - "figma", - "dev" - ], - "capabilities": [ - "inspect" + "figjam", + "slides", + "buzz" ] } diff --git a/figma-limitless-mcp/plugin/manifest.json b/figma-limitless-mcp/plugin/manifest.json index 3a1cee8..5c8782f 100644 --- a/figma-limitless-mcp/plugin/manifest.json +++ b/figma-limitless-mcp/plugin/manifest.json @@ -16,8 +16,9 @@ "documentAccess": "dynamic-page", "editorType": [ "figma", - "figjam", - "slides", - "buzz" + "dev" + ], + "capabilities": [ + "inspect" ] } diff --git a/figma-limitless-mcp/plugin/package.json b/figma-limitless-mcp/plugin/package.json index 7d64c99..65745ec 100644 --- a/figma-limitless-mcp/plugin/package.json +++ b/figma-limitless-mcp/plugin/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "concurrently \"vite build --watch\" \"vite build --watch -c vite.config.main.ts\"", - "build": "vite build && vite build -c vite.config.main.ts && node scripts/sync-dev-mode.mjs", + "build": "vite build && vite build -c vite.config.main.ts && node scripts/sync-extras.mjs", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/figma-limitless-mcp/plugin/scripts/sync-dev-mode.mjs b/figma-limitless-mcp/plugin/scripts/sync-dev-mode.mjs deleted file mode 100644 index 9f1bdbb..0000000 --- a/figma-limitless-mcp/plugin/scripts/sync-dev-mode.mjs +++ /dev/null @@ -1,21 +0,0 @@ -// Copies the built plugin bundle into dev-mode/ so the Dev-Mode companion -// manifest can reference its code/ui in the SAME directory — Figma forbids a -// manifest's "main"/"ui" from pointing at a parent dir (../dist). The two -// manifests (plugin/manifest.json + plugin/dev-mode/manifest.json) share one -// codebase; this keeps dev-mode/ in lockstep with dist/ on every build. -import { copyFileSync, mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const pluginDir = join(dirname(fileURLToPath(import.meta.url)), ".."); -const devDir = join(pluginDir, "dev-mode"); -mkdirSync(devDir, { recursive: true }); - -for (const [from, to] of [ - ["dist/code.js", "code.js"], - ["dist/index.html", "index.html"], -]) { - copyFileSync(join(pluginDir, from), join(devDir, to)); -} - -console.log("synced dist/ -> dev-mode/ (code.js, index.html)"); diff --git a/figma-limitless-mcp/plugin/scripts/sync-extras.mjs b/figma-limitless-mcp/plugin/scripts/sync-extras.mjs new file mode 100644 index 0000000..416f230 --- /dev/null +++ b/figma-limitless-mcp/plugin/scripts/sync-extras.mjs @@ -0,0 +1,22 @@ +// Copies the built plugin bundle into extras/ so the companion manifest +// (FigJam / Slides / Buzz) can reference its code/ui in the SAME directory — +// Figma forbids a manifest's "main"/"ui" from pointing at a parent dir +// (../dist). The two manifests (plugin/manifest.json = figma+dev, and +// plugin/extras/manifest.json = figjam/slides/buzz) share one codebase; this +// keeps extras/ in lockstep with dist/ on every build. +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pluginDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const extrasDir = join(pluginDir, "extras"); +mkdirSync(extrasDir, { recursive: true }); + +for (const [from, to] of [ + ["dist/code.js", "code.js"], + ["dist/index.html", "index.html"], +]) { + copyFileSync(join(pluginDir, from), join(extrasDir, to)); +} + +console.log("synced dist/ -> extras/ (code.js, index.html)"); From 3f7ea88027ccb14bacc36993e3dff0f7194604d6 Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:45:30 +0530 Subject: [PATCH 10/31] fix(limitless-plugin): extras manifest must include "figma" (Figma rule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live import surfaced a hard Figma constraint: EVERY plugin manifest's editorType must contain "figma" — a figjam/slides/buzz-only manifest is rejected with "The manifest editorType does not include 'figma'." So the extras companion is ["figma","figjam","slides","buzz"] (only ["figjam","dev"] together remains illegal). Both manifests now include figma; dev lives only in the flagship manifest, the niche surfaces in extras. Verified live: stale dev-plugin entry removed, both manifests imported and valid in Figma's Development menu. Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/plugin/extras/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/figma-limitless-mcp/plugin/extras/manifest.json b/figma-limitless-mcp/plugin/extras/manifest.json index 547d733..dae3594 100644 --- a/figma-limitless-mcp/plugin/extras/manifest.json +++ b/figma-limitless-mcp/plugin/extras/manifest.json @@ -15,6 +15,7 @@ }, "documentAccess": "dynamic-page", "editorType": [ + "figma", "figjam", "slides", "buzz" From 2d73678d2b199a5b1a821645612eae3b83343643 Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:06:32 +0530 Subject: [PATCH 11/31] feat(skills): bundle offline design-system knowledge layer (Wave 1a) The KNOWLEDGE half of the design-system skills layer, authored + verified by a 9-agent design workflow and bundled as offline Markdown (no network): - server/skills/canonical-structure.md the flawless build order (crown jewel) - server/skills/token-architecture.md 3-tier Primitive->Semantic->Component - server/skills/variable-scopes-binding-tokens.md - server/skills/theming-with-modes.md - server/skills/component-variant-structure-for-codegen.md - server/skills/design-to-code-correctness.md - server/skills/accessibility-naming-conventions.md - server/skills/lint-rules.md the 57-rule catalog (rule -> detection) - server/skills/index.json metadata for list_skills Encodes ONE opinionated structure (one collection per tier, theme as Semantic modes, aliases strictly one tier down, nodes bind Semantic/Component never Primitive) so design->code is deterministic. Every one of the 57 linter rules in the catalog was verified detectable via Plugin API 1.130 locally (0 REST). Next: Wave 1b wires the serving tools (list_skills / read_skill / get_build_recipe) + skill:// MCP resources + the copy-to-dist build step. Co-Authored-By: Claude Opus 4.8 --- .../accessibility-naming-conventions.md | 188 +++++++++++ .../server/skills/canonical-structure.md | 185 +++++++++++ ...component-variant-structure-for-codegen.md | 306 ++++++++++++++++++ .../skills/design-to-code-correctness.md | 252 +++++++++++++++ figma-limitless-mcp/server/skills/index.json | 118 +++++++ .../server/skills/lint-rules.md | 303 +++++++++++++++++ .../server/skills/theming-with-modes.md | 228 +++++++++++++ .../server/skills/token-architecture.md | 277 ++++++++++++++++ .../skills/variable-scopes-binding-tokens.md | 207 ++++++++++++ 9 files changed, 2064 insertions(+) create mode 100644 figma-limitless-mcp/server/skills/accessibility-naming-conventions.md create mode 100644 figma-limitless-mcp/server/skills/canonical-structure.md create mode 100644 figma-limitless-mcp/server/skills/component-variant-structure-for-codegen.md create mode 100644 figma-limitless-mcp/server/skills/design-to-code-correctness.md create mode 100644 figma-limitless-mcp/server/skills/index.json create mode 100644 figma-limitless-mcp/server/skills/lint-rules.md create mode 100644 figma-limitless-mcp/server/skills/theming-with-modes.md create mode 100644 figma-limitless-mcp/server/skills/token-architecture.md create mode 100644 figma-limitless-mcp/server/skills/variable-scopes-binding-tokens.md diff --git a/figma-limitless-mcp/server/skills/accessibility-naming-conventions.md b/figma-limitless-mcp/server/skills/accessibility-naming-conventions.md new file mode 100644 index 0000000..10d0c49 --- /dev/null +++ b/figma-limitless-mcp/server/skills/accessibility-naming-conventions.md @@ -0,0 +1,188 @@ +# Accessibility & Naming Conventions + +> Niche, quality-raising checks: **contrast between semantic fg/bg token pairs**, **minimum text sizes**, **naming-convention conformance**, and **orphan / duplicate / unused / mis-tiered tokens**. Every rule below is detectable **locally** via Figma Plugin API 1.130 — no REST, no token, no network. Each rule names the exact API call that proves it. + +--- + +## Why this exists (the code-output pain) + +Design→code generation "always messes it up unless you build each part one by one" for two structural reasons, both fixable up front: + +1. **There is no guarantee that a foreground goes with the right background.** If `color/content/on-brand` is just a loose variable that happens to sit near `color/surface/brand`, the codegen has no way to know they are a *pair*. It picks a plausible-looking combination, ships it, and you discover the 1.4:1 contrast failure only after rendering the component. Contrast is an **emergent property of a pair**, so it must be encoded as a pair — by naming convention — and checked by the linter, not by eyeballing rendered components. +2. **Ad-hoc names don't survive the trip to code.** A token called `color/blue-btn-text-2` maps to no stable code identifier, so the generator invents a name (`blueBtnText2`, `btnTextBlue`, …) and the design and code namespaces drift apart. Strict kebab/slash naming with an approved vocabulary makes the code name **derivable and stable**, so the same token is the same identifier everywhere. + +The linter closes the loop: **build → `lint_design_system` → fix → re-lint**. You stop discovering structural drift by assembling components one at a time; the file tells you. + +--- + +## The one right way (canonical rules) + +Pick this structure. Do not improvise alternatives. + +### 1. Three token tiers, three collections — strict one-directional layering + +| Tier | Collection | Modes | Holds | May reference | +| --- | --- | --- | --- | --- | +| **Primitive** | `primitives` | 1 (`value`) | raw literals only | nothing (literals only) | +| **Semantic** | `theme` | ≥2 (`light`, `dark`, …) | **aliases only** | `primitives` only | +| **Component** | `component` | mirrors `theme` or 1 | **aliases only** | `theme` only | + +Hard layering laws (each is a linter rule): + +- A **primitive** value in every mode is a literal (`{r,g,b,a}` / number / string), **never** a `VARIABLE_ALIAS`. → `RULE TOK-01` +- A **semantic** value in every mode is a `VARIABLE_ALIAS` into `primitives`, **never** a literal. A raw hex sitting in the `theme` tier is the single most common cause of code drift. → `RULE TOK-02` +- A **component** value is a `VARIABLE_ALIAS` into `theme`. It must **not** alias a primitive directly (skipping the semantic tier defeats theming). → `RULE TOK-03` +- **Themes live in modes, not in names.** There is no `color/surface/base-dark`. Light/dark are two modes of the *same* `color/surface/base` variable. → `RULE TOK-04` + +### 2. Naming grammar (kebab + slash, closed vocabulary) + +- Every slash segment matches `^[a-z0-9]+(-[a-z0-9]+)*$` — lowercase kebab, no camelCase, no spaces, no underscores. → `RULE NAME-01` +- Path shape is `//`, depth **2–4** segments. → `RULE NAME-02` +- Numeric scale steps are **zero-padded to 3 digits** (`050`, `100`, `500`, `900`) so lexical and numeric sort agree. → `RULE NAME-03` +- **Primitive** names are ramps: `color//`, `space/`, `radius/`, `font-size/`. Raw hue words (`blue`, `amber`) are allowed here and **only** here. → `RULE NAME-04` +- **Semantic** names are roles from a **closed allowlist** — no raw hues, no component names. Approved color roles: `surface`, `content`, `border`, `icon`, plus intent modifiers `brand | neutral | info | success | warning | danger`, plus the pairing prefix `on-*`. A segment outside the allowlist is an ad-hoc name and fails. → `RULE NAME-05` +- Every non-hidden variable has a `codeSyntax.WEB` entry, and it is the dotted/kebab form of the path (`color.surface.brand`). Missing or mismatched codeSyntax is what forces the generator to guess. → `RULE NAME-06` +- No two non-hidden variables produce the **same code identifier** across collections (namespace collision). → `RULE NAME-07` + +### 3. The `on-*` pairing convention (this is what makes contrast checkable) + +**For every background token `color/surface/` there MUST exist a foreground token `color/content/on-` in the same `theme` collection.** That pair — and only that pair — is the contract the linter checks for contrast, in **every mode**. + +- Missing counterpart (`surface/x` with no `content/on-x`, or vice-versa) → `RULE PAIR-01`. +- For each pair, in each mode, resolve both aliases to concrete RGB and compute contrast (below). → `RULE A11Y-01`. + +This makes contrast a **deterministic, node-free computation over `valuesByMode`** — you never have to render a component to know if it passes. + +### 4. Minimum text size floor + +- No `TextStyle` and no `font-size` primitive is below **12px** (absolute floor). → `RULE A11Y-04` +- Default body text style is **≥ 16px**; a text style tagged/ named `body*` below 16px is a warning. → `RULE A11Y-05` +- The 4.5:1 vs 3:1 contrast threshold is chosen per style using the WCAG **large-text** rule: `fontSize ≥ 24px`, **or** `fontSize ≥ 18.66px` AND weight ≥ 700 (from `TextStyle.fontName.style`). → feeds `A11Y-01`. + +--- + +## Contrast math (compute locally — no rendering needed for solid opaque tokens) + +Figma variable colors are already `{r,g,b,a}` floats in `0..1`. For an **opaque solid** pair you have everything; compute WCAG directly. + +**WCAG 2.x relative luminance & ratio** (this is the enforcement gate): + +``` +linearize(c) = c <= 0.03928 ? c/12.92 : ((c + 0.055)/1.055) ** 2.4 // c in 0..1 +L = 0.2126*linearize(r) + 0.7152*linearize(g) + 0.0722*linearize(b) +ratio = (Llighter + 0.05) / (Ldarker + 0.05) // 1..21 +``` + +Thresholds (gate = **fail below**): + +| Context | WCAG min | +| --- | --- | +| Body / normal text | **4.5 : 1** | +| Large text (≥24px, or ≥18.66px bold) | **3 : 1** | +| Borders / icons / focus rings (`border/*`, `icon/*`) — graphical-object contrast, SC 1.4.11 | **3 : 1** | + +**APCA (advisory, reported not gated):** also emit APCA `Lc` from the resolved RGB using the bundled offline `apca-w3` (0.1.9) constants shipped in the recipe — do **not** approximate with a homemade polynomial. Advisory targets: `Lc ≥ 60` body, `Lc ≥ 45` large, `Lc ≥ 75` fine print. WCAG remains the pass/fail gate until WCAG 3 is final. → `RULE A11Y-02` (advisory). + +**When the raw-RGB shortcut is invalid — you MUST fall back to pixel sampling:** the WCAG formula above is only truthful for a **flat, fully-opaque solid** foreground on a **flat, fully-opaque solid** background. It lies when any of these is true: + +- a paint has `color.a < 1` or `paint.opacity < 1` (must composite over the actual backdrop first), +- the fill `type !== 'SOLID'` (gradient / image / video), +- effects, blend modes, or overlapping layers sit between fg and bg. + +In those cases resolve nothing — render truth instead: `node.exportAsync({format:'PNG'})` a small swatch of the fg-over-bg composite and sample pixel luminance. Detectable via `paint.opacity`, `paint.type`, `color.a`, `node.effects`, `node.blendMode`. → `RULE A11Y-03`. + +**Mode handling:** evaluate every pair in **every mode** of the `theme` collection straight from `valuesByMode`. Do **not** rely on per-node resolved-mode traversal for the gate — `node.explicitVariableModes` only carries explicit overrides and is unreliable for deep nodes. The mode-complete sweep over token pairs is both simpler and more complete. + +**Optional empirical sweep (advisory):** additionally, `figma.root.findAllWithCriteria({ types: ['TEXT'] })`; for each text node read `fills[0].boundVariables?.color` → variable `F`, walk ancestors to the nearest node with a bound solid fill variable `B`, and check `contrast(F,B)` per theme mode. This catches *rendered* pairs that dodged the `on-*` convention. It supplements, never replaces, the deterministic pair gate. + +--- + +## Orphan / duplicate / unused / mis-tiered detection + +Build one **usage set** = (every variable id in any node's `boundVariables` / `paints[].boundVariables`, via `findAllWithCriteria`) ∪ (every alias-target id found while scanning all variables' `valuesByMode`). + +- **Broken alias (error):** a `valuesByMode` `VARIABLE_ALIAS` whose target id no longer resolves via `getVariableByIdAsync`. → `RULE ORPH-01` +- **Unused (warn, advisory):** a variable id absent from the usage set. **Caveat:** cross-file library usage is invisible to the local Plugin API — a token used only in another file looks unused. Emit as a warning, never auto-delete. → `RULE ORPH-02` +- **Duplicate primitive (warn):** two primitives with identical resolved value in the same mode → consolidate to one. Compare `valuesByMode` after alias resolution. → `RULE ORPH-03` +- **Duplicate semantic intent (warn):** two `theme` tokens whose full alias chains land on the same primitive in the same mode AND share a role stem → likely a rename or a merge. → `RULE ORPH-04` + +--- + +## Worked example (real collection / variable / scope / alias names) + +### `primitives` (1 mode: `value`) + +``` +color/neutral/000 = #FFFFFF +color/neutral/900 = #111418 +color/blue/050 = #EFF4FF +color/blue/600 = #1D4ED8 +color/amber/300 = #FCD34D +font-size/100 = 14 (scope: FONT_SIZE) +font-size/200 = 16 (scope: FONT_SIZE) +``` + +### `theme` (2 modes: `light`, `dark`) — aliases only + +| Variable | `light` → alias | `dark` → alias | +| --- | --- | --- | +| `color/surface/base` | `color/neutral/000` | `color/neutral/900` | +| `color/content/on-base` | `color/neutral/900` | `color/neutral/000` | +| `color/surface/brand` | `color/blue/600` | `color/blue/600` | +| `color/content/on-brand` | `color/neutral/000` | `color/neutral/000` | +| `color/surface/warning` | `color/amber/300` | `color/amber/300` | +| `color/content/on-warning` | `color/neutral/000` ⚠️ | `color/neutral/000` ⚠️ | + +### `component` (mirrors `theme`) — aliases into `theme` only + +``` +button/primary/bg → color/surface/brand +button/primary/label → color/content/on-brand +``` + +### What the linter computes (per pair, per mode) + +- `surface/base` ↔ `content/on-base`: light `#FFFFFF`↔`#111418` ≈ **17 : 1** PASS; dark inverse PASS. +- `surface/brand` ↔ `content/on-brand`: `#1D4ED8`↔`#FFFFFF` ≈ **6.6 : 1** PASS (AA normal + AAA large). +- **`surface/warning` ↔ `content/on-warning`: `#FCD34D`↔`#FFFFFF` ≈ 1.36 : 1 → `A11Y-01` ERROR.** + +### The deliberate failure it catches, and the fix + +White-on-amber is the classic mistake a component-by-component build would ship. The linter flags it from `valuesByMode` alone — before any button exists. **Fix:** re-point `color/content/on-warning` (both modes) → `color/neutral/900` (`#111418`), giving ≈ **12 : 1**. Re-lint → green. + +This is exactly the guarantee that lets design→code stop being "build one piece, find the break, repeat." + +--- + +## How the linter detects each rule (Plugin API 1.130 grounding) + +| Rule | Detection surface | +| --- | --- | +| TOK-01/02/03/04 (tiering, aliasing, modes) | `getLocalVariableCollectionsAsync()` → `.modes` / `.defaultModeId`; `getLocalVariablesAsync()` → `.variableCollectionId`, `.valuesByMode` (literal vs `{type:'VARIABLE_ALIAS'}`), resolve target's collection via `getVariableByIdAsync` | +| NAME-01…05 (grammar, vocabulary) | `Variable.name`, `VariableCollection.name`, style `.name` — regex + allowlist | +| NAME-06 (codeSyntax) | `Variable.codeSyntax` (WEB/ANDROID/iOS keys) | +| NAME-07 (code collision) | derive identifier from `codeSyntax.WEB` ?? name; detect dupes | +| PAIR-01 (`on-*` completeness) | set-diff over `Variable.name` within `theme` collection | +| A11Y-01 (WCAG pair gate) | `valuesByMode` → resolve alias chain → RGB → luminance formula; large-text branch from `TextStyle.fontSize` + `fontName.style` | +| A11Y-02 (APCA advisory) | same resolved RGB → bundled `apca-w3` | +| A11Y-03 (pixel fallback trigger) | `paint.opacity`, `paint.type`, `color.a`, `node.effects`, `node.blendMode`; `node.exportAsync({format:'PNG'})` sampling | +| A11Y-04/05 (text floor) | `getLocalTextStylesAsync()` → `.fontSize`, `.fontName`; `font-size` primitives with scope `FONT_SIZE` | +| ORPH-01 (broken alias) | `valuesByMode` alias id → `getVariableByIdAsync` returns null | +| ORPH-02 (unused) | usage set from `findAllWithCriteria(...).boundVariables` + `paints[].boundVariables` ∪ alias refs; advisory (cross-file blind) | +| ORPH-03/04 (duplicates) | resolved `valuesByMode` value equality within tier | + +Anything **not** on this table is **not** locally detectable — e.g. real cross-file token usage, and true rendered contrast through library-component overlays you can't reach. Report those as advisory-only and say so; never gate on them. + +--- + +## Common mistakes to avoid + +- **Encoding a theme in the name** (`surface/base-dark`) instead of a mode. Breaks `TOK-04`, doubles your token count, and confuses codegen theming. +- **Literals in the semantic tier** (`color/surface/brand = #1D4ED8`). This is the #1 code-drift cause — semantic must alias. Breaks `TOK-02`. +- **Component tokens aliasing primitives directly**, skipping `theme`. Themes stop propagating. Breaks `TOK-03`. +- **Checking every fg against every bg.** Combinatorial noise. Check the **`on-*` pairs only** for the gate; use the node sweep as advisory. +- **Trusting the WCAG formula on translucent / gradient / effect-laden fills.** It will read "pass" on a lie. Fall back to `exportAsync` pixel sampling (`A11Y-03`). +- **Auto-deleting "unused" tokens.** Local usage is file-scoped; a library consumer elsewhere still needs it. Warn, don't delete (`ORPH-02`). +- **Missing / hand-wavy `codeSyntax`.** Without it the generator invents identifiers. Every non-hidden variable needs a WEB codeSyntax that matches its path (`NAME-06`). +- **Un-padded scale steps** (`color/blue/50` next to `color/blue/500`). Sort order breaks; pad to 3 (`NAME-03`). +- **Approximating APCA by hand.** Ship and call the vendored `apca-w3` constants; a homemade curve gives wrong `Lc`. diff --git a/figma-limitless-mcp/server/skills/canonical-structure.md b/figma-limitless-mcp/server/skills/canonical-structure.md new file mode 100644 index 0000000..537b090 --- /dev/null +++ b/figma-limitless-mcp/server/skills/canonical-structure.md @@ -0,0 +1,185 @@ + +# The Flawless Design-System Structure (Canonical Build Order) + +> This is the single source of truth the AI follows **top-to-bottom** when building or repairing a design system in Figma via `figma-limitless-mcp`. It exists to kill trial-and-error: the hardest problem — *how tokens are derived (primitive → semantic → component) and why code output "always messes it up unless we build each part one by one"* — is solved by making the derivation **explicit, ordered, and lint-gated**. Build one layer, lint it, fix, then descend. Never skip a gate. + +## 0. The Iron Law of the Three Tiers + +Every value in a correct system lives in exactly one of three collections, and data only ever flows **down one tier at a time**: + +``` +Primitive (raw literals, no aliases) e.g. color/blue/500 = #2F6FED + │ alias, exactly one tier down + ▼ +Semantic (aliases only, theme-aware modes) e.g. color/bg/accent → {color/blue/500} + │ alias, exactly one tier down + ▼ +Component (aliases only, single mode) e.g. button/bg/rest → {color/bg/accent} +``` + +Nodes and component layers **bind to Component tokens** (or Semantic where no component token is warranted) — **never to Primitives**. This is the rule `design-to-code-correctness` depends on: codegen reads the binding chain and emits `button.bg.rest` → `--color-bg-accent` → `#2F6FED`. Bind a node straight to `color/blue/500` and codegen emits a magic literal — the exact failure the owner keeps hitting. + +**Lint gate after this section:** `three-tier-collections-exist`, `no-node-binds-primitive`, `alias-one-tier-down`. + +--- + +## 1. Collections & Modes (build these first, empty) + +Create exactly these collections with these mode axes. **One theme axis per collection** — never mix brand and appearance modes in one collection. + +| Collection | Purpose | Modes (this is the whole mode set) | `defaultModeId` | +|---|---|---|---| +| **Primitives** | Raw literals. The palette, the raw scale. | **single mode** `Value` | `Value` | +| **Semantic** | Theme-aware roles. Aliases into Primitives. | `Light`, `Dark` (add `hc-light`/`hc-dark` only if HC is real) | `Light` (the base) | +| **Component** | Per-component tokens. Aliases into Semantic. | **single mode** `Value` | `Value` | + +Rules baked in here: +- Primitives + Component are **single-mode** (`primitive-component-single-mode`). Theming happens **only** in Semantic. If a primitive needs two values, it was mis-tiered — it is a semantic role. +- Mode names are consistent across the appearance axis (`consistent-mode-names-across-axis`), the default Semantic mode is the neutral base (`semantic-default-mode-is-base` → `Light`). +- Keep the mode count sane: `mode-count-ceiling` (soft cap, heuristic/config: appearance ≤ 4). Multi-brand goes in its **own** collection with its own alias discipline (`one-theme-axis-per-collection`, `multi-brand-alias-discipline`), never as extra modes stapled onto Semantic. + +**Lint gate:** `primitive-component-single-mode`, `mode-count-ceiling`, `semantic-default-mode-is-base`, `consistent-mode-names-across-axis`, `one-theme-axis-per-collection`. + +--- + +## 2. Tier 1 — Primitives (raw values only) + +Populate Primitives with **literals only — zero aliases** (`primitive-raw-values-only`). + +**Naming** (`hue-ramp-words-primitives-only`, `numeric-scale-zero-padded`, `name-kebab-segments`, `name-slash-structure-depth`): +``` +color/{hue}/{step} color/blue/500 color/slate/050 (steps zero-padded, 050→900) +space/{step} space/04 space/16 (numeric scale) +radius/{step} radius/sm radius/lg +font-size/{step} font-size/md +font-family/{role} font-family/sans +``` +- Hue words (`blue`, `slate`) are **legal in Primitives only** — they must never appear in Semantic/Component names. +- Slash depth ≤ 3, every segment kebab-case, numeric ramps zero-padded so they sort. + +**Scopes** — even primitives get typed scopes so they can't be mis-bound (`scope-legal-for-resolved-type`, `no-all-scopes-on-typed-token`): +- COLOR primitive → `ALL_FILLS, STROKE_COLOR` (not `ALL_SCOPES`). +- FLOAT space/gap primitive → `GAP, WIDTH_HEIGHT`. +- FLOAT radius primitive → `CORNER_RADIUS`. +- FLOAT font-size → `FONT_SIZE`. +- STRING font-family → `FONT_FAMILY`. + +**Publishing:** primitives are **hidden from publishing** (`primitive-hidden-from-publishing`) — consumers get Semantic/Component, not the raw palette. Deduplicate: no two primitives with the same value (`duplicate-primitive-value`). + +**Lint gate:** `primitive-raw-values-only`, `hue-ramp-words-primitives-only`, `numeric-scale-zero-padded`, `primitive-hidden-from-publishing`, `duplicate-primitive-value`, and the scope pack (`scope-legal-for-resolved-type`, `no-all-scopes-on-typed-token`, `non-redundant-scope-set`). + +--- + +## 3. Tier 2 — Semantic (aliases + theming, the derivation heart) + +This is where the owner's "how tokens should be derived" problem is actually answered. Every Semantic variable is an **alias into a Primitive** (`alias-one-tier-down`, `alias-target-resolves`) and is **populated in *every* mode** (`semantic-alias-in-every-mode`). + +**Role vocabulary is a closed allowlist** (`semantic-role-allowlist`) — the only legal top segments in Semantic: +``` +color/bg/* surface & fills color/bg/canvas, color/bg/surface, color/bg/accent +color/fg/* text & icons color/fg/default, color/fg/muted, color/fg/on-accent +color/border/* strokes color/border/default, color/border/focus +space/* layout spacing roles space/inset-md, space/stack-lg +radius/* role radii radius/control, radius/card +text/* composite type roles text/body-md, text/heading-lg +``` + +**The fg/bg pairing law** (`surface-on-pair-completeness`): every `color/bg/{role}` that carries content must have a matching `color/fg/on-{role}`. `bg/accent` ⇒ `fg/on-accent`. This is what makes contrast checkable and codegen's surface pairs complete. + +**Theming = re-aliasing per mode**, never new literals: +``` +color/bg/accent Light → {color/blue/600} Dark → {color/blue/400} +color/fg/default Light → {color/slate/900} Dark → {color/slate/050} +``` +Both modes alias primitives; only the *target* changes. This gives free, correct dark mode (`theming-with-modes`). + +**Scopes are role-matched** (`color-role-scope-match`, `dimension-role-scope-match`, `type-role-scope-match`): +| Role | Legal scopes | +|---|---| +| `color/bg/*` | `FRAME_FILL, SHAPE_FILL` | +| `color/fg/*` | `TEXT_FILL` (+ `SHAPE_FILL` for icons) | +| `color/border/*` | `STROKE_COLOR` | +| `space/*` | `GAP, WIDTH_HEIGHT` (inset roles: also padding via `GAP`) | +| `radius/*` | `CORNER_RADIUS` | +| `text/size` | `FONT_SIZE` | +No token ever carries `TEXT_CONTENT` (`no-text-content-scope-on-token` — that scope is for string content variables, not design tokens). + +**Lint gate:** `alias-one-tier-down`, `alias-target-resolves`, `semantic-alias-in-every-mode`, `semantic-role-allowlist`, `surface-on-pair-completeness`, `color-role-scope-match`, `dimension-role-scope-match`, `type-role-scope-match`, `no-text-content-scope-on-token`, `alias-graph-acyclic-max-depth-2`. + +--- + +## 4. Tier 3 — Component tokens (single mode, alias Semantic) + +Component tokens exist **only** where a component needs a named, swappable hook. Each **must alias a Semantic token** (`component-token-must-alias-semantic`) — never a Primitive, never a raw value. + +``` +button/bg/rest → {color/bg/accent} +button/bg/hover → {color/bg/accent-hover} +button/fg/label → {color/fg/on-accent} +button/radius → {radius/control} +``` +- Naming: `{component}/{part}/{state}` — kebab segments, depth ≤ 3. +- **Pass-through discipline** (`single-use-component-passthrough`): if a component token is a bare 1:1 alias used exactly once with no state variance, flag it — it may be redundant indirection; bind the layer to the Semantic token directly instead. +- Kill orphans: any Component (or Semantic) variable with zero bindings and zero downstream aliases is dead (`unused-variable-orphan`). + +**Lint gate:** `component-token-must-alias-semantic`, `single-use-component-passthrough`, `unused-variable-orphan`, `alias-graph-acyclic-max-depth-2` (re-run whole graph — max depth Component→Semantic→Primitive = 2 hops). + +--- + +## 5. Codegen contract (make design → code deterministic) + +The moment tokens are stable, lock the codegen surface. This is the layer that makes output reproducible instead of "messed up". + +Every **published** variable must carry: +- `codeSyntax.WEB` set (`published-variable-has-codesyntax-web`), **unique** across the system (`codesyntax-web-unique`), and **derivable from its name** (`codesyntax-web-matches-name` → `color/bg/accent` ⇒ `--color-bg-accent` or `colorBgAccent`, per convention). +- A `description` (`published-variable-has-description`) — the human/codegen intent. + +Every **ComponentSet** must have a code mapping (`component-set-has-code-mapping`) — the Plugin API supplies the prop keys via `componentPropertyDefinitions`; the mapping half is stored in the local MCP `get_code_mappings`/`set_code_mapping` store (local, no REST). This binds Figma variant props to code component props so codegen emits `