From 318f5f0413a2df857b8b70bb9fa2947c311f5e22 Mon Sep 17 00:00:00 2001 From: Justineo Date: Mon, 27 Jul 2026 12:23:34 +0800 Subject: [PATCH 01/52] feat: add declarative Figma canvas authoring --- AGENTS.md | 1 + README.md | 12 +- README.zh-Hans.md | 12 +- .../tempad-dev/.claude-plugin/plugin.json | 13 +- .../tempad-dev/.codex-plugin/plugin.json | 30 +- agent-plugins/tempad-dev/README.md | 9 +- .../skills/figma-canvas-authoring/SKILL.md | 161 ++++ .../figma-canvas-authoring/agents/openai.yaml | 4 + docs/extension/mcp-canvas-authoring-design.md | 344 ++++++++ packages/extension/AGENTS.md | 19 +- packages/extension/CHANGELOG.md | 9 + .../sections/AgentIntegrationSection.vue | 19 +- packages/extension/composables/mcp.ts | 4 +- packages/extension/mcp/runtime.ts | 23 +- packages/extension/mcp/tools/canvas.ts | 761 ++++++++++++++++++ packages/extension/mcp/tools/design-system.ts | 251 ++++++ packages/extension/package.json | 2 +- .../extension/tests/composables/mcp.test.ts | 17 +- packages/extension/tests/mcp/runtime.test.ts | 16 + .../extension/tests/mcp/tools/canvas.test.ts | 674 ++++++++++++++++ .../tests/mcp/tools/design-system.test.ts | 232 ++++++ packages/extension/ui/state.ts | 1 + packages/extension/vitest.node.config.ts | 2 + packages/mcp-server/CHANGELOG.md | 6 + packages/mcp-server/README.md | 3 + packages/mcp-server/README.zh-Hans.md | 3 + packages/mcp-server/package.json | 2 +- packages/mcp-server/src/instructions.md | 5 + packages/mcp-server/src/tools.ts | 165 ++-- packages/mcp-server/tests/tools.test.ts | 76 ++ packages/shared/src/mcp/errors.ts | 6 + packages/shared/src/mcp/install.ts | 2 +- packages/shared/src/mcp/responses.ts | 20 + packages/shared/src/mcp/tools.ts | 339 ++++++++ .../shared/tests/mcp/constants-errors.test.ts | 6 + packages/shared/tests/mcp/index.test.ts | 2 + packages/shared/tests/mcp/install.test.ts | 13 +- packages/shared/tests/mcp/responses.test.ts | 35 + packages/shared/tests/mcp/tools.test.ts | 232 ++++++ packages/site/src/sections/ConnectSection.vue | 4 +- vitest.config.ts | 2 + 41 files changed, 3421 insertions(+), 116 deletions(-) create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/SKILL.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/agents/openai.yaml create mode 100644 docs/extension/mcp-canvas-authoring-design.md create mode 100644 packages/extension/mcp/tools/canvas.ts create mode 100644 packages/extension/mcp/tools/design-system.ts create mode 100644 packages/extension/tests/mcp/tools/canvas.test.ts create mode 100644 packages/extension/tests/mcp/tools/design-system.test.ts diff --git a/AGENTS.md b/AGENTS.md index 823f76e6..18ff785b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ Provide a single entry point for coding agents. This file links to package-level - `docs/testing/architecture.md` - `docs/extension/mcp-get-code-requirements.md` - `docs/extension/mcp-get-code-design.md` +- `docs/extension/mcp-canvas-authoring-design.md` - `docs/extension/mcp-browser-gateway-design.md` - `docs/marketing-screenshots.md` diff --git a/README.md b/README.md index a9aeb748..b60ef43f 100644 --- a/README.md +++ b/README.md @@ -205,14 +205,16 @@ Current available plugins: TemPad Dev ships an agent integration for coding agents and IDEs. The integration combines: -- an [MCP](https://modelcontextprotocol.io/) server that lets agents pull code and context directly from the node you have selected in Figma -- an agent skill that teaches the agent how to interpret that evidence in the current repository +- an [MCP](https://modelcontextprotocol.io/) server that lets agents inspect Figma and, with an explicit write toggle, apply declarative canvas results +- two agent skills: one for implementing Figma evidence in code, and one for designing on the Figma canvas with the active file's design system -Figma also provides official [remote and desktop MCP servers](https://developers.figma.com/docs/figma-mcp-server/), with the remote server recommended for most users. TemPad Dev is an open, local-control complement for teams that specifically want an inspectable browser-extension pipeline, the existing read-only inspection workflow, programmable output plugins, canonical agent-facing code/token IR, and an explicit context budget. It provides design evidence and a code starting point; the coding agent remains responsible for adapting that evidence to the repository, validating behavior, and producing the final implementation. +Figma also provides official [remote and desktop MCP servers](https://developers.figma.com/docs/figma-mcp-server/), with the remote server recommended for most users. TemPad Dev is an open, local-control complement for teams that specifically want an inspectable browser-extension pipeline, local inspection and opt-in declarative canvas authoring, programmable output plugins, canonical agent-facing code/token IR, and an explicit context budget. It provides design evidence and a code starting point; the coding agent remains responsible for adapting that evidence to the repository, validating behavior, and producing the final implementation. With the TemPad Dev panel open and MCP enabled, the MCP server exposes: - `get_code`: High-fidelity JSX/Vue + TailwindCSS code output by default, plus attached assets and the codegen preset/config used. +- `get_design_system`: Query-ranked native Figma component and variable references. +- `apply_canvas`: A declarative desired result that the extension safely reconciles with the live canvas. This requires the separate, session-only **Canvas writes** toggle. - `get_structure`: A structural outline (ids, types, geometry) for the current selection. - Binary assets are returned as metadata + HTTP download URLs (`asset.url`) in tool responses. Asset MCP resources are not exposed. @@ -224,9 +226,9 @@ With the TemPad Dev panel open and MCP enabled, the MCP server exposes: TemPad Dev agent setup dialog. -1. Install Node.js 18.20.0 or later with `npx`. Keep TemPad Dev open in the Figma tab you want the agent to inspect, then enable **Preferences → Agent integration → MCP access**. When prompted, allow the loopback connection to `127.0.0.1`. +1. Install Node.js 18.20.0 or later with `npx`. Keep TemPad Dev open in the Figma tab you want the agent to inspect, then enable **Preferences → Agent integration → MCP access**. When prompted, allow the loopback connection to `127.0.0.1`. Enable **Canvas writes** separately only when you want the agent to modify that file. 2. Select **Set up agents**, choose Codex, Cursor, Claude Code, Gemini, VS Code, OpenCode, or TRAE, and follow the displayed path. Use **Other** for another compatible client. The choice only changes the instructions shown; it does not bind or activate an agent. -3. Prefer the direct action when offered. Every fallback command or config is shown in full for review and copying. Codex and Claude Code plugins include both MCP and the `figma-design-to-code` skill; the other paths show the two required steps separately. +3. Prefer the direct action when offered. Every fallback command or config is shown in full for review and copying. Codex and Claude Code plugins include MCP plus the `figma-design-to-code` and `figma-canvas-authoring` skills; the other paths show MCP and standalone skill setup separately. Keep TemPad Dev open with MCP enabled while using it. If multiple Figma files are connected, click the MCP badge in the panel for the file you want the agent to inspect; that file becomes the active context. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 6a6e63cd..a975df34 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -201,14 +201,16 @@ sandboxed extension page 内启动一个全新的 Worker,并在完成或五秒 TemPad Dev 内置了面向编码 agent 和 IDE 的 Agent 集成。该集成包含: -- 一个 [MCP](https://modelcontextprotocol.io/) 服务器,使 agent 可以直接从你在 Figma 中选中的节点拉取代码和上下文 -- 一个 agent skill,用于指导 agent 在当前仓库中理解并使用这些证据 +- 一个 [MCP](https://modelcontextprotocol.io/) 服务器,使 agent 可以检查 Figma,并在显式启用写入后提交声明式画布结果 +- 两个 agent skill:一个用于根据 Figma 证据实现代码,另一个用于基于当前文件的 design system 在 Figma 画布上进行设计 -Figma 也提供官方的 [remote 与 desktop MCP server](https://developers.figma.com/docs/figma-mcp-server/),并建议大多数用户优先使用 remote server。TemPad Dev 的定位是一个开放、强调本地控制的补充方案,适合明确需要可审计的浏览器扩展链路、现有只读检查流程、可编程输出插件、规范化的 agent-facing 代码/token IR,以及显式上下文预算的团队。TemPad Dev 提供设计证据与代码起点;最终仍由 coding agent 结合目标仓库完成适配、验证和实现。 +Figma 也提供官方的 [remote 与 desktop MCP server](https://developers.figma.com/docs/figma-mcp-server/),并建议大多数用户优先使用 remote server。TemPad Dev 的定位是一个开放、强调本地控制的补充方案,适合明确需要可审计的浏览器扩展链路、本地检查与按需启用的声明式画布创作、可编程输出插件、规范化的 agent-facing 代码/token IR,以及显式上下文预算的团队。TemPad Dev 提供设计证据与代码起点;最终仍由 coding agent 结合目标仓库完成适配、验证和实现。 打开 TemPad Dev 面板并启用 MCP 后,MCP 服务器会暴露以下能力: - `get_code`:默认输出高保真的 JSX/Vue + TailwindCSS 代码,同时包含相关资源以及使用的 codegen 预设和配置。 +- `get_design_system`:返回按查询排序的原生 Figma 组件和变量引用。 +- `apply_canvas`:提交声明式目标结果,由扩展与实时画布安全地进行增量协调;需要单独启用仅当前会话有效的 **Canvas writes**。 - `get_structure`:当前选中节点的结构信息(id、类型、几何数据)。 - 二进制资源会通过工具响应中的元数据 + HTTP 下载地址(`asset.url`)提供;MCP 不再暴露 asset 资源模板。 @@ -220,9 +222,9 @@ Figma 也提供官方的 [remote 与 desktop MCP server](https://developers.figm TemPad Dev agent setup 对话框。 -1. 安装 Node.js 18.20.0 或更高版本并确保 `npx` 可用。在希望 agent 检查的 Figma 标签页中保持 TemPad Dev 打开,然后启用 **Preferences → Agent integration → MCP access**。出现提示时,请允许连接到 loopback 地址 `127.0.0.1`。 +1. 安装 Node.js 18.20.0 或更高版本并确保 `npx` 可用。在希望 agent 检查的 Figma 标签页中保持 TemPad Dev 打开,然后启用 **Preferences → Agent integration → MCP access**。出现提示时,请允许连接到 loopback 地址 `127.0.0.1`。只有在希望 agent 修改该文件时,才另外启用 **Canvas writes**。 2. 点击 **Set up agents**,选择 Codex、Cursor、Claude Code、Gemini、VS Code、OpenCode 或 TRAE,然后按界面显示的路径配置。其它兼容客户端请选择 **Other**。这里的选择只会切换说明,不会绑定或激活 agent。 -3. 如果界面提供直接操作,请优先使用。所有备用命令和 config 都会完整显示,便于检查和复制。Codex 与 Claude Code 的 plugin 同时包含 MCP 和 `figma-design-to-code` skill;其它路径会分别展示两个必要步骤。 +3. 如果界面提供直接操作,请优先使用。所有备用命令和 config 都会完整显示,便于检查和复制。Codex 与 Claude Code 的 plugin 同时包含 MCP、`figma-design-to-code` 和 `figma-canvas-authoring` skill;其它路径会分别展示 MCP 与独立 skill 的配置步骤。 使用期间请保持 TemPad Dev 打开并启用 MCP。如果连接了多个 Figma 文件,请点击目标文件面板中的 MCP 徽标;该文件会成为 agent 当前访问的上下文。 diff --git a/agent-plugins/tempad-dev/.claude-plugin/plugin.json b/agent-plugins/tempad-dev/.claude-plugin/plugin.json index bde03b87..ed2ed73b 100644 --- a/agent-plugins/tempad-dev/.claude-plugin/plugin.json +++ b/agent-plugins/tempad-dev/.claude-plugin/plugin.json @@ -1,14 +1,23 @@ { "name": "tempad-dev", "version": "0.1.0", - "description": "Use selected Figma nodes as agent-ready evidence for project-consistent UI implementation.", + "description": "Turn Figma evidence into UI code and create native designs from an existing Figma design system.", "author": { "name": "TemPad Dev" }, "homepage": "https://github.com/ecomfe/tempad-dev#agent-integration", "repository": "https://github.com/ecomfe/tempad-dev", "license": "MIT", - "keywords": ["figma", "mcp", "skill", "agent-integration", "design-to-code", "frontend"], + "keywords": [ + "figma", + "mcp", + "skill", + "agent-integration", + "design-to-code", + "canvas-authoring", + "design-system", + "frontend" + ], "skills": "./skills/", "mcpServers": "./.mcp.json" } diff --git a/agent-plugins/tempad-dev/.codex-plugin/plugin.json b/agent-plugins/tempad-dev/.codex-plugin/plugin.json index a420ad27..16fccc11 100644 --- a/agent-plugins/tempad-dev/.codex-plugin/plugin.json +++ b/agent-plugins/tempad-dev/.codex-plugin/plugin.json @@ -1,27 +1,43 @@ { "name": "tempad-dev", "version": "0.1.1", - "description": "Use the TemPad Dev agent integration to turn selected Figma nodes into repo-ready UI code.", + "description": "Use TemPad Dev to turn Figma evidence into UI code and create native designs from an existing Figma design system.", "author": { "name": "TemPad Dev" }, "homepage": "https://github.com/ecomfe/tempad-dev#agent-integration", "repository": "https://github.com/ecomfe/tempad-dev", "license": "MIT", - "keywords": ["figma", "mcp", "skill", "agent-integration", "design-to-code", "frontend"], + "keywords": [ + "figma", + "mcp", + "skill", + "agent-integration", + "design-to-code", + "canvas-authoring", + "design-system", + "frontend" + ], "skills": "./skills/", "interface": { "displayName": "TemPad Dev", - "shortDescription": "Use Figma selections as agent-ready design evidence.", - "longDescription": "TemPad Dev packages the figma-design-to-code agent skill with MCP server configuration so coding agents can inspect selected Figma nodes and implement project-consistent UI code.", + "shortDescription": "Read Figma evidence and create design-system-grounded canvas content.", + "longDescription": "TemPad Dev packages skills for implementing Figma designs in code and authoring native Figma content from the active file's design system, together with its MCP server configuration.", "developerName": "TemPad Dev", "category": "Design", - "capabilities": ["Agent integration", "MCP", "Design-to-code", "Frontend"], + "capabilities": [ + "Agent integration", + "MCP", + "Design-to-code", + "Canvas authoring", + "Design systems", + "Frontend" + ], "websiteURL": "https://github.com/ecomfe/tempad-dev", "defaultPrompt": [ "Use TemPad Dev to implement the selected Figma node.", - "Convert this Figma selection into repo-ready UI code.", - "Inspect the selected Figma node with TemPad Dev." + "Inspect the selected Figma node with TemPad Dev.", + "Create a Figma design using the active file's components and variables." ], "brandColor": "#0098FF" }, diff --git a/agent-plugins/tempad-dev/README.md b/agent-plugins/tempad-dev/README.md index 3042a180..b12fbba5 100644 --- a/agent-plugins/tempad-dev/README.md +++ b/agent-plugins/tempad-dev/README.md @@ -2,8 +2,9 @@ This plugin packages the TemPad Dev agent integration for Codex and Claude Code. It bundles: -- the `figma-design-to-code` agent skill -- the TemPad Dev MCP server configuration for selected-node design evidence +- `figma-design-to-code` for turning Figma evidence into project-consistent UI code +- `figma-canvas-authoring` for designing in Figma with the active file's components and variables +- the TemPad Dev MCP server configuration for design evidence and opt-in canvas authoring Install it for Codex: @@ -24,6 +25,8 @@ claude plugin install tempad-dev@tempad-dev The plugin appears in Claude Desktop after the marketplace is added. Both clients use the same skill and MCP server configuration from this directory. -Before using the integration, open TemPad Dev in Figma, then open **Preferences -> Agent integration** and enable **MCP access**. +Before using the integration, open TemPad Dev in Figma, then open **Preferences -> Agent +integration** and enable **MCP access**. Enable **Canvas writes** separately only when the agent +should modify the active Figma file. For app, CLI, direct MCP, and manual fallbacks, see the [complete setup guide](../../README.md#agent-integration). diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/SKILL.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/SKILL.md new file mode 100644 index 00000000..74d25cdf --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/SKILL.md @@ -0,0 +1,161 @@ +--- +name: figma-canvas-authoring +description: >- + Create or update native Figma designs with TemPad Dev MCP using components + and variables from the active file's design system. Use when the user asks + an agent to design, compose, draft, or refine screens or components directly + on the Figma canvas, including an empty canvas or document. Do not use for + Figma-to-code implementation, critique without canvas edits, raw Plugin API + automation, or unapproved design-system invention. +--- + +# TemPad Dev: Figma Canvas Authoring + +Turn product intent into native, editable Figma content. Make design decisions +from user intent and available evidence; let TemPad Dev perform deterministic +canvas reconciliation. + +TemPad Dev MCP must be connected to the intended Figma file. Canvas writes must +be enabled before calling `tempad-dev:apply_canvas`. If either is unavailable, +stop and tell the user how to reconnect or enable it; never work around the +write boundary. + +## Sources of truth + +Use each source for a different job: + +- **User input** defines the product goal, content, scope, and acceptable + creative freedom. +- **Figma design-system evidence** from `tempad-dev:get_design_system` defines + reusable component and variable identities. +- **Existing canvas evidence** from `tempad-dev:get_code` and + `tempad-dev:get_structure` defines visible composition and the exact update + scope when editing existing work. +- **Project evidence**, when a repository is available, supplies higher-level + design principles, product patterns, terminology, and constraints. + +Never invent a Figma component or variable `id` or `key`. A familiar name is +not proof that two design-system resources are equivalent. + +## Workflow + +### 1. Establish the task and scope + +Determine whether the user wants to create new content or update an existing +subtree. + +- For an update, resolve one explicit target node. Use a user-provided + `nodeId`, or call `tempad-dev:get_structure` on the current selection when + the exact root identity is needed. +- Inspect existing content with `tempad-dev:get_code` only when its visual + composition matters to the requested design. +- Do not add speculative screens, states, interactions, or content outside the + requested scope. +- Ask only when missing product intent would materially change the design. + +### 2. Read the available design system + +Call `tempad-dev:get_design_system` with one concrete task query such as +`settings form`, `checkout summary`, or `数据表格`. + +Treat returned components, component properties, variables, scopes, IDs, and +keys as design facts. Prefer: + +1. an existing component instance for a product control or repeated pattern +2. an exposed component property for its supported variation +3. a semantic variable for a supported visual or layout field +4. a primitive or literal only when the design system has a real gap + +The result is intentionally scoped and ranked; it is not proof that every +subscribed Figma library was searched. + +If a query returns no matches but does not report that components and variables +are absent, retry once without a query to distinguish a query miss from missing +design-system evidence. Do not repeatedly broaden searches. + +### 3. Handle an empty document + +An empty canvas is not automatically a blocker. Base the decision on available +design-system evidence: + +- **Components or variables are returned:** create the requested design + normally with `apply_canvas` in `create` mode. +- **No resources are discoverable, but the user or trusted project + documentation provides real component or variable keys:** use those + references and let Figma validate or import them. +- **No resources or trusted references exist:** do not pretend the result + follows a Figma design system. Ask the user to choose one of these paths: + - open or seed a page containing representative design-system instances and + bound variables + - provide a reference file or real library component/variable keys + - explicitly authorize a primitive draft that can be migrated later + +When a primitive draft is explicitly authorized: + +- label it as a draft rather than design-system-compliant work +- use only user-provided or neutral values +- keep the structure small and easy to replace +- do not invent brand tokens, logos, icons, or component identities + +The important distinction is not “empty document” versus “non-empty document”; +it is “grounded design-system evidence” versus “no such evidence.” + +### 4. Compose one desired result + +Describe the result as a `CanvasNodeSpec` tree, not as a sequence of Figma API +operations. + +- Use stable, semantic, unique `key` values and reuse them in later updates. +- Prefer `INSTANCE` nodes over redrawing available components. +- Bind returned variables wherever their semantics and scopes match. +- When a literal and variable binding target the same field, expect the + variable binding to win. Keep a valid solid fallback paint for bound fill or + stroke fields. +- Use component properties instead of detaching or rebuilding an instance. +- Keep hierarchy native and editable. Use `FRAME` for containers and auto + layout where the design calls for it. +- Stay within the current authoring surface. Do not approximate unsupported + images, logos, icons, gradients, effects, or arbitrary vector artwork with + unrelated primitives. + +Favor the smallest coherent design that satisfies the request. Consistency with +the available design system matters more than novelty. + +### 5. Apply once + +Send one `tempad-dev:apply_canvas` call: + +- Use `create` for a new tree. Its root must be a `FRAME`. +- Use `update` with one explicit `targetNodeId` for an existing subtree. +- Supply the desired result, not individual mutation steps. +- Remember that omitted fields and existing omitted children are preserved. + Deletion is not supported. + +Do not split a design into repeated tool calls merely to mimic Plugin API +operations. Split only when the tool's documented size or depth limits require +independent, meaningful subtrees. + +### 6. Verify and refine + +Read `rootNodeId`, `nodeIdsByKey`, `mutationCount`, and any warnings from the +result. + +- Retain `nodeIdsByKey` and reuse the returned identities for later refinement. +- Use `tempad-dev:get_structure` only to verify hierarchy, ordering, or + geometry. +- Use `tempad-dev:get_code` when exact rendered style evidence is needed. +- Make at most one evidence-based refinement pass unless the user asks for + further iteration. + +If a component, variable, font, or component property cannot be resolved, fix +the reference or ask the user. Do not silently replace it with an imitation. + +## Safety boundaries + +- Never bypass the session-only Canvas writes toggle. +- Never update outside the explicit target subtree. +- Never use names as identity when an ID or stable key is required. +- Never delete, detach, publish, or create design-system resources. +- Never send arbitrary JavaScript or emulate raw Figma Plugin API calls. +- Rely on `apply_canvas` validation and rollback, but still keep each requested + change narrowly scoped. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/agents/openai.yaml b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/agents/openai.yaml new file mode 100644 index 00000000..30d070b9 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: 'Design in Figma' + short_description: 'Create Figma designs from its existing design system' + default_prompt: 'Use $figma-canvas-authoring to create a settings screen in the active Figma file using its existing components and variables.' diff --git a/docs/extension/mcp-canvas-authoring-design.md b/docs/extension/mcp-canvas-authoring-design.md new file mode 100644 index 00000000..428a0adf --- /dev/null +++ b/docs/extension/mcp-canvas-authoring-design.md @@ -0,0 +1,344 @@ +# MCP canvas authoring + +Status: implemented v1 + +## Decision + +TemPad Dev exposes two canvas-authoring tools: + +- `get_design_system` gives an external agent a compact set of real Figma components and variables. +- `apply_canvas` accepts one declarative desired result and applies it to the canvas. + +The agent does not emit or call individual Figma Plugin API methods. It sends one result tree. The +trusted extension compares that tree with the live Figma nodes, skips unchanged values, and performs +the required Plugin API calls locally. + +This keeps the public surface small: + +```txt +project context + get_design_system + | + v + one CanvasSpec result + | + v +live canvas -> reconcile -> validated Figma API calls -> live canvas +``` + +TemPad Dev is a connector and deterministic executor. It is not a second agent runtime, a planning +service, or a canvas operating system. + +## Goals + +- Create one native, editable Figma frame tree in one MCP call. +- Incrementally update an explicitly scoped existing subtree. +- Reuse existing components and bind existing variables by stable Figma identity. +- Preserve content the agent did not explicitly describe. +- Make a repeated identical result a no-op. +- Put safety, scope, and reversibility ahead of the absolute shortest API call sequence. +- Give the agent stable node IDs for later refinements. + +## Non-goals for v1 + +- Deleting nodes. +- Creating or publishing variables, components, component sets, styles, or libraries. +- Detaching instances. +- Arbitrary JavaScript or raw Plugin API execution. +- File-wide synchronization or a persistent code-to-Figma database. +- Library-wide crawling. +- Native text, paint, effect, or grid styles. +- Inferring a project's design language from screenshots alone. +- Guaranteeing that a subjective design choice is good. + +## Public tool 1: `get_design_system` + +Input: + +```ts +type GetDesignSystemInput = { + query?: string +} +``` + +The optional query ranks results for a concrete task such as `settings form`, `primary button`, or +`数据表格`. Matching is Unicode-aware. + +Output: + +```ts +type GetDesignSystemResult = { + page: { + id: string + name: string + } + components: Array<{ + id: string + key: string + name: string + description?: string + componentSetName?: string + properties?: Record< + string, + { + type: 'BOOLEAN' | 'INSTANCE_SWAP' | 'SLOT' | 'TEXT' | 'VARIANT' + defaultValue: string | boolean + options?: string[] + } + > + remote: boolean + }> + variables: Array<{ + id: string + key: string + name: string + collectionName: string + description?: string + remote: boolean + resolvedType: 'BOOLEAN' | 'COLOR' | 'FLOAT' | 'STRING' + scopes?: string[] + }> + warnings?: string[] +} +``` + +### Discovery rules + +Components include: + +- local components on the current page +- main components of instances already used on the current page + +Variables include: + +- local variables in the current file +- remote variables currently bound to nodes on the current page + +Remote collection names are resolved when Figma makes them available. Results are ranked +deterministically and capped at 40 components and 60 variables. + +The tool deliberately does not scan every subscribed library. A known library key can still be used +by `apply_canvas`, which imports and validates it through Figma. + +## Public tool 2: `apply_canvas` + +Input: + +```ts +type ApplyCanvasInput = { + mode: 'create' | 'update' + targetNodeId?: string + root: CanvasNodeSpec +} +``` + +`CanvasNodeSpec` is a recursive result description: + +```ts +type CanvasNodeSpec = { + key: string + nodeId?: string + type: 'FRAME' | 'TEXT' | 'RECTANGLE' | 'ELLIPSE' | 'LINE' | 'INSTANCE' + name?: string + visible?: boolean + position?: { + x?: number + y?: number + } + size?: { + width?: number + height?: number + horizontal?: 'FILL' | 'FIXED' | 'HUG' + vertical?: 'FILL' | 'FIXED' | 'HUG' + } + layout?: { + mode?: 'HORIZONTAL' | 'NONE' | 'VERTICAL' + gap?: number + padding?: + | number + | { + top?: number + right?: number + bottom?: number + left?: number + } + primaryAlign?: 'CENTER' | 'MAX' | 'MIN' | 'SPACE_BETWEEN' + counterAlign?: 'BASELINE' | 'CENTER' | 'MAX' | 'MIN' + } + appearance?: { + fill?: `#${string}` | null + stroke?: `#${string}` | null + strokeWeight?: number + cornerRadius?: number + opacity?: number + } + text?: { + characters?: string + fontFamily?: string + fontStyle?: string + fontSize?: number + lineHeight?: number + letterSpacing?: number + alignHorizontal?: 'CENTER' | 'JUSTIFIED' | 'LEFT' | 'RIGHT' + alignVertical?: 'BOTTOM' | 'CENTER' | 'TOP' + } + component?: { + id?: string + key?: string + } + componentProperties?: Record + variables?: CanvasVariableBindings + children?: CanvasNodeSpec[] +} +``` + +Design-system references require at least one real `id` or `key`. Component references are allowed +only on `INSTANCE` nodes. Text properties are allowed only on `TEXT`; layout and children are +allowed only on `FRAME`. + +`CanvasVariableBindings` maps `fill`, `stroke`, `width`, `height`, `gap`, four padding fields, +`cornerRadius`, `opacity`, and the five font fields to the same `{ id?, key? }` reference shape. + +### Create mode + +- `root.type` must be `FRAME`. +- Existing `nodeId` values and `targetNodeId` are rejected. +- Figma creates one frame tree on the current page. +- If neither root coordinate is supplied, the root is centered in the current viewport. + +### Update mode + +- `targetNodeId` is required. +- The live target must have the same type as the desired root. +- Existing nodes may be matched by explicit `nodeId` or stable `key`. +- Every referenced existing node must be the target or its descendant. +- Omitted fields remain unchanged. +- Omitted children remain in Figma; v1 never deletes them. +- Supplied children are reconciled in their supplied order. A node is moved only when its current + parent or index differs. + +Output: + +```ts +type ApplyCanvasResult = { + rootNodeId: string + nodeIdsByKey: Record + createdNodeIds: string[] + updatedNodeIds: string[] + mutationCount: number + warnings?: string[] +} +``` + +The agent should retain `nodeIdsByKey` and reuse those IDs during later updates. + +## Identity + +Names are presentation, not identity. The reconciler never finds a target by node name. + +Identity uses: + +1. `nodeId` when the agent supplies one +2. otherwise the stable `key` stored as shared plugin data on generated or adopted nodes + +Keys must be unique within a result. Node IDs must also be unique. If a key already identifies a +different live node, the write fails instead of guessing. + +No identity database or background synchronization service is needed. Figma node IDs plus local +shared plugin data are enough for the first version. + +## Reconciliation + +The extension performs the diff against current live Figma state at call time: + +1. Parse and validate the complete input. +2. Resolve the explicit update scope, if any. +3. Index stable keys inside that scope. +4. Walk the desired tree. +5. Reuse a matching live node or create the requested native node. +6. Move it only when its parent or supplied index differs. +7. Compare each supplied property and write only changed values. +8. Resolve components and variables by live ID or importable key. +9. Return stable identities and the actual mutation count. + +Component and variable lookups are cached within the call. Repeated identical input against +unchanged live state produces zero mutations. + +When both a literal field and a variable binding describe the same property, the variable binding +wins. The executor does not repeatedly overwrite a binding with its literal and then bind it again. + +This is a safe-minimal patch, not a graph-search problem. The executor avoids unnecessary writes, +but it will not trade away validation, scope checks, deterministic ordering, or rollback just to +reduce the API-call count. + +## Safety floor + +### Explicit write capability + +Canvas writes have a separate session-only toggle under Agent integration. It defaults to disabled +and is reset when MCP access is disabled or unavailable. Read tools remain usable without enabling +writes. + +### Editor and schema checks + +- Authoring runs only in Figma Design files. +- One result contains at most 100 nodes. +- A result is at most 12 levels deep. +- Colors use `#RRGGBB` or `#RRGGBBAA`. +- Unknown input fields are rejected. +- Only the six supported native node types can be authored. + +### Scope and concurrency + +- Update mode requires one explicit root. +- Existing targets outside that root are rejected. +- Only one `apply_canvas` call runs at a time in the active extension instance. +- No delete operation exists. + +### References and fallback + +- Missing components, variables, fonts, or component properties fail the call. +- The executor does not redraw a missing component from primitives. +- It does not replace a failed variable binding with a literal. +- Mixed-font text is preserved when font fields are omitted. Replacing mixed fonts requires both + `fontFamily` and `fontStyle`. + +### Undo and failure behavior + +The extension starts a Figma undo boundary before mutation and commits one boundary after success. +If an operation fails, it triggers Figma Undo before returning the error. If automatic rollback is +not available, the error says so and directs recovery through Figma Undo. + +The actual editor mutation remains the final edit-permission check: a read-only or otherwise +unsupported Figma context rejects the write and returns a coded failure. + +## Agent workflow + +The intended flow is short: + +```txt +1. Read the repository's design-system rules and nearby implementation. +2. Call get_design_system with the concrete task. +3. Prefer returned components and semantic variables. +4. Send one apply_canvas result. +5. Inspect the result with get_code or get_structure when useful. +6. Send one updated result only if refinement is needed. +``` + +Repository evidence supplies high-level principles. Figma supplies native identities and live canvas +state. TemPad Dev should not invent a design language when neither source provides one. + +## Implementation map + +- Shared contracts and coded errors: `packages/shared/src/mcp/` +- MCP tool definitions and agent instructions: `packages/mcp-server/src/` +- Runtime routing: `packages/extension/mcp/runtime.ts` +- Design-system discovery: `packages/extension/mcp/tools/design-system.ts` +- Canvas reconciliation: `packages/extension/mcp/tools/canvas.ts` +- Session write toggle: `packages/extension/components/sections/AgentIntegrationSection.vue` + +Both authoring tools are in the extension's node-coverage scope, with behavioral tests for their +contracts, reconciliation, and safety boundaries. + +Do not add more tools merely because the Plugin API has more methods. Extend this surface only when a +real authoring task cannot be expressed safely. Prefer extending the same discovery-and-apply model +unless the workflow is genuinely different. diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index ae5dab0b..19fccc5a 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -43,7 +43,7 @@ You are not responsible for: ## Project structure - `mcp/` - - `tools/`: MCP tool implementations (`get_code`, `get_structure`, `get_screenshot` internal, `token`). + - `tools/`: MCP tool implementations and supporting code. - `bridge/`: page-to-extension runtime messaging. - `broker/`: background session routing and loopback WebSocket lifecycle. - `runtime.ts`: tool routing + validation. @@ -88,14 +88,15 @@ Do not reuse UI codegen logic for MCP without a clear reason. ## Docs routing -| Change area | Read first | -| --------------------------------------------------------------------- | ---------------------------------------------- | -| MCP `get_code` behavior or contract | `docs/extension/mcp-get-code-requirements.md` | -| MCP `get_code` implementation or pipeline | `docs/extension/mcp-get-code-design.md` | -| MCP context and output strategy | `docs/extension/mcp-context-strategy.md` | -| Browser gateway, permissions, sessions, WebSocket, or asset transport | `docs/extension/mcp-browser-gateway-design.md` | -| Test selection and required checks | `TESTING.md` | -| Test architecture or coverage scope | `docs/testing/architecture.md` | +| Change area | Read first | +| --------------------------------------------------------------------- | ----------------------------------------------- | +| MCP `get_code` behavior or contract | `docs/extension/mcp-get-code-requirements.md` | +| MCP `get_code` implementation or pipeline | `docs/extension/mcp-get-code-design.md` | +| MCP context and output strategy | `docs/extension/mcp-context-strategy.md` | +| MCP canvas authoring | `docs/extension/mcp-canvas-authoring-design.md` | +| Browser gateway, permissions, sessions, WebSocket, or asset transport | `docs/extension/mcp-browser-gateway-design.md` | +| Test selection and required checks | `TESTING.md` | +| Test architecture or coverage scope | `docs/testing/architecture.md` | ## Git workflow diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 2f49a39a..f16fea57 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.21.0 + +- Added opt-in Figma canvas authoring for agents with query-ranked design-system discovery and one + declarative create or update result. +- Added safe incremental reconciliation with explicit update scopes, stable node identities, + component and variable reuse, no-op detection, validation, and automatic undo on failure. +- Added the `figma-canvas-authoring` agent skill, including a grounded workflow for empty Figma + documents. + ## 0.20.0 - Hid Figma's inactive mode switcher in read-only files after its toolbar structure changed. diff --git a/packages/extension/components/sections/AgentIntegrationSection.vue b/packages/extension/components/sections/AgentIntegrationSection.vue index d1291f8d..4617c317 100644 --- a/packages/extension/components/sections/AgentIntegrationSection.vue +++ b/packages/extension/components/sections/AgentIntegrationSection.vue @@ -8,9 +8,9 @@ import Tick from '@/components/icons/Tick.vue' import Section from '@/components/Section.vue' import SegmentedControl from '@/components/SegmentedControl.vue' import { MCP_PERMISSION_REQUEST_EVENT } from '@/mcp/permissions' -import { options } from '@/ui/state' +import { canvasWritesOn, options } from '@/ui/state' -const mcpOptions = [ +const toggleOptions = [ { label: 'Disabled', value: false, icon: Minus }, { label: 'Enabled', value: true, icon: Tick } ] @@ -22,6 +22,9 @@ function setMcpEnabled(enabled: boolean | undefined): void { window.dispatchEvent(new Event(MCP_PERMISSION_REQUEST_EVENT)) } options.value.mcpOn = enabled === true + if (!enabled) { + canvasWritesOn.value = false + } } @@ -35,13 +38,23 @@ function setMcpEnabled(enabled: boolean | undefined): void {
+
+ + +
+
diff --git a/packages/extension/composables/mcp.ts b/packages/extension/composables/mcp.ts index f4961445..02f1836b 100644 --- a/packages/extension/composables/mcp.ts +++ b/packages/extension/composables/mcp.ts @@ -22,7 +22,7 @@ import { import { coerceToolErrorPayload } from '@/mcp/errors' import { MCP_LOCAL_HOST_PERMISSION_ERROR, MCP_PERMISSION_REQUEST_EVENT } from '@/mcp/permissions' import { runMcpTool } from '@/mcp/runtime' -import { layoutReady, options, runtimeMode } from '@/ui/state' +import { canvasWritesOn, layoutReady, options, runtimeMode } from '@/ui/state' type PendingAssetUpload = { reject: (error: Error) => void @@ -107,6 +107,7 @@ export const useMcp = createSharedComposable(() => { status.value = state.status setAssetServerUrl(state.assetServerUrl ?? null) if (state.status !== 'connected') { + canvasWritesOn.value = false resetUploadedAssets() } return @@ -127,6 +128,7 @@ export const useMcp = createSharedComposable(() => { if (shouldEnable) { sendEnable() } else { + canvasWritesOn.value = false stop() } }, diff --git a/packages/extension/mcp/runtime.ts b/packages/extension/mcp/runtime.ts index 1ceb5994..dc483846 100644 --- a/packages/extension/mcp/runtime.ts +++ b/packages/extension/mcp/runtime.ts @@ -16,7 +16,9 @@ import { selection } from '@/ui/state' import type { GetCodeRuntimeOptions } from './tools/code' import { createCodedError } from './errors' +import { handleApplyCanvas } from './tools/canvas' import { handleGetCode as runGetCode } from './tools/code' +import { handleGetDesignSystem } from './tools/design-system' import { handleGetScreenshot as runGetScreenshot } from './tools/screenshot' import { handleGetStructure as runGetStructure } from './tools/structure' import { handleGetTokenDefs as runGetTokenDefs } from './tools/token' @@ -93,13 +95,17 @@ async function handleGetStructure(args?: GetStructureParametersInput): Promise Promise - get_token_defs: (args?: GetTokenDefsParametersInput) => Promise - get_screenshot: (args?: GetScreenshotParametersInput) => Promise - get_structure: (args?: GetStructureParametersInput) => Promise +export const MCP_TOOL_HANDLERS = { + apply_canvas: handleApplyCanvas, + get_code: handleGetCode, + get_design_system: handleGetDesignSystem, + get_token_defs: handleGetTokenDefs, + get_screenshot: handleGetScreenshot, + get_structure: handleGetStructure } +export type MCPHandlers = typeof MCP_TOOL_HANDLERS + export type TempadWindowHandlers = Omit & { get_code: (args?: WindowGetCodeParametersInput) => Promise } @@ -110,13 +116,6 @@ declare global { } } -export const MCP_TOOL_HANDLERS: MCPHandlers = { - get_code: handleGetCode, - get_token_defs: handleGetTokenDefs, - get_screenshot: handleGetScreenshot, - get_structure: handleGetStructure -} - export const WINDOW_TEMPAD_TOOL_HANDLERS: TempadWindowHandlers = { ...MCP_TOOL_HANDLERS, get_code: handleWindowGetCode diff --git a/packages/extension/mcp/tools/canvas.ts b/packages/extension/mcp/tools/canvas.ts new file mode 100644 index 00000000..f23fc455 --- /dev/null +++ b/packages/extension/mcp/tools/canvas.ts @@ -0,0 +1,761 @@ +import type { + ApplyCanvasParameters, + ApplyCanvasParametersInput, + ApplyCanvasResult, + CanvasDesignReference, + CanvasNodeSpec, + CanvasVariableBindings +} from '@tempad-dev/shared' + +import { ApplyCanvasParametersSchema, TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' + +import { canvasWritesOn } from '@/ui/state' + +import { createCodedError } from '../errors' + +const CANVAS_KEY_NAMESPACE = 'tempad-dev' +const CANVAS_KEY_NAME = 'canvas-key' +const SUPPORTED_NODE_TYPES = new Set([ + 'ELLIPSE', + 'FRAME', + 'INSTANCE', + 'LINE', + 'RECTANGLE', + 'TEXT' +]) + +type SupportedCanvasNode = Extract + +type ApplyState = { + claimedNodeIds: Set + componentCache: Map + createdNodeIds: Set + keyedNodes: Map + mutationCount: number + nodeIdsByKey: Record + scope: SupportedCanvasNode | null + updatedNodeIds: Set + variableCache: Map +} + +let applyInProgress = false + +function specError(message: string): never { + throw createCodedError(TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC, message) +} + +function scopeError(message: string): never { + throw createCodedError(TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SCOPE, message) +} + +function isSupportedSceneNode(node: BaseNode | null): node is SupportedCanvasNode { + return !!node && SUPPORTED_NODE_TYPES.has(node.type as CanvasNodeSpec['type']) +} + +function isWithinScope(node: BaseNode, scope: BaseNode): boolean { + let current: BaseNode | null = node + while (current) { + if (current.id === scope.id) return true + current = current.parent + } + return false +} + +function collectKeyedNodes(scope: SupportedCanvasNode): Map { + const keyed = new Map() + const stack: BaseNode[] = [scope] + while (stack.length) { + const node = stack.pop()! + if (isSupportedSceneNode(node)) { + const key = node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_KEY_NAME) + if (key) { + if (keyed.has(key)) { + scopeError(`Canvas key "${key}" is duplicated inside the update scope.`) + } + keyed.set(key, node) + } + } + if ('children' in node) { + stack.push(...node.children) + } + } + return keyed +} + +function markMutation(state: ApplyState, node: SupportedCanvasNode): void { + state.mutationCount += 1 + if (!state.createdNodeIds.has(node.id)) { + state.updatedNodeIds.add(node.id) + } +} + +function setNodeKey(state: ApplyState, node: SupportedCanvasNode, key: string): void { + const currentKey = node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_KEY_NAME) + if (currentKey === key) return + if (currentKey) { + specError(`Node "${node.id}" is already owned by canvas key "${currentKey}".`) + } + node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_KEY_NAME, key) + markMutation(state, node) +} + +function resolveExistingNode( + spec: CanvasNodeSpec, + state: ApplyState, + forcedNode?: SupportedCanvasNode +): SupportedCanvasNode | null { + let node = forcedNode ?? null + if (!node && spec.nodeId) { + const candidate = figma.getNodeById(spec.nodeId) + if (!isSupportedSceneNode(candidate)) { + scopeError(`Node "${spec.nodeId}" does not exist or is not supported by apply_canvas.`) + } + node = candidate + } else if (!node) { + node = state.keyedNodes.get(spec.key) ?? null + } + + if (!node) return null + const keyedNode = state.keyedNodes.get(spec.key) + if (keyedNode && keyedNode.id !== node.id) { + specError( + `Canvas key "${spec.key}" already identifies node "${keyedNode.id}", not "${node.id}".` + ) + } + if (state.scope && !isWithinScope(node, state.scope)) { + scopeError(`Node "${node.id}" is outside the requested update scope.`) + } + if (node.type !== spec.type) { + specError( + `Canvas key "${spec.key}" expects ${spec.type}, but node "${node.id}" is ${node.type}.` + ) + } + if (state.claimedNodeIds.has(node.id)) { + specError(`Node "${node.id}" is referenced more than once in the desired result.`) + } + state.claimedNodeIds.add(node.id) + return node +} + +function referenceCacheKey(reference: CanvasDesignReference): string { + return reference.id !== undefined ? `id:${reference.id}` : `key:${reference.key}` +} + +async function resolveComponent(reference: CanvasDesignReference, state: ApplyState) { + const cacheKey = referenceCacheKey(reference) + const cached = state.componentCache.get(cacheKey) + if (cached) return cached + + let component: ComponentNode | null = null + if (reference.id !== undefined) { + const node = figma.getNodeById(reference.id) + if (node?.type === 'COMPONENT') { + component = node + } else if (node?.type === 'COMPONENT_SET') { + component = node.defaultVariant + } + } else { + component = await figma.importComponentByKeyAsync(reference.key) + } + + if (!component) { + specError('The requested component could not be resolved.') + } + state.componentCache.set(cacheKey, component) + return component +} + +async function resolveVariable( + reference: CanvasDesignReference, + state: ApplyState +): Promise { + const cacheKey = referenceCacheKey(reference) + const cached = state.variableCache.get(cacheKey) + if (cached) return cached + + const variable = + reference.id !== undefined + ? await figma.variables.getVariableByIdAsync(reference.id) + : await figma.variables.importVariableByKeyAsync(reference.key) + if (!variable) { + specError('The requested variable could not be resolved.') + } + state.variableCache.set(cacheKey, variable) + return variable +} + +async function createNode(spec: CanvasNodeSpec, state: ApplyState): Promise { + let node: SupportedCanvasNode + switch (spec.type) { + case 'ELLIPSE': + node = figma.createEllipse() + break + case 'FRAME': + node = figma.createFrame() + break + case 'INSTANCE': { + const component = await resolveComponent(spec.component!, state) + node = component.createInstance() + break + } + case 'LINE': + node = figma.createLine() + break + case 'RECTANGLE': + node = figma.createRectangle() + break + case 'TEXT': + node = figma.createText() + break + } + state.mutationCount += 1 + state.createdNodeIds.add(node.id) + state.claimedNodeIds.add(node.id) + return node +} + +function moveIntoParent( + node: SupportedCanvasNode, + parent: FrameNode, + index: number, + state: ApplyState +): void { + if (node.parent?.id === parent.id && parent.children.indexOf(node) === index) return + parent.insertChild(index, node) + markMutation(state, node) +} + +function setValue( + node: SupportedCanvasNode, + current: T, + desired: T | undefined, + apply: (value: T) => void, + state: ApplyState +): void { + if (desired === undefined || Object.is(current, desired)) return + apply(desired) + markMutation(state, node) +} + +const PADDING_FIELDS = [ + ['top', 'paddingTop'], + ['right', 'paddingRight'], + ['bottom', 'paddingBottom'], + ['left', 'paddingLeft'] +] as const + +function applyLayout(node: FrameNode, spec: CanvasNodeSpec, state: ApplyState): void { + const layout = spec.layout + if (!layout) return + const bindings = spec.variables + + setValue(node, node.layoutMode, layout.mode, (value) => (node.layoutMode = value), state) + const hasAutoLayoutProperty = + layout.gap !== undefined || + layout.padding !== undefined || + layout.primaryAlign !== undefined || + layout.counterAlign !== undefined + if (hasAutoLayoutProperty && node.layoutMode === 'NONE') { + specError( + `FRAME "${spec.key}" must use HORIZONTAL or VERTICAL layout before setting layout details.` + ) + } + setValue( + node, + node.itemSpacing, + bindings?.gap ? undefined : layout.gap, + (value) => (node.itemSpacing = value), + state + ) + setValue( + node, + node.primaryAxisAlignItems, + layout.primaryAlign, + (value) => (node.primaryAxisAlignItems = value), + state + ) + setValue( + node, + node.counterAxisAlignItems, + layout.counterAlign, + (value) => (node.counterAxisAlignItems = value), + state + ) + + const padding = layout.padding + if (padding === undefined) return + for (const [side, field] of PADDING_FIELDS) { + const desired = typeof padding === 'number' ? padding : padding[side] + setValue( + node, + node[field], + bindings?.[field] ? undefined : desired, + (value) => (node[field] = value), + state + ) + } +} + +function applyPosition(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { + const position = spec.position + if (!position) return + setValue(node, node.x, position.x, (value) => (node.x = value), state) + setValue(node, node.y, position.y, (value) => (node.y = value), state) +} + +function applySize(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { + const size = spec.size + if (!size) return + const width = !spec.variables?.width && size.width !== undefined ? size.width : node.width + const height = + node.type !== 'LINE' && !spec.variables?.height && size.height !== undefined + ? size.height + : node.height + if (Math.abs(node.width - width) > 0.01 || Math.abs(node.height - height) > 0.01) { + node.resize(width, height) + markMutation(state, node) + } + setValue( + node, + node.layoutSizingHorizontal, + size.horizontal, + (value) => (node.layoutSizingHorizontal = value), + state + ) + setValue( + node, + node.layoutSizingVertical, + size.vertical, + (value) => (node.layoutSizingVertical = value), + state + ) +} + +function paintsEqual(current: readonly Paint[], desired: readonly SolidPaint[]): boolean { + return ( + current.length === desired.length && + current.every((paint, index) => { + const expected = desired[index]! + return ( + paint.type === 'SOLID' && + paint.color.r === expected.color.r && + paint.color.g === expected.color.g && + paint.color.b === expected.color.b && + (paint.opacity ?? 1) === (expected.opacity ?? 1) && + (paint.visible ?? true) === (expected.visible ?? true) && + (paint.blendMode ?? 'NORMAL') === (expected.blendMode ?? 'NORMAL') + ) + }) + ) +} + +function applyPaint( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + field: 'fill' | 'stroke', + state: ApplyState +): void { + const color = spec.appearance?.[field] + if (color === undefined) return + + const property = field === 'fill' ? 'fills' : 'strokes' + const paints = node[property] + const desired = color === null ? [] : [figma.util.solidPaint(color)] + const hasBinding = !!spec.variables?.[field] + if (hasBinding) { + if (paints !== figma.mixed && paints.length === 1 && paints[0]?.type === 'SOLID') return + if (color === null) { + const label = field === 'fill' ? 'Fill' : 'Stroke' + specError(`${label} variable binding on "${spec.key}" requires a solid fallback paint.`) + } + } else if (paints !== figma.mixed && paintsEqual(paints, desired)) { + return + } + + node[property] = desired + markMutation(state, node) +} + +function applyAppearance(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { + const appearance = spec.appearance + if (!appearance) return + + applyPaint(node, spec, 'fill', state) + applyPaint(node, spec, 'stroke', state) + if ('strokeWeight' in node) { + setValue( + node, + node.strokeWeight, + appearance.strokeWeight, + (value) => (node.strokeWeight = value), + state + ) + } + if ('cornerRadius' in node) { + setValue( + node, + node.cornerRadius, + spec.variables?.cornerRadius ? undefined : appearance.cornerRadius, + (value) => (node.cornerRadius = value), + state + ) + } + setValue( + node, + node.opacity, + spec.variables?.opacity ? undefined : appearance.opacity, + (value) => (node.opacity = value), + state + ) +} + +async function loadTextFonts(node: TextNode, spec: CanvasNodeSpec): Promise { + const text = spec.text + const currentFont = node.fontName + const fontFamily = spec.variables?.fontFamily ? undefined : text?.fontFamily + const fontStyle = spec.variables?.fontStyle ? undefined : text?.fontStyle + const hasExplicitFont = fontFamily !== undefined || fontStyle !== undefined + if (currentFont === figma.mixed && hasExplicitFont && (!fontFamily || !fontStyle)) { + specError( + `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` + ) + } + + const desiredFont: FontName | null = hasExplicitFont + ? { + family: fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family), + style: fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style) + } + : null + const fonts = desiredFont + ? [desiredFont] + : currentFont === figma.mixed + ? node.getRangeAllFontNames(0, node.characters.length) + : [currentFont] + const uniqueFonts = [ + ...new Map(fonts.map((font) => [`${font.family}\0${font.style}`, font])).values() + ] + await Promise.all(uniqueFonts.map((font) => figma.loadFontAsync(font))) + return desiredFont +} + +async function applyText(node: TextNode, spec: CanvasNodeSpec, state: ApplyState): Promise { + const text = spec.text + if (!text) return + const desiredFont = await loadTextFonts(node, spec) + if ( + desiredFont && + (node.fontName === figma.mixed || + node.fontName.family !== desiredFont.family || + node.fontName.style !== desiredFont.style) + ) { + node.fontName = desiredFont + markMutation(state, node) + } + setValue(node, node.characters, text.characters, (value) => (node.characters = value), state) + setValue( + node, + node.fontSize, + spec.variables?.fontSize ? undefined : text.fontSize, + (value) => (node.fontSize = value), + state + ) + setTextPixelValue( + node, + 'lineHeight', + spec.variables?.lineHeight ? undefined : text.lineHeight, + state + ) + setTextPixelValue( + node, + 'letterSpacing', + spec.variables?.letterSpacing ? undefined : text.letterSpacing, + state + ) + setValue( + node, + node.textAlignHorizontal, + text.alignHorizontal, + (value) => (node.textAlignHorizontal = value), + state + ) + setValue( + node, + node.textAlignVertical, + text.alignVertical, + (value) => (node.textAlignVertical = value), + state + ) +} + +function setTextPixelValue( + node: TextNode, + field: 'letterSpacing' | 'lineHeight', + desired: number | undefined, + state: ApplyState +): void { + if (desired === undefined) return + const current = node[field] + if (current !== figma.mixed && current.unit === 'PIXELS' && current.value === desired) return + node[field] = { unit: 'PIXELS', value: desired } + markMutation(state, node) +} + +async function applyComponent( + node: InstanceNode, + spec: CanvasNodeSpec, + state: ApplyState +): Promise { + const component = await resolveComponent(spec.component!, state) + const currentComponent = await node.getMainComponentAsync() + if (currentComponent?.id !== component.id) { + node.swapComponent(component) + markMutation(state, node) + } + + if (!spec.componentProperties) return + const changedProperties = Object.entries(spec.componentProperties).filter( + ([name, value]) => node.componentProperties[name]?.value !== value + ) + if (!changedProperties.length) return + node.setProperties(Object.fromEntries(changedProperties)) + markMutation(state, node) +} + +type DirectVariableField = Exclude + +const DIRECT_VARIABLE_FIELDS: Record< + DirectVariableField, + VariableBindableNodeField | VariableBindableTextField +> = { + width: 'width', + height: 'height', + gap: 'itemSpacing', + paddingTop: 'paddingTop', + paddingRight: 'paddingRight', + paddingBottom: 'paddingBottom', + paddingLeft: 'paddingLeft', + cornerRadius: 'cornerRadius', + opacity: 'opacity', + fontFamily: 'fontFamily', + fontStyle: 'fontStyle', + fontSize: 'fontSize', + lineHeight: 'lineHeight', + letterSpacing: 'letterSpacing' +} + +function currentBoundVariableId( + node: SupportedCanvasNode, + field: VariableBindableNodeField | VariableBindableTextField +): string | undefined { + const value = node.boundVariables?.[field] + const directId = Array.isArray(value) ? value[0]?.id : value?.id + if (directId || field !== 'cornerRadius') return directId + + const aliases = [ + node.boundVariables?.topLeftRadius, + node.boundVariables?.topRightRadius, + node.boundVariables?.bottomLeftRadius, + node.boundVariables?.bottomRightRadius + ] + const radiusId = aliases[0]?.id + return radiusId && aliases.every((alias) => alias?.id === radiusId) ? radiusId : undefined +} + +function applyPaintVariable( + node: SupportedCanvasNode, + field: 'fill' | 'stroke', + variable: Variable, + state: ApplyState +): void { + const property = field === 'fill' ? 'fills' : 'strokes' + const currentPaints = node[property] + if (currentPaints === figma.mixed) { + specError(`${field} variable bindings cannot target mixed paints on node "${node.id}".`) + } + const paints = [...currentPaints] + if (paints.length !== 1 || paints[0]?.type !== 'SOLID') { + specError(`${field} variable bindings require exactly one solid paint on node "${node.id}".`) + } + const currentVariable = node.boundVariables?.[property]?.[0] + if (currentVariable?.id === variable.id) return + paints[0] = figma.variables.setBoundVariableForPaint(paints[0], 'color', variable) + node[property] = paints + markMutation(state, node) +} + +async function applyVariables( + node: SupportedCanvasNode, + bindings: CanvasVariableBindings | undefined, + state: ApplyState +): Promise { + if (!bindings) return + for (const field of Object.keys(bindings) as Array) { + const reference = bindings[field] + if (!reference) continue + const variable = await resolveVariable(reference, state) + if (field === 'fill' || field === 'stroke') { + applyPaintVariable(node, field, variable, state) + continue + } + const figmaField = DIRECT_VARIABLE_FIELDS[field] + if (currentBoundVariableId(node, figmaField) === variable.id) continue + node.setBoundVariable(figmaField, variable) + markMutation(state, node) + } +} + +async function applyNodeProperties( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): Promise { + setValue(node, node.name, spec.name, (value) => (node.name = value), state) + setValue(node, node.visible, spec.visible, (value) => (node.visible = value), state) + if (node.type === 'FRAME') applyLayout(node, spec, state) + applyPosition(node, spec, state) + applySize(node, spec, state) + applyAppearance(node, spec, state) + if (node.type === 'TEXT') await applyText(node, spec, state) + if (node.type === 'INSTANCE') await applyComponent(node, spec, state) + await applyVariables(node, spec.variables, state) +} + +async function reconcileNode( + spec: CanvasNodeSpec, + state: ApplyState, + parent?: FrameNode, + index = 0, + forcedNode?: SupportedCanvasNode +): Promise { + const existing = resolveExistingNode(spec, state, forcedNode) + const node = existing ?? (await createNode(spec, state)) + + if (parent) moveIntoParent(node, parent, index, state) + setNodeKey(state, node, spec.key) + await applyNodeProperties(node, spec, state) + state.nodeIdsByKey[spec.key] = node.id + + if (spec.children?.length) { + if (node.type !== 'FRAME') { + specError(`Only FRAME nodes can contain desired children; "${spec.key}" is ${node.type}.`) + } + for (const [childIndex, child] of spec.children.entries()) { + await reconcileNode(child, state, node, childIndex) + } + } + return node +} + +function placeCreatedRoot( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + if (spec.position?.x !== undefined || spec.position?.y !== undefined) return + const center = figma.viewport.center + const x = center.x - node.width / 2 + const y = center.y - node.height / 2 + if (node.x === x && node.y === y) return + node.x = x + node.y = y + markMutation(state, node) +} + +async function applyParsedCanvas(input: ApplyCanvasParameters): Promise { + let target: SupportedCanvasNode | null = null + if (input.mode === 'update') { + const candidate = figma.getNodeById(input.targetNodeId!) + if (!isSupportedSceneNode(candidate)) { + scopeError('The requested update target does not exist or is not a supported scene node.') + } + target = candidate + } + if (target && target.type !== input.root.type) { + specError( + `The update root expects ${input.root.type}, but target "${target.id}" is ${target.type}.` + ) + } + + const state: ApplyState = { + claimedNodeIds: new Set(), + componentCache: new Map(), + createdNodeIds: new Set(), + keyedNodes: target ? collectKeyedNodes(target) : new Map(), + mutationCount: 0, + nodeIdsByKey: Object.create(null) as Record, + scope: target, + updatedNodeIds: new Set(), + variableCache: new Map() + } + + figma.commitUndo() + try { + const root = await reconcileNode(input.root, state, undefined, 0, target ?? undefined) + if (input.mode === 'create') { + placeCreatedRoot(root, input.root, state) + } + figma.commitUndo() + return { + rootNodeId: root.id, + nodeIdsByKey: state.nodeIdsByKey, + createdNodeIds: [...state.createdNodeIds], + updatedNodeIds: [...state.updatedNodeIds], + mutationCount: state.mutationCount + } + } catch (error) { + try { + figma.triggerUndo() + } catch { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + 'Canvas apply failed and automatic rollback was not available. Use Figma Undo.' + ) + } + throw error + } +} + +export async function handleApplyCanvas( + args?: ApplyCanvasParametersInput +): Promise { + if (!canvasWritesOn.value) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_WRITE_DISABLED, + 'Canvas writing is disabled. Enable Canvas writes in TemPad Dev → Agent integration.' + ) + } + if (figma.editorType !== 'figma') { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_UNSUPPORTED_EDITOR, + 'Canvas authoring is supported only in Figma Design files.' + ) + } + if (applyInProgress) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_BUSY, + 'Another apply_canvas call is already running in this Figma session.' + ) + } + + const parsed = ApplyCanvasParametersSchema.safeParse(args) + if (!parsed.success) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC, + parsed.error.issues.map((issue) => issue.message).join(' ') + ) + } + + applyInProgress = true + try { + return await applyParsedCanvas(parsed.data) + } catch (error) { + if (error instanceof Error && 'code' in error) throw error + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + error instanceof Error ? error.message : 'Canvas apply failed.' + ) + } finally { + applyInProgress = false + } +} diff --git a/packages/extension/mcp/tools/design-system.ts b/packages/extension/mcp/tools/design-system.ts new file mode 100644 index 00000000..33db8246 --- /dev/null +++ b/packages/extension/mcp/tools/design-system.ts @@ -0,0 +1,251 @@ +import type { + DesignSystemComponent, + DesignSystemComponentProperty, + DesignSystemVariable, + GetDesignSystemParametersInput, + GetDesignSystemResult +} from '@tempad-dev/shared' + +const MAX_COMPONENTS = 40 +const MAX_VARIABLES = 60 + +function normalizeSearchText(value: string): string { + return value + .toLowerCase() + .replaceAll(/[^\p{L}\p{N}]+/gu, ' ') + .trim() +} + +function queryTerms(query?: string): string[] { + return query ? normalizeSearchText(query).split(/\s+/).filter(Boolean) : [] +} + +function scoreCandidate(name: string, searchText: string, terms: string[]): number { + if (!terms.length) return 1 + const normalizedName = normalizeSearchText(name) + let score = 0 + for (const term of terms) { + if (normalizedName === term) { + score += 20 + } else if (normalizedName.startsWith(term)) { + score += 10 + } else if (normalizedName.includes(term)) { + score += 6 + } else if (searchText.includes(term)) { + score += 2 + } + } + return score +} + +function rankAndLimit( + items: T[], + terms: string[], + getSearchText: (item: T) => string, + limit: number +): T[] { + return items + .map((item) => ({ + item, + score: scoreCandidate(item.name, normalizeSearchText(getSearchText(item)), terms) + })) + .filter((entry) => entry.score > 0) + .sort( + (left, right) => right.score - left.score || left.item.name.localeCompare(right.item.name) + ) + .slice(0, limit) + .map((entry) => entry.item) +} + +async function readOrNull(read: () => Promise): Promise { + try { + return await read() + } catch { + return null + } +} + +function componentProperties( + definitions: ComponentPropertyDefinitions +): Record | undefined { + const entries = Object.entries(definitions).map(([name, definition]) => { + const options = + definition.type === 'VARIANT' + ? definition.variantOptions + : definition.preferredValues?.map((value) => value.key) + return [ + name, + { + type: definition.type, + defaultValue: definition.defaultValue, + ...(options?.length ? { options } : {}) + } + ] as const + }) + return entries.length ? Object.fromEntries(entries) : undefined +} + +function describeComponent(component: ComponentNode): DesignSystemComponent { + const componentSet = component.parent?.type === 'COMPONENT_SET' ? component.parent : null + const definitions = + componentSet?.componentPropertyDefinitions ?? component.componentPropertyDefinitions + const properties = componentProperties(definitions) + const description = component.description.trim() + return { + id: component.id, + key: component.key, + name: component.name, + ...(description ? { description } : {}), + ...(componentSet ? { componentSetName: componentSet.name } : {}), + ...(properties ? { properties } : {}), + remote: component.remote + } +} + +async function collectComponents(warnings: string[]): Promise { + const localComponents = figma.currentPage.findAllWithCriteria({ + types: ['COMPONENT'] + }) + const byId = new Map(localComponents.map((component) => [component.id, component])) + + const instances = figma.currentPage.findAllWithCriteria({ + types: ['INSTANCE'] + }) + const mainComponents = await Promise.all( + instances.map((instance) => readOrNull(() => instance.getMainComponentAsync())) + ) + for (const component of mainComponents) { + if (component) byId.set(component.id, component) + } + + if (!byId.size) { + warnings.push('No components were found on the current page.') + } + return [...byId.values()].map(describeComponent) +} + +async function collectVariables(warnings: string[]): Promise { + try { + const [localVariables, localCollections] = await Promise.all([ + figma.variables.getLocalVariablesAsync(), + figma.variables.getLocalVariableCollectionsAsync() + ]) + const variablesById = new Map(localVariables.map((variable) => [variable.id, variable])) + const boundVariableIds = new Set() + for (const node of figma.currentPage.findAll()) { + if ('boundVariables' in node) { + collectVariableAliasIds(node.boundVariables, boundVariableIds) + } + } + const remoteVariables = await Promise.all( + [...boundVariableIds] + .filter((id) => !variablesById.has(id)) + .map((id) => readOrNull(() => figma.variables.getVariableByIdAsync(id))) + ) + for (const variable of remoteVariables) { + if (variable) variablesById.set(variable.id, variable) + } + + const variables = [...variablesById.values()] + const collectionsById = new Map( + localCollections.map((collection) => [collection.id, collection.name]) + ) + const remoteCollectionIds = [ + ...new Set( + variables + .map((variable) => variable.variableCollectionId) + .filter((id) => !collectionsById.has(id)) + ) + ] + const remoteCollections = await Promise.all( + remoteCollectionIds.map((id) => + readOrNull(() => figma.variables.getVariableCollectionByIdAsync(id)) + ) + ) + for (const collection of remoteCollections) { + if (collection) collectionsById.set(collection.id, collection.name) + } + + if (!variables.length) { + warnings.push('No local or currently bound variables were found.') + } + return variables.map((variable) => { + const description = variable.description.trim() + const scopes = variable.scopes?.map(String) + return { + id: variable.id, + key: variable.key, + name: variable.name, + collectionName: collectionsById.get(variable.variableCollectionId) ?? 'Unknown collection', + ...(description ? { description } : {}), + remote: variable.remote, + resolvedType: variable.resolvedType, + ...(scopes?.length ? { scopes } : {}) + } + }) + } catch { + warnings.push('Variables could not be read in the current Figma context.') + return [] + } +} + +function collectVariableAliasIds(value: unknown, ids: Set): void { + if (Array.isArray(value)) { + value.forEach((item) => collectVariableAliasIds(item, ids)) + return + } + if (!value || typeof value !== 'object') return + const record = value as Record + if (record.type === 'VARIABLE_ALIAS' && typeof record.id === 'string') { + ids.add(record.id) + return + } + Object.values(record).forEach((item) => collectVariableAliasIds(item, ids)) +} + +export async function handleGetDesignSystem( + args?: GetDesignSystemParametersInput +): Promise { + const componentWarnings: string[] = [] + const variableWarnings: string[] = [] + const terms = queryTerms(args?.query) + const [components, variables] = await Promise.all([ + collectComponents(componentWarnings), + collectVariables(variableWarnings) + ]) + const warnings = [...componentWarnings, ...variableWarnings] + + const rankedComponents = rankAndLimit( + components, + terms, + (component) => + [ + component.name, + component.componentSetName, + component.description, + ...Object.keys(component.properties ?? {}) + ] + .filter(Boolean) + .join(' '), + MAX_COMPONENTS + ) + const rankedVariables = rankAndLimit( + variables, + terms, + (variable) => + [variable.name, variable.collectionName, variable.description, ...(variable.scopes ?? [])] + .filter(Boolean) + .join(' '), + MAX_VARIABLES + ) + + return { + page: { + id: figma.currentPage.id, + name: figma.currentPage.name + }, + components: rankedComponents, + variables: rankedVariables, + ...(warnings.length ? { warnings } : {}) + } +} diff --git a/packages/extension/package.json b/packages/extension/package.json index 045a4635..28f4bbfc 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@tempad-dev/extension", - "version": "0.20.0", + "version": "0.21.0", "private": true, "description": "Open handoff tooling for Figma", "type": "module", diff --git a/packages/extension/tests/composables/mcp.test.ts b/packages/extension/tests/composables/mcp.test.ts index d2b99010..7e6130bc 100644 --- a/packages/extension/tests/composables/mcp.test.ts +++ b/packages/extension/tests/composables/mcp.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => { } const runtimeMode = { value: 'standard' } const layoutReady = { value: true } + const canvasWritesOn = { value: false } const listeners: Array<(event: MessageEvent) => void> = [] const window = { dispatchEvent: vi.fn(), @@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => { } return { + canvasWritesOn, layoutReady, listeners, options, @@ -78,6 +80,7 @@ vi.mock('@/mcp/runtime', () => ({ })) vi.mock('@/ui/state', () => ({ + canvasWritesOn: mocks.canvasWritesOn, layoutReady: mocks.layoutReady, options: mocks.options, runtimeMode: mocks.runtimeMode @@ -132,6 +135,7 @@ function receive(message: BridgeToPageMessage): void { describe('composables/mcp', () => { beforeEach(() => { mocks.options.value.mcpOn = true + mocks.canvasWritesOn.value = false mocks.runtimeMode.value = 'standard' mocks.layoutReady.value = true mocks.listeners.length = 0 @@ -144,14 +148,16 @@ describe('composables/mcp', () => { vi.stubGlobal('location', { origin: ORIGIN }) }) - it('keeps MCP enabled and retries permission from user action', () => { + it('keeps MCP enabled but drops writes while retrying local-host permission', () => { const mcp = useMcp() const sessionId = getPostedMessage('mcp.enable').sessionId + mocks.canvasWritesOn.value = true receive(bridgeState(sessionId, 'connecting', MCP_LOCAL_HOST_PERMISSION_ERROR)) expect(mcp.needsLocalHostPermission.value).toBe(true) expect(mocks.options.value.mcpOn).toBe(true) + expect(mocks.canvasWritesOn.value).toBe(false) expect( mocks.window.postMessage.mock.calls.some( ([payload]) => (payload as PageToBridgeMessage).type === 'mcp.disable' @@ -181,4 +187,13 @@ describe('composables/mcp', () => { ) ).toBe(false) }) + + it('turns off session canvas writes when MCP cannot stay enabled', () => { + mocks.options.value.mcpOn = false + mocks.canvasWritesOn.value = true + + useMcp() + + expect(mocks.canvasWritesOn.value).toBe(false) + }) }) diff --git a/packages/extension/tests/mcp/runtime.test.ts b/packages/extension/tests/mcp/runtime.test.ts index 3a1e2779..9e1a2f77 100644 --- a/packages/extension/tests/mcp/runtime.test.ts +++ b/packages/extension/tests/mcp/runtime.test.ts @@ -5,7 +5,9 @@ const mocks = vi.hoisted(() => ({ selection: { value: [] as Array<{ visible: boolean }> }, + runApplyCanvas: vi.fn(), runGetCode: vi.fn(), + runGetDesignSystem: vi.fn(), runGetScreenshot: vi.fn(), runGetStructure: vi.fn(), runGetTokenDefs: vi.fn() @@ -19,6 +21,14 @@ vi.mock('@/mcp/tools/code', () => ({ handleGetCode: mocks.runGetCode })) +vi.mock('@/mcp/tools/canvas', () => ({ + handleApplyCanvas: mocks.runApplyCanvas +})) + +vi.mock('@/mcp/tools/design-system', () => ({ + handleGetDesignSystem: mocks.runGetDesignSystem +})) + vi.mock('@/mcp/tools/screenshot', () => ({ handleGetScreenshot: mocks.runGetScreenshot })) @@ -63,7 +73,9 @@ describe('mcp/runtime', () => { const runtime = await importRuntime() expect(Object.keys(runtime.MCP_TOOL_HANDLERS)).toEqual([ + 'apply_canvas', 'get_code', + 'get_design_system', 'get_token_defs', 'get_screenshot', 'get_structure' @@ -80,7 +92,9 @@ describe('mcp/runtime', () => { const tools = (window as Window & { tempadTools: Record }).tempadTools expect(tools.existing).toBe(existing) + expect(tools.apply_canvas).toBe(runtime.MCP_TOOL_HANDLERS.apply_canvas) expect(tools.get_code).toBe(runtime.WINDOW_TEMPAD_TOOL_HANDLERS.get_code) + expect(tools.get_design_system).toBe(runtime.MCP_TOOL_HANDLERS.get_design_system) expect(tools.get_token_defs).toBe(runtime.MCP_TOOL_HANDLERS.get_token_defs) expect(tools.get_screenshot).toBe(runtime.MCP_TOOL_HANDLERS.get_screenshot) expect(tools.get_structure).toBe(runtime.MCP_TOOL_HANDLERS.get_structure) @@ -93,7 +107,9 @@ describe('mcp/runtime', () => { const runtime = await importRuntime() const tools = (window as Window & { tempadTools: Record }).tempadTools + expect(tools.apply_canvas).toBe(runtime.MCP_TOOL_HANDLERS.apply_canvas) expect(tools.get_code).toBe(runtime.WINDOW_TEMPAD_TOOL_HANDLERS.get_code) + expect(tools.get_design_system).toBe(runtime.MCP_TOOL_HANDLERS.get_design_system) expect(tools.get_token_defs).toBe(runtime.MCP_TOOL_HANDLERS.get_token_defs) expect(tools.get_screenshot).toBe(runtime.MCP_TOOL_HANDLERS.get_screenshot) expect(tools.get_structure).toBe(runtime.MCP_TOOL_HANDLERS.get_structure) diff --git a/packages/extension/tests/mcp/tools/canvas.test.ts b/packages/extension/tests/mcp/tools/canvas.test.ts new file mode 100644 index 00000000..3ab34fdc --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas.test.ts @@ -0,0 +1,674 @@ +import type { ApplyCanvasParametersInput, CanvasNodeSpec } from '@tempad-dev/shared' + +import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + canvasWritesOn: { value: false } +})) + +vi.mock('@/ui/state', () => ({ + canvasWritesOn: mocks.canvasWritesOn +})) + +import { handleApplyCanvas } from '@/mcp/tools/canvas' + +const MIXED = Symbol('mixed') +const PAGE = { + id: '0:1', + name: 'Page 1', + type: 'PAGE', + parent: null, + children: [] as SceneNode[] +} + +type Mutable = T extends unknown ? { -readonly [Key in keyof T]: T[Key] } : never + +type MutableNode = Mutable & { + boundVariables: Record + children: SceneNode[] + componentProperties: Record + mainComponent?: ComponentNode + parent: BaseNode | null +} + +type SupportedMockNode = Extract + +type FigmaFixture = { + commitUndo: ReturnType + createNode: (type: CanvasNodeSpec['type']) => MutableNode + getNode: (id: string) => MutableNode + importComponentByKeyAsync: ReturnType + loadFontAsync: ReturnType + nodes: Map + triggerUndo: ReturnType +} + +function solidPaint(color = { r: 1, g: 1, b: 1 }, opacity = 1): SolidPaint { + return { + type: 'SOLID', + color, + opacity, + visible: true, + blendMode: 'NORMAL' + } +} + +function normalizePaint(paint: Paint): Paint { + return paint.type === 'SOLID' + ? { + ...paint, + opacity: paint.opacity ?? 1, + visible: paint.visible ?? true, + blendMode: paint.blendMode ?? 'NORMAL' + } + : paint +} + +function createFixture(): FigmaFixture { + let nextId = 1 + const nodes = new Map() + PAGE.children.length = 0 + + function createNode(type: CanvasNodeSpec['type']): MutableNode { + const id = `node:${nextId++}` + const pluginData = new Map() + const boundVariables: Record = {} + let fills: readonly Paint[] = [solidPaint()] + let strokes: readonly Paint[] = [] + const node = { + id, + type, + name: '', + visible: true, + x: 0, + y: 0, + width: 100, + height: type === 'LINE' ? 0 : 100, + parent: PAGE, + opacity: 1, + strokeWeight: 1, + cornerRadius: 0, + layoutSizingHorizontal: 'FIXED', + layoutSizingVertical: 'FIXED', + boundVariables, + getSharedPluginData(namespace: string, key: string) { + return pluginData.get(`${namespace}:${key}`) ?? '' + }, + setSharedPluginData(namespace: string, key: string, value: string) { + pluginData.set(`${namespace}:${key}`, value) + }, + resize(width: number, height: number) { + node.width = width + node.height = height + }, + setBoundVariable: vi.fn((field: string, variable: Variable) => { + const alias = { type: 'VARIABLE_ALIAS', id: variable.id } + if (field === 'cornerRadius' && (type === 'FRAME' || type === 'RECTANGLE')) { + boundVariables.topLeftRadius = alias + boundVariables.topRightRadius = alias + boundVariables.bottomLeftRadius = alias + boundVariables.bottomRightRadius = alias + } else { + boundVariables[field] = alias + } + }) + } as unknown as MutableNode + + function normalizePaints( + value: readonly Paint[], + field: 'fills' | 'strokes' + ): readonly Paint[] { + const paints = value.map(normalizePaint) + const aliases = paints + .map((paint) => ('boundVariables' in paint ? paint.boundVariables?.color : undefined)) + .filter((alias): alias is VariableAlias => !!alias) + if (aliases.length) boundVariables[field] = aliases + else delete boundVariables[field] + return paints + } + + Object.defineProperties(node, { + fills: { + get: () => fills, + set: (value: readonly Paint[]) => { + fills = normalizePaints(value, 'fills') + } + }, + strokes: { + get: () => strokes, + set: (value: readonly Paint[]) => { + strokes = normalizePaints(value, 'strokes') + } + } + }) + + if (type === 'FRAME') { + Object.assign(node, { + children: [] as SceneNode[], + layoutMode: 'NONE', + itemSpacing: 0, + primaryAxisAlignItems: 'MIN', + counterAxisAlignItems: 'MIN', + paddingTop: 10, + paddingRight: 11, + paddingBottom: 12, + paddingLeft: 13, + insertChild(index: number, child: MutableNode) { + const oldParent = child.parent as (BaseNode & { children?: SceneNode[] }) | null + if (oldParent?.children) { + const oldIndex = oldParent.children.indexOf(child) + if (oldIndex >= 0) oldParent.children.splice(oldIndex, 1) + } + child.parent = node as unknown as FrameNode + node.children.splice(index, 0, child) + } + }) + } + + if (type === 'TEXT') { + Object.assign(node, { + characters: '', + fontName: { family: 'Inter', style: 'Regular' }, + fontSize: 12, + lineHeight: { unit: 'AUTO' }, + letterSpacing: { unit: 'PIXELS', value: 0 }, + textAlignHorizontal: 'LEFT', + textAlignVertical: 'TOP', + getRangeAllFontNames: vi.fn(() => [{ family: 'Inter', style: 'Regular' } as FontName]) + }) + } + + PAGE.children.push(node) + nodes.set(id, node) + return node + } + + const component = { + id: 'component:1', + type: 'COMPONENT', + key: 'component-key', + createInstance: () => { + const instance = createNode('INSTANCE') + instance.mainComponent = component as ComponentNode + Object.assign(instance, { + componentProperties: { + Label: { type: 'TEXT', value: 'Default' }, + Disabled: { type: 'BOOLEAN', value: false } + }, + getMainComponentAsync: vi.fn(() => Promise.resolve(instance.mainComponent ?? null)), + swapComponent: vi.fn((next: ComponentNode) => { + instance.mainComponent = next + }), + setProperties: vi.fn((properties: Record) => { + for (const [name, value] of Object.entries(properties)) { + const current = instance.componentProperties[name] + instance.componentProperties[name] = { type: current?.type ?? 'TEXT', value } + } + }) + }) + return instance as unknown as InstanceNode + } + } as ComponentNode + nodes.set(component.id, component) + + const colorVariable = { + id: 'variable:color', + key: 'color-key' + } as Variable + const spacingVariable = { + id: 'variable:spacing', + key: 'spacing-key' + } as Variable + const fontVariable = { + id: 'variable:font', + key: 'font-key' + } as Variable + const variablesById = new Map([ + [spacingVariable.id, spacingVariable], + [fontVariable.id, fontVariable] + ]) + const commitUndo = vi.fn() + const triggerUndo = vi.fn() + const loadFontAsync = vi.fn().mockResolvedValue(undefined) + const importComponentByKeyAsync = vi.fn().mockResolvedValue(component) + + vi.stubGlobal('figma', { + editorType: 'figma', + mixed: MIXED, + viewport: { center: { x: 500, y: 400 } }, + commitUndo, + triggerUndo, + getNodeById: vi.fn((id: string) => nodes.get(id) ?? null), + createEllipse: vi.fn(() => createNode('ELLIPSE')), + createFrame: vi.fn(() => createNode('FRAME')), + createLine: vi.fn(() => createNode('LINE')), + createRectangle: vi.fn(() => createNode('RECTANGLE')), + createText: vi.fn(() => createNode('TEXT')), + importComponentByKeyAsync, + loadFontAsync, + util: { + solidPaint: vi.fn((color: string) => { + const hex = color.slice(1) + return { + type: 'SOLID', + color: { + r: Number.parseInt(hex.slice(0, 2), 16) / 255, + g: Number.parseInt(hex.slice(2, 4), 16) / 255, + b: Number.parseInt(hex.slice(4, 6), 16) / 255 + }, + opacity: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1 + } as SolidPaint + }) + }, + variables: { + getVariableByIdAsync: vi.fn((id: string) => Promise.resolve(variablesById.get(id) ?? null)), + importVariableByKeyAsync: vi.fn((key: string) => + Promise.resolve(key === colorVariable.key ? colorVariable : null) + ), + setBoundVariableForPaint: vi.fn((paint: SolidPaint, _field: string, variable: Variable) => ({ + ...paint, + boundVariables: { + color: { type: 'VARIABLE_ALIAS', id: variable.id } + } + })) + } + } as unknown as PluginAPI) + + return { + commitUndo, + createNode, + getNode(id: string) { + const node = nodes.get(id) + if (!node || node.type === 'COMPONENT') throw new Error(`Missing mock node ${id}`) + return node as MutableNode + }, + importComponentByKeyAsync, + loadFontAsync, + nodes, + triggerUndo + } +} + +function createSpec(): ApplyCanvasParametersInput { + return { + mode: 'create', + root: { + key: 'card', + type: 'FRAME', + name: 'Card', + size: { width: 320, height: 200, horizontal: 'HUG', vertical: 'FIXED' }, + layout: { + mode: 'HORIZONTAL', + gap: 8, + padding: { top: 16 }, + primaryAlign: 'SPACE_BETWEEN', + counterAlign: 'CENTER' + }, + appearance: { + fill: '#336699CC', + stroke: '#112233', + strokeWeight: 2, + cornerRadius: 12, + opacity: 0.8 + }, + variables: { + fill: { key: 'color-key' }, + stroke: { key: 'color-key' }, + gap: { id: 'variable:spacing' }, + paddingRight: { id: 'variable:spacing' } + }, + children: [ + { + key: 'card/title', + type: 'TEXT', + name: 'Title', + text: { + characters: 'Hello', + fontFamily: 'Inter', + fontStyle: 'Semi Bold', + fontSize: 18, + lineHeight: 24, + letterSpacing: 0.5, + alignHorizontal: 'CENTER', + alignVertical: 'CENTER' + } + }, + { + key: 'card/body', + type: 'RECTANGLE', + size: { width: 80, height: 40 }, + appearance: { fill: '#ABCDEF', cornerRadius: 8 } + }, + { + key: 'card/dot', + type: 'ELLIPSE', + size: { width: 12, height: 12 } + }, + { + key: 'card/divider', + type: 'LINE', + size: { width: 120 }, + appearance: { stroke: '#000000', strokeWeight: 1 } + }, + { + key: 'card/action', + type: 'INSTANCE', + component: { key: 'component-key' }, + componentProperties: { Label: 'Save', Disabled: true } + } + ] + } + } +} + +beforeEach(() => { + mocks.canvasWritesOn.value = true +}) + +afterEach(() => { + mocks.canvasWritesOn.value = false + PAGE.children.length = 0 + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('mcp/tools/canvas', () => { + it('gates writes and validates the editor and desired result before mutation', async () => { + createFixture() + mocks.canvasWritesOn.value = false + await expect(handleApplyCanvas(createSpec())).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_WRITE_DISABLED + }) + + mocks.canvasWritesOn.value = true + Object.assign(figma, { editorType: 'figjam' }) + await expect(handleApplyCanvas(createSpec())).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_UNSUPPORTED_EDITOR + }) + + Object.assign(figma, { editorType: 'figma' }) + await expect(handleApplyCanvas()).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC + }) + }) + + it('creates one result tree, applies design-system references, and centers the root', async () => { + const fixture = createFixture() + const result = await handleApplyCanvas(createSpec()) + + expect(result.createdNodeIds).toHaveLength(6) + expect(result.updatedNodeIds).toEqual([]) + expect(Object.keys(result.nodeIdsByKey)).toEqual([ + 'card', + 'card/title', + 'card/body', + 'card/dot', + 'card/divider', + 'card/action' + ]) + expect(fixture.commitUndo).toHaveBeenCalledTimes(2) + expect(fixture.triggerUndo).not.toHaveBeenCalled() + expect(fixture.importComponentByKeyAsync).toHaveBeenCalledWith('component-key') + + const root = fixture.getNode(result.rootNodeId) as unknown as FrameNode + expect(root.children).toHaveLength(5) + expect(root.x).toBe(340) + expect(root.y).toBe(300) + expect(root.layoutMode).toBe('HORIZONTAL') + expect(root.paddingTop).toBe(16) + expect(root.paddingRight).toBe(11) + expect(root.paddingBottom).toBe(12) + expect(root.paddingLeft).toBe(13) + expect(root.boundVariables).toMatchObject({ + fills: [{ id: 'variable:color' }], + strokes: [{ id: 'variable:color' }], + itemSpacing: { id: 'variable:spacing' }, + paddingRight: { id: 'variable:spacing' } + }) + + const title = fixture.getNode(result.nodeIdsByKey['card/title'] ?? '') as unknown as TextNode + expect(title.characters).toBe('Hello') + expect(title.fontName).toEqual({ family: 'Inter', style: 'Semi Bold' }) + expect(fixture.loadFontAsync).toHaveBeenCalledWith({ + family: 'Inter', + style: 'Semi Bold' + }) + + const action = fixture.getNode( + result.nodeIdsByKey['card/action'] ?? '' + ) as unknown as InstanceNode + expect(action.componentProperties.Label?.value).toBe('Save') + expect(action.componentProperties.Disabled?.value).toBe(true) + }) + + it('reconciles against live state, skips an unchanged result, and preserves omissions', async () => { + const fixture = createFixture() + const created = await handleApplyCanvas(createSpec()) + const root = fixture.getNode(created.rootNodeId) as unknown as FrameNode + const unmanaged = fixture.createNode('RECTANGLE') + root.insertChild(root.children.length, unmanaged) + + const update: ApplyCanvasParametersInput = { + ...createSpec(), + mode: 'update', + targetNodeId: created.rootNodeId + } + const unchanged = await handleApplyCanvas(update) + expect(unchanged.mutationCount).toBe(0) + expect(unchanged.createdNodeIds).toEqual([]) + expect(unchanged.updatedNodeIds).toEqual([]) + expect(root.children).toContain(unmanaged) + + const changed = await handleApplyCanvas({ + mode: 'update', + targetNodeId: created.rootNodeId, + root: { + key: 'card', + type: 'FRAME', + children: [ + { + key: 'card/title', + nodeId: created.nodeIdsByKey['card/title'], + type: 'TEXT', + text: { characters: 'Updated' } + } + ] + } + }) + expect(changed.mutationCount).toBe(1) + expect(changed.updatedNodeIds).toEqual([created.nodeIdsByKey['card/title']]) + expect(root.children).toContain(unmanaged) + expect( + (fixture.getNode(created.nodeIdsByKey['card/title'] ?? '') as unknown as TextNode).characters + ).toBe('Updated') + }) + + it('treats independent corner-radius bindings as unchanged', async () => { + const fixture = createFixture() + const input: ApplyCanvasParametersInput = { + mode: 'create', + root: { + key: 'card', + type: 'FRAME', + layout: { mode: 'VERTICAL', padding: 8 }, + appearance: { cornerRadius: 8 }, + variables: { cornerRadius: { id: 'variable:spacing' } } + } + } + const created = await handleApplyCanvas(input) + const root = fixture.getNode(created.rootNodeId) as unknown as FrameNode + + expect([root.paddingTop, root.paddingRight, root.paddingBottom, root.paddingLeft]).toEqual([ + 8, 8, 8, 8 + ]) + + await expect( + handleApplyCanvas({ + ...input, + mode: 'update', + targetNodeId: created.rootNodeId + }) + ).resolves.toMatchObject({ + mutationCount: 0, + updatedNodeIds: [] + }) + }) + + it('applies an unbound font field when the other field uses a variable', async () => { + const fixture = createFixture() + const input: ApplyCanvasParametersInput = { + mode: 'create', + root: { + key: 'root', + type: 'FRAME', + children: [ + { + key: 'root/title', + type: 'TEXT', + text: { + fontFamily: 'Ignored fallback', + fontStyle: 'Semi Bold' + }, + variables: { + fontFamily: { id: 'variable:font' } + } + } + ] + } + } + const created = await handleApplyCanvas(input) + const title = fixture.getNode(created.nodeIdsByKey['root/title'] ?? '') as unknown as TextNode + + expect(title.fontName).toEqual({ family: 'Inter', style: 'Semi Bold' }) + await expect( + handleApplyCanvas({ + ...input, + mode: 'update', + targetNodeId: created.rootNodeId + }) + ).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('rejects a root key already owned by another node in the update scope', async () => { + const fixture = createFixture() + const root = fixture.createNode('FRAME') + const owner = fixture.createNode('RECTANGLE') + const frame = root as unknown as FrameNode + frame.insertChild(0, owner) + owner.setSharedPluginData('tempad-dev', 'canvas-key', 'root') + + await expect( + handleApplyCanvas({ + mode: 'update', + targetNodeId: root.id, + root: { + key: 'root', + type: 'FRAME' + } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC + }) + expect(fixture.triggerUndo).toHaveBeenCalledTimes(1) + }) + + it('reuses keyed descendants nested below unsupported containers', async () => { + const fixture = createFixture() + const root = fixture.createNode('FRAME') + const nested = fixture.createNode('RECTANGLE') + const group = { + id: 'group:1', + type: 'GROUP', + parent: root, + children: [nested] + } as unknown as GroupNode + PAGE.children.splice(PAGE.children.indexOf(nested), 1) + nested.parent = group + root.children.push(group) + root.setSharedPluginData('tempad-dev', 'canvas-key', 'root') + nested.setSharedPluginData('tempad-dev', 'canvas-key', 'root/nested') + fixture.nodes.set(group.id, group) + + const result = await handleApplyCanvas({ + mode: 'update', + targetNodeId: root.id, + root: { + key: 'root', + type: 'FRAME', + children: [{ key: 'root/nested', type: 'RECTANGLE', visible: false }] + } + }) + + expect(result.createdNodeIds).toEqual([]) + expect(result.nodeIdsByKey['root/nested']).toBe(nested.id) + expect(nested.visible).toBe(false) + }) + + it('rejects nodes outside the update scope and rolls back partial work', async () => { + const fixture = createFixture() + const created = await handleApplyCanvas(createSpec()) + const foreign = fixture.createNode('RECTANGLE') + + await expect( + handleApplyCanvas({ + mode: 'update', + targetNodeId: created.rootNodeId, + root: { + key: 'card', + type: 'FRAME', + children: [ + { + key: 'foreign', + nodeId: foreign.id, + type: 'RECTANGLE' + } + ] + } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SCOPE + }) + expect(fixture.triggerUndo).toHaveBeenCalledTimes(1) + }) + + it('wraps Figma failures and reports when automatic rollback is unavailable', async () => { + const fixture = createFixture() + fixture.loadFontAsync.mockRejectedValueOnce(new Error('font unavailable')) + + await expect(handleApplyCanvas(createSpec())).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + message: 'font unavailable' + }) + expect(fixture.triggerUndo).toHaveBeenCalledTimes(1) + + fixture.loadFontAsync.mockRejectedValueOnce(new Error('font unavailable')) + fixture.triggerUndo.mockImplementationOnce(() => { + throw new Error('undo unavailable') + }) + await expect(handleApplyCanvas(createSpec())).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + message: expect.stringContaining('automatic rollback was not available') + }) + }) + + it('serializes concurrent apply requests within one Figma session', async () => { + const fixture = createFixture() + let finishFontLoad: (() => void) | undefined + fixture.loadFontAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFontLoad = resolve + }) + ) + + const first = handleApplyCanvas(createSpec()) + await vi.waitFor(() => expect(finishFontLoad).toBeTypeOf('function')) + await expect(handleApplyCanvas(createSpec())).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_BUSY + }) + + finishFontLoad?.() + await expect(first).resolves.toMatchObject({ rootNodeId: expect.any(String) }) + }) +}) diff --git a/packages/extension/tests/mcp/tools/design-system.test.ts b/packages/extension/tests/mcp/tools/design-system.test.ts new file mode 100644 index 00000000..6de461cd --- /dev/null +++ b/packages/extension/tests/mcp/tools/design-system.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { handleGetDesignSystem } from '@/mcp/tools/design-system' + +function component( + id: string, + name: string, + overrides: Partial = {} +): ComponentNode { + return { + id, + key: `${id}-key`, + name, + description: '', + remote: false, + componentPropertyDefinitions: {}, + parent: null, + ...overrides + } as unknown as ComponentNode +} + +function variable( + id: string, + name: string, + collectionId: string, + overrides: Partial = {} +): Variable { + return { + id, + key: `${id}-key`, + name, + description: '', + remote: false, + resolvedType: 'COLOR', + scopes: ['ALL_FILLS'], + variableCollectionId: collectionId, + ...overrides + } as Variable +} + +function stubFigma({ + boundNodes = [], + components = [], + instances = [], + localCollections = [], + localVariables = [], + remoteCollections = new Map(), + remoteVariables = new Map() +}: { + boundNodes?: SceneNode[] + components?: ComponentNode[] + instances?: InstanceNode[] + localCollections?: VariableCollection[] + localVariables?: Variable[] + remoteCollections?: Map + remoteVariables?: Map +} = {}): void { + vi.stubGlobal('figma', { + currentPage: { + id: '0:1', + name: 'Design System', + findAll: vi.fn(() => boundNodes), + findAllWithCriteria: vi.fn(({ types }: { types: string[] }) => + types[0] === 'COMPONENT' ? components : instances + ) + }, + variables: { + getLocalVariablesAsync: vi.fn().mockResolvedValue(localVariables), + getLocalVariableCollectionsAsync: vi.fn().mockResolvedValue(localCollections), + getVariableByIdAsync: vi.fn((id: string) => Promise.resolve(remoteVariables.get(id) ?? null)), + getVariableCollectionByIdAsync: vi.fn((id: string) => + Promise.resolve(remoteCollections.get(id) ?? null) + ) + } + } as unknown as PluginAPI) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('mcp/tools/design-system', () => { + it('returns query-ranked component metadata with stable ids and property options', async () => { + const set = { + id: 'set:1', + type: 'COMPONENT_SET', + name: '按钮 Button', + componentPropertyDefinitions: { + Size: { + type: 'VARIANT', + defaultValue: 'Medium', + variantOptions: ['Small', 'Medium', 'Large'] + }, + Icon: { + type: 'INSTANCE_SWAP', + defaultValue: 'icon:default', + preferredValues: [{ type: 'COMPONENT', key: 'icon-key' }] + } + } + } as unknown as ComponentSetNode + const local = component('component:1', '按钮 / Primary', { + description: 'Primary action', + parent: set + }) + const remote = component('component:2', 'Input', { remote: true }) + const instance = { + getMainComponentAsync: vi.fn().mockResolvedValue(remote) + } as unknown as InstanceNode + stubFigma({ components: [local], instances: [instance] }) + + const result = await handleGetDesignSystem({ query: '按钮' }) + + expect(result.components).toEqual([ + { + id: 'component:1', + key: 'component:1-key', + name: '按钮 / Primary', + description: 'Primary action', + componentSetName: '按钮 Button', + properties: { + Size: { + type: 'VARIANT', + defaultValue: 'Medium', + options: ['Small', 'Medium', 'Large'] + }, + Icon: { + type: 'INSTANCE_SWAP', + defaultValue: 'icon:default', + options: ['icon-key'] + } + }, + remote: false + } + ]) + expect(result.page).toEqual({ id: '0:1', name: 'Design System' }) + expect(result.warnings).toEqual(['No local or currently bound variables were found.']) + }) + + it('includes local and currently bound remote variables and resolves collection names', async () => { + const local = variable('variable:1', 'Spacing / Small', 'collection:1', { + resolvedType: 'FLOAT', + scopes: ['GAP'] + }) + const remote = variable('variable:2', 'Color / Primary', 'collection:2', { + description: 'Brand foreground', + remote: true + }) + const boundNode = { + boundVariables: { + fills: [{ type: 'VARIABLE_ALIAS', id: 'variable:2' }], + width: { type: 'VARIABLE_ALIAS', id: 'variable:1' } + } + } as unknown as SceneNode + stubFigma({ + boundNodes: [boundNode], + localVariables: [local], + localCollections: [{ id: 'collection:1', name: 'Dimensions' } as VariableCollection], + remoteVariables: new Map([[remote.id, remote]]), + remoteCollections: new Map([ + ['collection:2', { id: 'collection:2', name: 'Brand' } as VariableCollection] + ]) + }) + + const result = await handleGetDesignSystem() + + expect(result.variables).toEqual([ + { + id: 'variable:2', + key: 'variable:2-key', + name: 'Color / Primary', + collectionName: 'Brand', + description: 'Brand foreground', + remote: true, + resolvedType: 'COLOR', + scopes: ['ALL_FILLS'] + }, + { + id: 'variable:1', + key: 'variable:1-key', + name: 'Spacing / Small', + collectionName: 'Dimensions', + remote: false, + resolvedType: 'FLOAT', + scopes: ['GAP'] + } + ]) + }) + + it('returns concise warnings when components or variables are unavailable', async () => { + const brokenInstance = { + getMainComponentAsync: vi.fn().mockRejectedValue(new Error('unavailable')) + } as unknown as InstanceNode + stubFigma({ instances: [brokenInstance] }) + const variables = ( + figma as PluginAPI & { + variables: { getLocalVariablesAsync: ReturnType } + } + ).variables + variables.getLocalVariablesAsync.mockRejectedValue(new Error('no variables API')) + + const result = await handleGetDesignSystem({ query: 'missing' }) + + expect(result.components).toEqual([]) + expect(result.variables).toEqual([]) + expect(result.warnings).toEqual([ + 'No components were found on the current page.', + 'Variables could not be read in the current Figma context.' + ]) + }) + + it('caps broad discovery responses deterministically', async () => { + const components = Array.from({ length: 45 }, (_, index) => + component(`component:${index}`, `Component ${String(index).padStart(2, '0')}`) + ) + const variables = Array.from({ length: 65 }, (_, index) => + variable(`variable:${index}`, `Variable ${String(index).padStart(2, '0')}`, 'collection:1') + ) + stubFigma({ + components, + localVariables: variables, + localCollections: [{ id: 'collection:1', name: 'Tokens' } as VariableCollection] + }) + + const result = await handleGetDesignSystem() + + expect(result.components).toHaveLength(40) + expect(result.variables).toHaveLength(60) + expect(result.components[0]?.name).toBe('Component 00') + expect(result.variables[0]?.name).toBe('Variable 00') + }) +}) diff --git a/packages/extension/ui/state.ts b/packages/extension/ui/state.ts index 7acf350e..504672b1 100644 --- a/packages/extension/ui/state.ts +++ b/packages/extension/ui/state.ts @@ -55,6 +55,7 @@ export const options = useStorage('tempad-dev', { export const runtimeMode = shallowRef<'standard' | 'unavailable'>('standard') export const layoutReady = shallowRef(false) +export const canvasWritesOn = shallowRef(false) export const selection = shallowRef([]) export const selectedNode = computed(() => selection.value?.[0] ?? null) export const selectedTemPadComponent = computed(() => getTemPadComponent(selectedNode.value)) diff --git a/packages/extension/vitest.node.config.ts b/packages/extension/vitest.node.config.ts index be410ba3..a4e52f14 100644 --- a/packages/extension/vitest.node.config.ts +++ b/packages/extension/vitest.node.config.ts @@ -67,6 +67,8 @@ export default defineConfig({ 'rewrite/shared.ts', 'mcp/errors.ts', 'mcp/tools/config.ts', + 'mcp/tools/canvas.ts', + 'mcp/tools/design-system.ts', 'mcp/tools/structure.ts', 'mcp/tools/screenshot.ts', 'mcp/tools/code/layout-parent.ts', diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 4f065fed..7c27700f 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.8.0-alpha.0 + +- Added the `get_design_system` and `apply_canvas` tool contracts, extension routing, result + validation, and concise agent guidance for declarative Figma authoring. +- Added tool-budget retry guidance for design-system discovery and canvas writes. + ## 0.7.1 - Aligned MCP server identity metadata around the stable `tempad-dev` name and human-readable diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 8ca7350b..70626575 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -20,11 +20,14 @@ For agent-specific setup, open TemPad Dev's **Preferences → Agent integration Supported tools/resources: - `get_code`: Tailwind-first JSX/Vue markup plus assets and token references. +- `get_design_system`: Query-ranked Figma components and variables from the active page/file. +- `apply_canvas`: One declarative create/update result, reconciled locally against the live canvas. - `get_structure`: Hierarchy/geometry outline for the selection. Notes: - Tool responses use a shared `64 KiB` inline budget measured on the `CallToolResult` body. When a selection is too large for the `get_code` budget, TemPad Dev may return a shell response instead of failing. The shell keeps the current node wrapper and lists omitted direct child ids in an inline code comment so agents can request them one by one. The accompanying warning stays lightweight and only points agents to that comment. +- `apply_canvas` is disabled by default. Enable **Canvas writes** in Agent integration only when you want the connected agent to modify the active Figma file. - Assets are ephemeral and tool-linked; image/SVG bytes are downloaded via the capability-bearing HTTP `asset.url` from tool results. Treat the full URL as a temporary secret and do not persist it in logs. - Asset resources are not exposed via MCP `resources/list`/`resources/read`. - The HTTP fallback URL uses `/{capability}/assets/{hash}` and may include an image extension (for example `/{capability}/assets/{hash}.png`). Both filename forms are accepted. diff --git a/packages/mcp-server/README.zh-Hans.md b/packages/mcp-server/README.zh-Hans.md index c1c0553c..a61f4443 100644 --- a/packages/mcp-server/README.zh-Hans.md +++ b/packages/mcp-server/README.zh-Hans.md @@ -18,11 +18,14 @@ 支持的工具和资源: - `get_code`:以 Tailwind 优先的 JSX/Vue 标记输出,并附带资源和变量引用。 +- `get_design_system`:从当前页面/文件中返回按查询排序的 Figma 组件和变量。 +- `apply_canvas`:提交一次声明式创建或更新结果,由扩展与实时画布在本地进行增量协调。 - `get_structure`:当前选中节点的层级/几何结构信息。 说明: - 工具响应共用 `64 KiB` 的 inline budget,按 `CallToolResult` 整体响应体积计算。若选区过大而超出 `get_code` 的预算,TemPad Dev 可能返回 shell response 而不是直接失败。shell 会保留当前节点的包裹结构,并在内联代码注释中列出被省略的直接子节点 id,方便 agent 逐个继续拉取;配套 warning 只保留最小化的提示信息,用来指向这条注释。 +- `apply_canvas` 默认禁用。只有在希望已连接的 agent 修改当前 Figma 文件时,才在 Agent integration 中启用 **Canvas writes**。 - 资源是临时且与工具调用关联的;图片/SVG 请直接使用工具结果中带 capability 的 HTTP `asset.url` 下载。完整 URL 应视作临时密钥,不要持久化到日志中。 - MCP 不再暴露 `resources/list` / `resources/read` 用于 asset 内容读取。 - HTTP 回退 URL 使用 `/{capability}/assets/{hash}`,也可能带图片扩展名(例如 `/{capability}/assets/{hash}.png`),两种文件名形式都支持。 diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 9bdc2ccb..3f0f9a71 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@tempad-dev/mcp", - "version": "0.7.1", + "version": "0.8.0-alpha.0", "description": "MCP server for TemPad Dev.", "repository": { "type": "git", diff --git a/packages/mcp-server/src/instructions.md b/packages/mcp-server/src/instructions.md index c622743d..ed6e4809 100644 --- a/packages/mcp-server/src/instructions.md +++ b/packages/mcp-server/src/instructions.md @@ -4,6 +4,11 @@ Treat tool outputs as design facts. Refactor only to match the user’s repo con Rules: +- For Figma authoring, combine Host design-system guidance with one `get_design_system` call. Prefer + returned components and semantic variables, use primitives or literals only for gaps, then send + one declarative `apply_canvas` result; do not emulate individual Figma Plugin API calls. +- Reuse `nodeIdsByKey` returned by `apply_canvas` when refining existing generated content. Omitted + fields and children are preserved; deletion is not supported. - Never output any `data-hint-*` attributes from tool outputs (hints only). - If `get_code` warns `depth-cap`, keep the returned parent code as composition evidence and use returned `data-hint-id` values to choose narrower `get_code` follow-ups. - If `get_code` warns `shell`, read the inline code comment for omitted direct child ids, then call `get_code` for those ids in order and fill the results back into the returned shell. diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index b8e2c0bc..395df75c 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -1,7 +1,6 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' import type { GetAssetsResult, - GetScreenshotResult, TempadMcpErrorCode, ToolName, ToolResponseLike, @@ -11,29 +10,36 @@ import type { import type { ZodType } from 'zod' import { + ApplyCanvasParametersSchema, MCP_TOOL_INLINE_BUDGET_BYTES, + buildApplyCanvasToolResult, buildGetAssetsToolResult, buildGetCodeToolResult, + buildGetDesignSystemToolResult, buildGetScreenshotToolResult, buildGetStructureToolResult, buildGetTokenDefsToolResult, GetAssetsParametersSchema, GetAssetsResultSchema, GetCodeParametersSchema, + GetDesignSystemParametersSchema, GetScreenshotParametersSchema, GetStructureParametersSchema, GetTokenDefsParametersSchema, TEMPAD_MCP_ERROR_CODES, - measureCallToolResultBytes, - type TempadMcpErrorPayload + measureCallToolResultBytes } from '@tempad-dev/shared' export type { + ApplyCanvasParametersInput, + ApplyCanvasResult, AssetDescriptor, GetAssetsParametersInput, GetAssetsResult, GetCodeParametersInput, GetCodeResult, + GetDesignSystemParametersInput, + GetDesignSystemResult, GetScreenshotParametersInput, GetScreenshotResult, GetStructureParametersInput, @@ -89,11 +95,12 @@ const CONNECTIVITY_TROUBLESHOOTING_LINES = [ const SELECTION_TROUBLESHOOTING_LINE = 'Tip: Select exactly one visible node, or pass nodeId.' +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + function getRecordProperty(record: unknown, key: string): unknown { - if (!record || typeof record !== 'object') { - return undefined - } - return Reflect.get(record, key) + return isRecord(record) ? record[key] : undefined } function extTool( @@ -117,6 +124,22 @@ export const TOOL_DEFS = [ target: 'extension', format: createCodeToolResponse }), + extTool({ + name: 'get_design_system', + description: + 'Return a compact, query-ranked set of Figma components and variables from the current page/file. Reuse returned ids/keys in apply_canvas.', + parameters: GetDesignSystemParametersSchema, + target: 'extension', + format: createDesignSystemToolResponse + }), + extTool({ + name: 'apply_canvas', + description: + 'Apply one declarative canvas tree. Create adds a FRAME tree; update safely reconciles within targetNodeId, preserving omissions and skipping unchanged values. Requires Canvas writes.', + parameters: ApplyCanvasParametersSchema, + target: 'extension', + format: createApplyCanvasToolResponse + }), extTool({ name: 'get_token_defs', description: @@ -170,9 +193,8 @@ function isTempadMcpErrorCode(value: unknown): value is TempadMcpErrorCode { function extractToolErrorMessage(error: unknown): string { if (error instanceof Error) return error.message || 'Unknown error occurred.' if (typeof error === 'string') return error - if (error && typeof error === 'object') { - const candidate = error as Partial> - if (typeof candidate.message === 'string' && candidate.message.trim()) return candidate.message + if (isRecord(error)) { + if (typeof error.message === 'string' && error.message.trim()) return error.message } return 'Unknown error occurred.' } @@ -226,90 +248,115 @@ function isSelectionToolError(code: TempadMcpErrorCode | undefined, message: str } export function createCodeToolResponse(payload: ToolResultMap['get_code']): CallToolResult { - if (!isCodeResult(payload)) { - throw new Error('Invalid get_code payload received from extension.') - } + return formatToolResult('get_code', payload, isCodeResult, buildGetCodeToolResult) +} + +export function createDesignSystemToolResponse( + payload: ToolResultMap['get_design_system'] +): CallToolResult { + return formatToolResult( + 'get_design_system', + payload, + isDesignSystemResult, + buildGetDesignSystemToolResult + ) +} - return toCallToolResult(buildGetCodeToolResult(payload)) +export function createApplyCanvasToolResponse( + payload: ToolResultMap['apply_canvas'] +): CallToolResult { + return formatToolResult('apply_canvas', payload, isApplyCanvasResult, buildApplyCanvasToolResult) } export function createStructureToolResponse( payload: ToolResultMap['get_structure'] ): CallToolResult { - if (!isStructureResult(payload)) { - throw new Error('Invalid get_structure payload received from extension.') - } - - return toCallToolResult(buildGetStructureToolResult(payload)) + return formatToolResult('get_structure', payload, isStructureResult, buildGetStructureToolResult) } export function createTokenDefsToolResponse( payload: ToolResultMap['get_token_defs'] ): CallToolResult { - if (!isTokenDefsResult(payload)) { - throw new Error('Invalid get_token_defs payload received from extension.') - } - - return toCallToolResult(buildGetTokenDefsToolResult(payload)) + return formatToolResult('get_token_defs', payload, isTokenDefsResult, buildGetTokenDefsToolResult) } export function createScreenshotToolResponse( payload: ToolResultMap['get_screenshot'] ): CallToolResult { - if (!isScreenshotResult(payload)) { - throw new Error('Invalid get_screenshot payload received from extension.') - } + return formatToolResult( + 'get_screenshot', + payload, + isScreenshotResult, + buildGetScreenshotToolResult + ) +} + +function formatToolResult( + toolName: ToolName, + payload: Result, + isValid: (payload: unknown) => payload is Result, + build: (payload: Result) => ToolResponseLike +): CallToolResult { + if (!isValid(payload)) throw new Error(`Invalid ${toolName} payload received from extension.`) + return toCallToolResult(build(payload)) +} - return toCallToolResult(buildGetScreenshotToolResult(payload)) +function isScreenshotResult(payload: unknown): payload is ToolResultMap['get_screenshot'] { + return ( + isRecord(payload) && + isRecord(payload.asset) && + typeof payload.width === 'number' && + typeof payload.height === 'number' && + typeof payload.scale === 'number' && + typeof payload.bytes === 'number' && + typeof payload.format === 'string' + ) +} + +function isDesignSystemResult(payload: unknown): payload is ToolResultMap['get_design_system'] { + return ( + isRecord(payload) && + isRecord(payload.page) && + Array.isArray(payload.components) && + Array.isArray(payload.variables) + ) } -function isScreenshotResult(payload: unknown): payload is GetScreenshotResult { - if (typeof payload !== 'object' || !payload) return false - const candidate = payload as Partial> +function isApplyCanvasResult(payload: unknown): payload is ToolResultMap['apply_canvas'] { return ( - typeof candidate.asset === 'object' && - candidate.asset !== null && - typeof candidate.width === 'number' && - typeof candidate.height === 'number' && - typeof candidate.scale === 'number' && - typeof candidate.bytes === 'number' && - typeof candidate.format === 'string' + isRecord(payload) && + typeof payload.rootNodeId === 'string' && + isRecord(payload.nodeIdsByKey) && + Array.isArray(payload.createdNodeIds) && + Array.isArray(payload.updatedNodeIds) && + typeof payload.mutationCount === 'number' ) } function isCodeResult(payload: unknown): payload is ToolResultMap['get_code'] { - if (typeof payload !== 'object' || !payload) return false - const candidate = payload as Partial> return ( - typeof candidate.code === 'string' && - typeof candidate.lang === 'string' && - (candidate.assets === undefined || Array.isArray(candidate.assets)) + isRecord(payload) && + typeof payload.code === 'string' && + typeof payload.lang === 'string' && + (payload.assets === undefined || Array.isArray(payload.assets)) ) } function isStructureResult(payload: unknown): payload is ToolResultMap['get_structure'] { - if (typeof payload !== 'object' || !payload) return false - const candidate = payload as Partial> - return Array.isArray(candidate.roots) + return isRecord(payload) && Array.isArray(payload.roots) } function isTokenDefsResult(payload: unknown): payload is ToolResultMap['get_token_defs'] { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false - for (const value of Object.values(payload as Record)) { - if (!value || typeof value !== 'object') return false - const token = value as Partial> - if (typeof token.kind !== 'string') return false - if (token.value === undefined) return false + if (!isRecord(payload)) return false + for (const token of Object.values(payload)) { + if (!isRecord(token) || typeof token.kind !== 'string' || token.value === undefined) + return false } return true } export function coercePayloadToToolResponse(payload: unknown): CallToolResult { - if ( - payload && - typeof payload === 'object' && - Array.isArray((payload as CallToolResult).content) - ) { + if (isRecord(payload) && Array.isArray(payload.content)) { return payload as CallToolResult } @@ -353,8 +400,12 @@ function toCallToolResult(result: ToolResponseLike): CallToolResult { function getBudgetRetryGuidance(toolName: ToolName): string { switch (toolName) { + case 'apply_canvas': + return 'Submit a smaller desired subtree and retry.' case 'get_code': return 'Reduce selection size or request a smaller nodeId subtree and retry.' + case 'get_design_system': + return 'Use a narrower design-system query and retry.' case 'get_structure': return 'Reduce selection size or pass a smaller depth and retry.' case 'get_token_defs': diff --git a/packages/mcp-server/tests/tools.test.ts b/packages/mcp-server/tests/tools.test.ts index 26f8babf..e0b761cf 100644 --- a/packages/mcp-server/tests/tools.test.ts +++ b/packages/mcp-server/tests/tools.test.ts @@ -4,9 +4,12 @@ import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' import { describe, expect, it } from 'vitest' import { + TOOL_DEFS, coercePayloadToToolResponse, + createApplyCanvasToolResponse, createAssetsToolResponse, createCodeToolResponse, + createDesignSystemToolResponse, createInlineBudgetExceededToolResponse, createScreenshotToolResponse, createStructureToolResponse, @@ -33,6 +36,15 @@ function textContent(block: unknown): string { } describe('tools response helpers', () => { + it('exposes result-oriented canvas authoring tools', () => { + expect(TOOL_DEFS.filter((tool) => tool.exposed !== false).map((tool) => tool.name)).toEqual([ + 'get_code', + 'get_design_system', + 'apply_canvas', + 'get_structure' + ]) + }) + it('formats code tool responses with summaries, warnings, assets and tokens', () => { const payload: ToolResultMap['get_code'] = { ...codePayload, @@ -109,6 +121,36 @@ describe('tools response helpers', () => { expect(textContent(tokenResult.content[0])).toContain('Resolved 1 token definition') }) + it('formats design-system discovery and canvas apply responses', () => { + const designSystemPayload: ToolResultMap['get_design_system'] = { + page: { id: '0:1', name: 'Design system' }, + components: [ + { + id: '1:1', + key: 'button-key', + name: 'Button', + remote: true + } + ], + variables: [] + } + const designSystemResult = createDesignSystemToolResponse(designSystemPayload) + expect(designSystemResult.structuredContent).toEqual(designSystemPayload) + expect(textContent(designSystemResult.content[0])).toContain('Found 1 component') + + const applyPayload: ToolResultMap['apply_canvas'] = { + rootNodeId: '2:1', + nodeIdsByKey: { root: '2:1' }, + createdNodeIds: [], + updatedNodeIds: ['2:1'], + mutationCount: 1 + } + const applyResult = createApplyCanvasToolResponse(applyPayload) + expect(applyResult.structuredContent).toEqual(applyPayload) + expect(textContent(applyResult.content[0])).toContain('Applied 1 canvas mutation') + expect(textContent(applyResult.content[0])).toContain('Reuse nodeIdsByKey') + }) + it('formats screenshot tool responses with summary text only', () => { const payload: ToolResultMap['get_screenshot'] = { format: 'png', @@ -156,6 +198,13 @@ describe('tools response helpers', () => { expect(result.isError).toBe(true) expect(textContent(result.content[0])).toContain('64 KiB inline budget') expect(textContent(result.content[0])).toContain('split them into smaller batches') + + expect( + textContent(createInlineBudgetExceededToolResponse('apply_canvas', 70000).content[0]) + ).toContain('smaller desired subtree') + expect( + textContent(createInlineBudgetExceededToolResponse('get_design_system', 70000).content[0]) + ).toContain('narrower design-system query') }) it('coerces payloads to MCP CallToolResult', () => { @@ -248,5 +297,32 @@ describe('tools response helpers', () => { expect(() => createTokenDefsToolResponse({ '--x': null } as unknown as ToolResultMap['get_token_defs']) ).toThrow(/Invalid get_token_defs payload/) + expect(() => + createDesignSystemToolResponse({ + page: null + } as unknown as ToolResultMap['get_design_system']) + ).toThrow(/Invalid get_design_system payload/) + expect(() => + createDesignSystemToolResponse(null as unknown as ToolResultMap['get_design_system']) + ).toThrow(/Invalid get_design_system payload/) + expect(() => + createApplyCanvasToolResponse({ + rootNodeId: '1:1' + } as ToolResultMap['apply_canvas']) + ).toThrow(/Invalid apply_canvas payload/) + expect(() => + createApplyCanvasToolResponse(null as unknown as ToolResultMap['apply_canvas']) + ).toThrow(/Invalid apply_canvas payload/) + expect(() => + createApplyCanvasToolResponse( + Object.assign([], { + rootNodeId: '1:1', + nodeIdsByKey: {}, + createdNodeIds: [], + updatedNodeIds: [], + mutationCount: 0 + }) as unknown as ToolResultMap['apply_canvas'] + ) + ).toThrow(/Invalid apply_canvas payload/) }) }) diff --git a/packages/shared/src/mcp/errors.ts b/packages/shared/src/mcp/errors.ts index 045f7ef1..0db1f5da 100644 --- a/packages/shared/src/mcp/errors.ts +++ b/packages/shared/src/mcp/errors.ts @@ -6,6 +6,12 @@ export const TEMPAD_MCP_ERROR_CODES = { EXTENSION_DISCONNECTED: 'EXTENSION_DISCONNECTED', INVALID_SELECTION: 'INVALID_SELECTION', NODE_NOT_VISIBLE: 'NODE_NOT_VISIBLE', + CANVAS_WRITE_DISABLED: 'CANVAS_WRITE_DISABLED', + CANVAS_UNSUPPORTED_EDITOR: 'CANVAS_UNSUPPORTED_EDITOR', + CANVAS_BUSY: 'CANVAS_BUSY', + INVALID_CANVAS_SCOPE: 'INVALID_CANVAS_SCOPE', + INVALID_CANVAS_SPEC: 'INVALID_CANVAS_SPEC', + CANVAS_APPLY_FAILED: 'CANVAS_APPLY_FAILED', ASSET_SERVER_NOT_CONFIGURED: 'ASSET_SERVER_NOT_CONFIGURED', TRANSPORT_NOT_CONNECTED: 'TRANSPORT_NOT_CONNECTED' } as const diff --git a/packages/shared/src/mcp/install.ts b/packages/shared/src/mcp/install.ts index 3f5230fe..04b528e0 100644 --- a/packages/shared/src/mcp/install.ts +++ b/packages/shared/src/mcp/install.ts @@ -193,7 +193,7 @@ function buildPluginSetupCommand(prefix: 'claude' | 'codex'): string { function buildPluginSetupDeepLink(prefix: 'claude' | 'codex'): string { const command = buildPluginSetupCommand(prefix) - const prompt = `Install the TemPad Dev agent plugin by running this command, then confirm that its MCP server and figma-design-to-code skill are available:\n\n${command}` + const prompt = `Install the TemPad Dev agent plugin by running this command, then confirm that its MCP server plus figma-design-to-code and figma-canvas-authoring skills are available:\n\n${command}` const target = prefix === 'claude' ? 'claude-cli://open?q=' : 'codex://new?prompt=' return `${target}${encodeURIComponent(prompt)}` } diff --git a/packages/shared/src/mcp/responses.ts b/packages/shared/src/mcp/responses.ts index 384aaf62..9f72ce5e 100644 --- a/packages/shared/src/mcp/responses.ts +++ b/packages/shared/src/mcp/responses.ts @@ -1,6 +1,8 @@ import type { + ApplyCanvasResult, GetAssetsResult, GetCodeResult, + GetDesignSystemResult, GetScreenshotResult, GetStructureResult, GetTokenDefsResult @@ -53,6 +55,24 @@ export function buildGetCodeToolResult(payload: GetCodeResult): ToolResponseLike return buildTextToolResult(summary.join('\n'), payload) } +export function buildGetDesignSystemToolResult(payload: GetDesignSystemResult): ToolResponseLike { + const summary = `Found ${formatCount(payload.components.length, 'component')} and ${formatCount(payload.variables.length, 'variable')} on page "${payload.page.name}".` + const warnings = payload.warnings?.length ? `\n${payload.warnings.join('\n')}` : '' + return buildTextToolResult( + `${summary}${warnings}\nRead structuredContent for stable component and variable references.`, + payload + ) +} + +export function buildApplyCanvasToolResult(payload: ApplyCanvasResult): ToolResponseLike { + const summary = `Applied ${formatCount(payload.mutationCount, 'canvas mutation')}: ${formatCount(payload.createdNodeIds.length, 'node')} created and ${formatCount(payload.updatedNodeIds.length, 'node')} updated.` + const warnings = payload.warnings?.length ? `\n${payload.warnings.join('\n')}` : '' + return buildTextToolResult( + `${summary}${warnings}\nRoot node: ${payload.rootNodeId}. Reuse nodeIdsByKey for later updates.`, + payload + ) +} + export function buildGetStructureToolResult(payload: GetStructureResult): ToolResponseLike { const roots = payload.roots.length const nodeCount = countOutlineNodes(payload.roots) diff --git a/packages/shared/src/mcp/tools.ts b/packages/shared/src/mcp/tools.ts index d42c7bf3..d37f7a81 100644 --- a/packages/shared/src/mcp/tools.ts +++ b/packages/shared/src/mcp/tools.ts @@ -144,6 +144,343 @@ export type GetStructureResult = { roots: OutlineNode[] } +// get_design_system +export const GetDesignSystemParametersSchema = z + .object({ + query: z + .string() + .trim() + .min(1) + .max(500) + .describe( + 'Optional task or design-system query used to rank matching components and variables.' + ) + .optional() + }) + .strict() + +export type GetDesignSystemParametersInput = z.input + +export type DesignSystemComponentProperty = { + type: 'BOOLEAN' | 'INSTANCE_SWAP' | 'SLOT' | 'TEXT' | 'VARIANT' + defaultValue: string | boolean + options?: string[] +} + +export type DesignSystemComponent = { + id: string + key: string + name: string + description?: string + componentSetName?: string + properties?: Record + remote: boolean +} + +export type DesignSystemVariable = { + id: string + key: string + name: string + collectionName: string + description?: string + remote: boolean + resolvedType: 'BOOLEAN' | 'COLOR' | 'FLOAT' | 'STRING' + scopes?: string[] +} + +export type GetDesignSystemResult = { + page: { + id: string + name: string + } + components: DesignSystemComponent[] + variables: DesignSystemVariable[] + warnings?: string[] +} + +// apply_canvas +export type CanvasDesignReference = { id: string; key?: string } | { id?: never; key: string } + +const CanvasDesignReferenceSchema = z + .object({ + id: z.string().min(1).describe('Live Figma node or variable id.').optional(), + key: z.string().min(1).describe('Importable Figma library key.').optional() + }) + .strict() + .refine( + (reference): reference is CanvasDesignReference => + reference.id !== undefined || reference.key !== undefined, + { + message: 'A design-system reference requires id or key.' + } + ) + +const CanvasNodeTypeSchema = z.enum(['ELLIPSE', 'FRAME', 'INSTANCE', 'LINE', 'RECTANGLE', 'TEXT']) +type CanvasNodeType = z.infer + +const CanvasColorSchema = z + .string() + .regex(/^#[\dA-Fa-f]{6}(?:[\dA-Fa-f]{2})?$/, 'Use #RRGGBB or #RRGGBBAA.') + +const CanvasFiniteNumberSchema = z.number().finite() +const CanvasNonnegativeNumberSchema = z.number().nonnegative().finite() +const CanvasPositiveNumberSchema = z.number().positive().finite() + +const CanvasPositionSchema = z + .object({ + x: CanvasFiniteNumberSchema.optional(), + y: CanvasFiniteNumberSchema.optional() + }) + .strict() + +const CanvasSizeSchema = z + .object({ + width: CanvasPositiveNumberSchema.optional(), + height: CanvasPositiveNumberSchema.optional(), + horizontal: z.enum(['FILL', 'FIXED', 'HUG']).optional(), + vertical: z.enum(['FILL', 'FIXED', 'HUG']).optional() + }) + .strict() + +const CanvasPaddingSchema = z + .object({ + top: CanvasNonnegativeNumberSchema.optional(), + right: CanvasNonnegativeNumberSchema.optional(), + bottom: CanvasNonnegativeNumberSchema.optional(), + left: CanvasNonnegativeNumberSchema.optional() + }) + .strict() + +const CanvasLayoutSchema = z + .object({ + mode: z.enum(['HORIZONTAL', 'NONE', 'VERTICAL']).optional(), + gap: CanvasFiniteNumberSchema.optional(), + padding: z.union([CanvasNonnegativeNumberSchema, CanvasPaddingSchema]).optional(), + primaryAlign: z.enum(['CENTER', 'MAX', 'MIN', 'SPACE_BETWEEN']).optional(), + counterAlign: z.enum(['BASELINE', 'CENTER', 'MAX', 'MIN']).optional() + }) + .strict() + +const CanvasAppearanceSchema = z + .object({ + fill: CanvasColorSchema.nullable().optional(), + stroke: CanvasColorSchema.nullable().optional(), + strokeWeight: CanvasNonnegativeNumberSchema.optional(), + cornerRadius: CanvasNonnegativeNumberSchema.optional(), + opacity: z.number().min(0).max(1).finite().optional() + }) + .strict() + +const CanvasTextSchema = z + .object({ + characters: z.string().max(100_000).optional(), + fontFamily: z.string().min(1).max(200).optional(), + fontStyle: z.string().min(1).max(200).optional(), + fontSize: CanvasPositiveNumberSchema.optional(), + lineHeight: CanvasPositiveNumberSchema.optional(), + letterSpacing: CanvasFiniteNumberSchema.optional(), + alignHorizontal: z.enum(['CENTER', 'JUSTIFIED', 'LEFT', 'RIGHT']).optional(), + alignVertical: z.enum(['BOTTOM', 'CENTER', 'TOP']).optional() + }) + .strict() + +export const CanvasVariableBindingsSchema = z + .object({ + fill: CanvasDesignReferenceSchema.optional(), + stroke: CanvasDesignReferenceSchema.optional(), + width: CanvasDesignReferenceSchema.optional(), + height: CanvasDesignReferenceSchema.optional(), + gap: CanvasDesignReferenceSchema.optional(), + paddingTop: CanvasDesignReferenceSchema.optional(), + paddingRight: CanvasDesignReferenceSchema.optional(), + paddingBottom: CanvasDesignReferenceSchema.optional(), + paddingLeft: CanvasDesignReferenceSchema.optional(), + cornerRadius: CanvasDesignReferenceSchema.optional(), + opacity: CanvasDesignReferenceSchema.optional(), + fontFamily: CanvasDesignReferenceSchema.optional(), + fontStyle: CanvasDesignReferenceSchema.optional(), + fontSize: CanvasDesignReferenceSchema.optional(), + lineHeight: CanvasDesignReferenceSchema.optional(), + letterSpacing: CanvasDesignReferenceSchema.optional() + }) + .strict() + +export type CanvasVariableBindings = z.infer + +const MAX_CANVAS_NODES = 100 +const MAX_CANVAS_DEPTH = 12 + +export type CanvasNodeSpec = { + key: string + nodeId?: string + type: CanvasNodeType + name?: string + visible?: boolean + position?: z.infer + size?: z.infer + layout?: z.infer + appearance?: z.infer + text?: z.infer + component?: CanvasDesignReference + componentProperties?: Record + variables?: CanvasVariableBindings + children?: CanvasNodeSpec[] +} + +export const CanvasNodeSpecSchema: z.ZodType = z.lazy(() => + z + .object({ + key: z + .string() + .min(1) + .max(128) + .regex(/^[\w./:-]+$/, 'Use a stable key containing letters, numbers, ., /, :, _, or -.') + .describe('Agent-stable identity reused across later apply_canvas results.'), + nodeId: z + .string() + .min(1) + .describe('Optional exact live node identity; update mode only.') + .optional(), + type: CanvasNodeTypeSchema.describe('Native Figma node type.'), + name: z.string().max(500).optional(), + visible: z.boolean().optional(), + position: CanvasPositionSchema.optional(), + size: CanvasSizeSchema.optional(), + layout: CanvasLayoutSchema.optional(), + appearance: CanvasAppearanceSchema.optional(), + text: CanvasTextSchema.optional(), + component: CanvasDesignReferenceSchema.describe( + 'Required design-system component reference for INSTANCE nodes.' + ).optional(), + componentProperties: z + .record(z.string().min(1), z.union([z.string(), z.boolean()])) + .describe('Exposed component property values for an INSTANCE.') + .optional(), + variables: CanvasVariableBindingsSchema.describe( + 'Figma variable bindings. A binding wins over a literal for the same field.' + ).optional(), + children: z + .array(CanvasNodeSpecSchema) + .max(MAX_CANVAS_NODES) + .describe('Desired FRAME children in order; omitted live children are preserved.') + .optional() + }) + .strict() +) + +export const ApplyCanvasParametersSchema = z + .object({ + mode: z + .enum(['create', 'update']) + .describe('Create one new FRAME tree, or update one explicitly scoped live subtree.'), + targetNodeId: z + .string() + .min(1) + .describe('Required update-scope root node id; invalid in create mode.') + .optional(), + root: CanvasNodeSpecSchema.describe( + 'Declarative desired result. Omitted fields and live children are preserved.' + ) + }) + .strict() + .superRefine((value, context) => { + function addIssue(message: string, path: Array): void { + context.addIssue({ + code: 'custom', + message, + path + }) + } + + if (value.mode === 'create') { + if (value.targetNodeId !== undefined) { + addIssue('targetNodeId is only valid in update mode.', ['targetNodeId']) + } + if (value.root.type !== 'FRAME') { + addIssue('Create mode requires a FRAME root.', ['root', 'type']) + } + } else { + if (value.targetNodeId === undefined) { + addIssue('Update mode requires targetNodeId.', ['targetNodeId']) + } + if (value.root.nodeId !== undefined && value.root.nodeId !== value.targetNodeId) { + addIssue('The root nodeId must match targetNodeId in update mode.', ['root', 'nodeId']) + } + } + + const keys = new Set() + const nodeIds = new Set() + const stack: Array<{ depth: number; node: CanvasNodeSpec; path: Array }> = [ + { depth: 1, node: value.root, path: ['root'] } + ] + let count = 0 + + while (stack.length) { + const { depth, node, path } = stack.pop()! + count += 1 + if (count > MAX_CANVAS_NODES) { + addIssue(`Canvas specs may contain at most ${MAX_CANVAS_NODES} nodes.`, ['root']) + break + } + if (depth > MAX_CANVAS_DEPTH) { + addIssue(`Canvas specs may be at most ${MAX_CANVAS_DEPTH} levels deep.`, path) + } + if (keys.has(node.key)) { + addIssue(`Duplicate canvas key "${node.key}".`, [...path, 'key']) + } + keys.add(node.key) + if (node.nodeId) { + if (value.mode === 'create') { + addIssue('Create mode cannot reference existing nodeIds.', [...path, 'nodeId']) + } + if (nodeIds.has(node.nodeId)) { + addIssue(`Duplicate nodeId "${node.nodeId}".`, [...path, 'nodeId']) + } + nodeIds.add(node.nodeId) + } + if (node.type === 'INSTANCE' && !node.component) { + addIssue('INSTANCE nodes require a component reference.', [...path, 'component']) + } + if (node.type !== 'INSTANCE' && node.component) { + addIssue('Only INSTANCE nodes accept a component reference.', [...path, 'component']) + } + if (node.type !== 'INSTANCE' && node.componentProperties) { + addIssue('Only INSTANCE nodes accept componentProperties.', [ + ...path, + 'componentProperties' + ]) + } + if (node.type !== 'TEXT' && node.text) { + addIssue('Only TEXT nodes accept text properties.', [...path, 'text']) + } + if (node.type !== 'FRAME' && node.layout) { + addIssue('Only FRAME nodes accept layout properties.', [...path, 'layout']) + } + if (node.type !== 'FRAME' && node.children !== undefined) { + addIssue('Only FRAME nodes may declare children.', [...path, 'children']) + } + node.children?.forEach((child, index) => { + stack.push({ + depth: depth + 1, + node: child, + path: [...path, 'children', index] + }) + }) + } + }) + +export type ApplyCanvasParametersInput = z.input +export type ApplyCanvasParameters = z.output + +export type ApplyCanvasResult = { + rootNodeId: string + nodeIdsByKey: Record + createdNodeIds: string[] + updatedNodeIds: string[] + mutationCount: number + warnings?: string[] +} + // get_assets (hub only) export const GetAssetsParametersSchema = z.object({ hashes: z @@ -166,6 +503,8 @@ export type AssetDescriptor = z.infer export type ToolResultMap = { get_code: GetCodeResult + get_design_system: GetDesignSystemResult + apply_canvas: ApplyCanvasResult get_token_defs: GetTokenDefsResult get_screenshot: GetScreenshotResult get_structure: GetStructureResult diff --git a/packages/shared/tests/mcp/constants-errors.test.ts b/packages/shared/tests/mcp/constants-errors.test.ts index 8ccb0e2d..9a50009c 100644 --- a/packages/shared/tests/mcp/constants-errors.test.ts +++ b/packages/shared/tests/mcp/constants-errors.test.ts @@ -44,6 +44,12 @@ describe('mcp/errors', () => { EXTENSION_DISCONNECTED: 'EXTENSION_DISCONNECTED', INVALID_SELECTION: 'INVALID_SELECTION', NODE_NOT_VISIBLE: 'NODE_NOT_VISIBLE', + CANVAS_WRITE_DISABLED: 'CANVAS_WRITE_DISABLED', + CANVAS_UNSUPPORTED_EDITOR: 'CANVAS_UNSUPPORTED_EDITOR', + CANVAS_BUSY: 'CANVAS_BUSY', + INVALID_CANVAS_SCOPE: 'INVALID_CANVAS_SCOPE', + INVALID_CANVAS_SPEC: 'INVALID_CANVAS_SPEC', + CANVAS_APPLY_FAILED: 'CANVAS_APPLY_FAILED', ASSET_SERVER_NOT_CONFIGURED: 'ASSET_SERVER_NOT_CONFIGURED', TRANSPORT_NOT_CONNECTED: 'TRANSPORT_NOT_CONNECTED' }) diff --git a/packages/shared/tests/mcp/index.test.ts b/packages/shared/tests/mcp/index.test.ts index 49d10f58..d6682813 100644 --- a/packages/shared/tests/mcp/index.test.ts +++ b/packages/shared/tests/mcp/index.test.ts @@ -24,6 +24,8 @@ describe('shared/mcp index barrel', () => { expect(mcp.AssetDescriptorSchema).toBe(tools.AssetDescriptorSchema) expect(mcp.GetCodeParametersSchema).toBe(tools.GetCodeParametersSchema) + expect(mcp.GetDesignSystemParametersSchema).toBe(tools.GetDesignSystemParametersSchema) + expect(mcp.ApplyCanvasParametersSchema).toBe(tools.ApplyCanvasParametersSchema) expect(mcp.GetAssetsResultSchema).toBe(tools.GetAssetsResultSchema) }) }) diff --git a/packages/shared/tests/mcp/install.test.ts b/packages/shared/tests/mcp/install.test.ts index 7f57db67..98f99283 100644 --- a/packages/shared/tests/mcp/install.test.ts +++ b/packages/shared/tests/mcp/install.test.ts @@ -127,15 +127,16 @@ describe('shared/mcp/install', () => { value: expect.stringContaining('codex plugin marketplace add ecomfe/tempad-dev') }) ]) - expect(decodeURIComponent(codex.actions[0]?.value ?? '')).toContain( - 'codex plugin add tempad-dev@tempad-dev' - ) + const codexPluginPrompt = decodeURIComponent(codex.actions[0]?.value ?? '') + expect(codexPluginPrompt).toContain('codex plugin add tempad-dev@tempad-dev') + expect(codexPluginPrompt).toContain('figma-design-to-code') + expect(codexPluginPrompt).toContain('figma-canvas-authoring') const claude = mcp.AGENT_INTEGRATIONS_BY_ID.claude expect(claude.actions[0]?.value).toMatch(/^claude-cli:\/\/open\?q=/) - expect(decodeURIComponent(claude.actions[0]?.value ?? '')).toContain( - 'claude plugin install tempad-dev@tempad-dev' - ) + const claudePluginPrompt = decodeURIComponent(claude.actions[0]?.value ?? '') + expect(claudePluginPrompt).toContain('claude plugin install tempad-dev@tempad-dev') + expect(claudePluginPrompt).toContain('figma-canvas-authoring') const cursor = mcp.AGENT_INTEGRATIONS_BY_ID.cursor expect(JSON.parse(cursor.actions[1]?.value ?? '')).toHaveProperty( diff --git a/packages/shared/tests/mcp/responses.test.ts b/packages/shared/tests/mcp/responses.test.ts index e0b98a1c..0c08e627 100644 --- a/packages/shared/tests/mcp/responses.test.ts +++ b/packages/shared/tests/mcp/responses.test.ts @@ -4,7 +4,9 @@ import type { ToolResponseLike } from '../../src/mcp/responses' import type { ToolResultMap } from '../../src/mcp/tools' import { + buildApplyCanvasToolResult, buildGetCodeToolResult, + buildGetDesignSystemToolResult, buildGetStructureToolResult, buildGetTokenDefsToolResult, measureCallToolResultBytes, @@ -70,4 +72,37 @@ describe('mcp/responses helpers', () => { }) expect(tokens.content?.[0]?.text).toContain('Resolved 1 token definition') }) + + it('builds design-system and canvas-apply summaries', () => { + const designSystem = buildGetDesignSystemToolResult({ + page: { id: '0:1', name: 'Components' }, + components: [ + { + id: '1:1', + key: 'button-key', + name: 'Button', + remote: false + } + ], + variables: [], + warnings: ['No variables were found.'] + }) + expect(designSystem.content?.[0]?.text).toContain( + 'Found 1 component and 0 variables on page "Components".' + ) + expect(designSystem.content?.[0]?.text).toContain('No variables were found.') + + const applied = buildApplyCanvasToolResult({ + rootNodeId: '2:1', + nodeIdsByKey: { root: '2:1' }, + createdNodeIds: ['2:1'], + updatedNodeIds: [], + mutationCount: 2, + warnings: ['One optional property was skipped.'] + }) + expect(applied.content?.[0]?.text).toContain('Applied 2 canvas mutations') + expect(applied.content?.[0]?.text).toContain('1 node created and 0 nodes updated') + expect(applied.content?.[0]?.text).toContain('One optional property was skipped.') + expect(applied.content?.[0]?.text).toContain('Root node: 2:1') + }) }) diff --git a/packages/shared/tests/mcp/tools.test.ts b/packages/shared/tests/mcp/tools.test.ts index 4abec5ce..c670d448 100644 --- a/packages/shared/tests/mcp/tools.test.ts +++ b/packages/shared/tests/mcp/tools.test.ts @@ -1,15 +1,40 @@ import { describe, expect, it } from 'vitest' import { + ApplyCanvasParametersSchema, AssetDescriptorSchema, GetAssetsParametersSchema, GetAssetsResultSchema, GetCodeParametersSchema, + GetDesignSystemParametersSchema, GetScreenshotParametersSchema, GetStructureParametersSchema, GetTokenDefsParametersSchema } from '../../src/mcp/tools' +function frameTree(depth: number): Record { + let root: Record = { + key: `depth-${depth}`, + type: 'FRAME' + } + for (let level = depth - 1; level >= 1; level -= 1) { + root = { + key: `depth-${level}`, + type: 'FRAME', + children: [root] + } + } + return root +} + +function acceptsCanvas(value: unknown): boolean { + return ApplyCanvasParametersSchema.safeParse(value).success +} + +function acceptsCreate(root: Record): boolean { + return acceptsCanvas({ mode: 'create', root }) +} + describe('mcp/tools AssetDescriptorSchema', () => { it('accepts a valid asset descriptor', () => { const parsed = AssetDescriptorSchema.safeParse({ @@ -44,6 +69,213 @@ describe('mcp/tools AssetDescriptorSchema', () => { }) }) +describe('mcp/tools canvas authoring schemas', () => { + it('accepts a compact design-system query and a declarative create result', () => { + expect(GetDesignSystemParametersSchema.safeParse({}).success).toBe(true) + expect(GetDesignSystemParametersSchema.safeParse({ query: ' primary button ' }).success).toBe( + true + ) + expect(GetDesignSystemParametersSchema.safeParse({ query: ' ' }).success).toBe(false) + expect(GetDesignSystemParametersSchema.safeParse({ extra: true }).success).toBe(false) + + expect( + acceptsCreate({ + key: 'settings/card', + type: 'FRAME', + name: 'Settings card', + position: { x: 20, y: 30 }, + size: { width: 320, height: 200, horizontal: 'FIXED', vertical: 'FIXED' }, + layout: { + mode: 'VERTICAL', + gap: 12, + padding: { top: 16, right: 16, bottom: 16, left: 16 }, + primaryAlign: 'MIN', + counterAlign: 'CENTER' + }, + appearance: { + fill: '#FFFFFFFF', + stroke: '#112233', + strokeWeight: 1, + cornerRadius: 12, + opacity: 0.9 + }, + variables: { + fill: { key: 'color-key' }, + gap: { id: 'VariableID:1' } + }, + children: [ + { + key: 'settings/card/title', + type: 'TEXT', + text: { + characters: 'Settings', + fontFamily: 'Inter', + fontStyle: 'Semi Bold', + fontSize: 18, + lineHeight: 24, + letterSpacing: 0, + alignHorizontal: 'LEFT', + alignVertical: 'TOP' + } + }, + { + key: 'settings/card/action', + type: 'INSTANCE', + component: { id: 'ComponentID:1' }, + componentProperties: { + Label: 'Save', + Disabled: false + } + } + ] + }) + ).toBe(true) + }) + + it('accepts a scoped update and rejects unsafe create/update identities', () => { + expect( + acceptsCanvas({ + mode: 'update', + targetNodeId: '1:2', + root: { + key: 'root', + nodeId: '1:2', + type: 'FRAME', + children: [{ key: 'root/title', nodeId: '1:3', type: 'TEXT' }] + } + }) + ).toBe(true) + + const invalidCreates = [ + { + mode: 'create', + targetNodeId: '1:2', + root: { key: 'root', type: 'FRAME' } + }, + { + mode: 'create', + root: { key: 'root', nodeId: '1:2', type: 'FRAME' } + }, + { + mode: 'create', + root: { + key: 'root', + type: 'RECTANGLE' + } + } + ] + for (const input of invalidCreates) { + expect(acceptsCanvas(input)).toBe(false) + } + expect( + acceptsCanvas({ + mode: 'update', + root: { key: 'root', type: 'FRAME' } + }) + ).toBe(false) + expect( + acceptsCanvas({ + mode: 'update', + targetNodeId: '1:2', + root: { key: 'root', nodeId: '1:9', type: 'FRAME' } + }) + ).toBe(false) + }) + + it('rejects ambiguous identities and properties on incompatible node types', () => { + const parsed = ApplyCanvasParametersSchema.safeParse({ + mode: 'update', + targetNodeId: '1:1', + root: { + key: 'root', + nodeId: '1:1', + type: 'FRAME', + children: [ + { + key: 'duplicate', + nodeId: '1:2', + type: 'INSTANCE' + }, + { + key: 'duplicate', + nodeId: '1:2', + type: 'TEXT', + component: { key: 'component-key' }, + componentProperties: {}, + layout: { mode: 'NONE' }, + children: [{ key: 'nested', type: 'RECTANGLE' }] + }, + { + key: 'rectangle', + type: 'RECTANGLE', + text: { characters: 'Not valid' }, + component: { key: 'component-key' } + } + ] + } + }) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + const messages = parsed.error.issues.map((issue) => issue.message).join(' ') + expect(messages).toContain('Duplicate canvas key') + expect(messages).toContain('Duplicate nodeId') + expect(messages).toContain('INSTANCE nodes require') + expect(messages).toContain('Only INSTANCE nodes accept') + expect(messages).toContain('Only TEXT nodes accept') + expect(messages).toContain('Only FRAME nodes accept') + expect(messages).toContain('Only FRAME nodes may declare children') + } + + expect( + acceptsCreate({ + key: 'root', + type: 'FRAME', + variables: { fill: {} } + }) + ).toBe(false) + expect( + acceptsCanvas({ + mode: 'update', + targetNodeId: '1:1', + root: { + key: 'root', + nodeId: '1:1', + type: 'FRAME', + children: [{ key: 'rectangle', type: 'RECTANGLE', children: [] }] + } + }) + ).toBe(false) + }) + + it('enforces canvas size, depth, color, and stable-key bounds', () => { + const children = Array.from({ length: 100 }, (_, index) => ({ + key: `root/item-${index}`, + type: 'RECTANGLE' as const + })) + expect( + acceptsCreate({ + key: 'root', + type: 'FRAME', + children: children.slice(0, 99) + }) + ).toBe(true) + expect(acceptsCreate({ key: 'root', type: 'FRAME', children })).toBe(false) + + expect(acceptsCreate(frameTree(12))).toBe(true) + expect(acceptsCreate(frameTree(13))).toBe(false) + + expect(acceptsCreate({ key: 'invalid key', type: 'FRAME' })).toBe(false) + expect( + acceptsCreate({ + key: 'root', + type: 'FRAME', + appearance: { fill: 'red' } + }) + ).toBe(false) + }) +}) + describe('mcp/tools parameter schemas', () => { it('accepts optional get_code params and validates preferred language enum', () => { expect(GetCodeParametersSchema.safeParse({}).success).toBe(true) diff --git a/packages/site/src/sections/ConnectSection.vue b/packages/site/src/sections/ConnectSection.vue index ca8ae2fb..beebe220 100644 --- a/packages/site/src/sections/ConnectSection.vue +++ b/packages/site/src/sections/ConnectSection.vue @@ -58,8 +58,8 @@ const terminalCardRef = ref(null) const terminalViewportRef = ref(null) function getAgentDescription(agent: AgentIntegrationConfig): string { return agent.actions.some(({ id }) => id === 'plugin-prompt') - ? 'The plugin adds MCP access and the design skill.' - : 'Add MCP access and the design skill.' + ? 'The plugin adds MCP access and design skills.' + : 'Add MCP access and a design skill.' } const terminalEntries: readonly TerminalEntry[] = [ diff --git a/vitest.config.ts b/vitest.config.ts index 3bb1acd4..641d0dcf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -58,6 +58,8 @@ export default defineConfig({ 'packages/extension/rewrite/shared.ts', 'packages/extension/mcp/errors.ts', 'packages/extension/mcp/tools/config.ts', + 'packages/extension/mcp/tools/canvas.ts', + 'packages/extension/mcp/tools/design-system.ts', 'packages/extension/mcp/tools/structure.ts', 'packages/extension/mcp/tools/screenshot.ts', 'packages/extension/mcp/tools/code/layout-parent.ts', From 2b526fae4dcea15b48e39815e889341cc1281a48 Mon Sep 17 00:00:00 2001 From: Justineo Date: Mon, 27 Jul 2026 12:37:00 +0800 Subject: [PATCH 02/52] ci: support tagged MCP prereleases --- .github/workflows/publish-mcp.yml | 12 +++++++++++- packages/mcp-server/package.json | 4 +++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 5de2ea44..79b4785c 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -2,6 +2,16 @@ name: publish-mcp on: workflow_dispatch: + inputs: + tag: + description: npm dist-tag + required: true + default: latest + type: choice + options: + - latest + - next + - alpha permissions: contents: read @@ -35,4 +45,4 @@ jobs: - name: Publish working-directory: packages/mcp-server - run: npm publish --access public + run: npm publish --access public --tag "${{ inputs.tag }}" diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 3f0f9a71..a280a42c 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -7,7 +7,9 @@ "url": "git+https://github.com/ecomfe/tempad-dev.git", "directory": "packages/mcp-server" }, - "bin": "dist/cli.mjs", + "bin": { + "mcp": "dist/cli.mjs" + }, "files": [ "README.md", "dist/**/*" From cb50fc13d519abb4d8d47a82b46fb7776529b395 Mon Sep 17 00:00:00 2001 From: Justineo Date: Mon, 27 Jul 2026 12:46:23 +0800 Subject: [PATCH 03/52] chore(plugin): use alpha MCP release --- agent-plugins/tempad-dev/.mcp.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-plugins/tempad-dev/.mcp.json b/agent-plugins/tempad-dev/.mcp.json index 0d8ff3d7..e8471748 100644 --- a/agent-plugins/tempad-dev/.mcp.json +++ b/agent-plugins/tempad-dev/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "tempad-dev": { "command": "npx", - "args": ["-y", "@tempad-dev/mcp@latest"] + "args": ["-y", "@tempad-dev/mcp@alpha"] } } } From 9ad65bf2d389136d72338eed27f3f9dd2b65cea6 Mon Sep 17 00:00:00 2001 From: Justineo Date: Mon, 27 Jul 2026 14:11:14 +0800 Subject: [PATCH 04/52] fix(extension): use valid canvas data namespace --- packages/extension/mcp/tools/canvas.ts | 2 +- packages/extension/tests/mcp/tools/canvas.test.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/extension/mcp/tools/canvas.ts b/packages/extension/mcp/tools/canvas.ts index f23fc455..cd89ecfe 100644 --- a/packages/extension/mcp/tools/canvas.ts +++ b/packages/extension/mcp/tools/canvas.ts @@ -13,7 +13,7 @@ import { canvasWritesOn } from '@/ui/state' import { createCodedError } from '../errors' -const CANVAS_KEY_NAMESPACE = 'tempad-dev' +const CANVAS_KEY_NAMESPACE = 'tempad_dev' const CANVAS_KEY_NAME = 'canvas-key' const SUPPORTED_NODE_TYPES = new Set([ 'ELLIPSE', diff --git a/packages/extension/tests/mcp/tools/canvas.test.ts b/packages/extension/tests/mcp/tools/canvas.test.ts index 3ab34fdc..1c348bca 100644 --- a/packages/extension/tests/mcp/tools/canvas.test.ts +++ b/packages/extension/tests/mcp/tools/canvas.test.ts @@ -14,6 +14,13 @@ vi.mock('@/ui/state', () => ({ import { handleApplyCanvas } from '@/mcp/tools/canvas' const MIXED = Symbol('mixed') +const SHARED_PLUGIN_DATA_NAMESPACE_PATTERN = /^[A-Za-z0-9_.]+$/ + +function assertSharedPluginDataNamespace(namespace: string): void { + if (!SHARED_PLUGIN_DATA_NAMESPACE_PATTERN.test(namespace)) { + throw new Error('The namespace can only consist of alphanumeric characters, _ or .') + } +} const PAGE = { id: '0:1', name: 'Page 1', @@ -93,9 +100,11 @@ function createFixture(): FigmaFixture { layoutSizingVertical: 'FIXED', boundVariables, getSharedPluginData(namespace: string, key: string) { + assertSharedPluginDataNamespace(namespace) return pluginData.get(`${namespace}:${key}`) ?? '' }, setSharedPluginData(namespace: string, key: string, value: string) { + assertSharedPluginDataNamespace(namespace) pluginData.set(`${namespace}:${key}`, value) }, resize(width: number, height: number) { @@ -556,7 +565,7 @@ describe('mcp/tools/canvas', () => { const owner = fixture.createNode('RECTANGLE') const frame = root as unknown as FrameNode frame.insertChild(0, owner) - owner.setSharedPluginData('tempad-dev', 'canvas-key', 'root') + owner.setSharedPluginData('tempad_dev', 'canvas-key', 'root') await expect( handleApplyCanvas({ @@ -586,8 +595,8 @@ describe('mcp/tools/canvas', () => { PAGE.children.splice(PAGE.children.indexOf(nested), 1) nested.parent = group root.children.push(group) - root.setSharedPluginData('tempad-dev', 'canvas-key', 'root') - nested.setSharedPluginData('tempad-dev', 'canvas-key', 'root/nested') + root.setSharedPluginData('tempad_dev', 'canvas-key', 'root') + nested.setSharedPluginData('tempad_dev', 'canvas-key', 'root/nested') fixture.nodes.set(group.id, group) const result = await handleApplyCanvas({ From ccab50522e33934398e9581902adf633d47ef3bc Mon Sep 17 00:00:00 2001 From: Justineo Date: Tue, 4 Aug 2026 10:52:54 +0800 Subject: [PATCH 05/52] feat: expand Figma canvas authoring support --- .claude-plugin/marketplace.json | 4 +- .github/workflows/build.yml | 7 + .gitignore | 1 + .lefthook.yml | 4 - AGENTS.md | 23 +- README.md | 20 +- README.zh-Hans.md | 19 +- .../tempad-dev/.claude-plugin/plugin.json | 4 +- .../tempad-dev/.codex-plugin/plugin.json | 14 +- agent-plugins/tempad-dev/.mcp.json | 2 +- agent-plugins/tempad-dev/README.md | 42 +- .../tempad-dev/assets/icon-padded.svg | 16 + agent-plugins/tempad-dev/assets/icon.png | Bin 0 -> 752 bytes .../skills/figma-canvas-authoring/SKILL.md | 273 +- .../figma-canvas-authoring/agents/openai.yaml | 7 +- .../figma-canvas-authoring/assets/icon.svg | 16 + .../references/canvas-html.md | 124 + .../references/component-authoring.md | 112 + .../references/design-system-authoring.md | 55 + .../references/design-system-reuse.md | 68 + .../references/document-geometry.md | 61 + .../references/paints-effects.md | 68 + .../references/rich-text.md | 58 + .../references/style-grounding.md | 80 + .../references/styles.md | 53 + .../references/variables.md | 74 + .../references/visual-assets.md | 100 + .../figma-design-to-code/agents/openai.yaml | 7 + .../figma-design-to-code/assets/icon.svg | 16 + docs/engineering/optimization-audit.md | 7 +- docs/extension/mcp-browser-gateway-design.md | 17 +- docs/extension/mcp-canvas-assets-design.md | 495 + .../mcp-canvas-authoring-coverage.md | 323 + docs/extension/mcp-canvas-authoring-design.md | 724 +- docs/extension/mcp-context-strategy.md | 73 +- docs/extension/mcp-get-code-design.md | 7 + docs/extension/mcp-get-code-requirements.md | 11 +- .../extension/multi-fill-background-design.md | 4 +- docs/mcp/provider-sdk-design.md | 1444 +++ docs/security/local-mcp-threat-model.md | 22 +- docs/testing/architecture.md | 9 +- package.json | 9 +- packages/extension/AGENTS.md | 1 + packages/extension/CHANGELOG.md | 36 +- packages/extension/codegen/worker.ts | 2 +- .../extension/components/AgentSetupDialog.vue | 4 +- packages/extension/components/Code.vue | 11 +- .../sections/AgentIntegrationSection.vue | 19 +- .../components/sections/MetaSection.vue | 5 +- packages/extension/composables/key-lock.ts | 5 +- packages/extension/composables/mcp.ts | 136 +- packages/extension/mcp/assets.ts | 91 +- packages/extension/mcp/bounded-response.ts | 36 + packages/extension/mcp/broker/hub-client.ts | 53 +- .../extension/mcp/broker/service-worker.ts | 110 +- packages/extension/mcp/broker/sessions.ts | 2 +- packages/extension/mcp/encoding.ts | 36 + packages/extension/mcp/errors.ts | 22 +- packages/extension/mcp/local-styles.ts | 10 + packages/extension/mcp/media.ts | 41 + packages/extension/mcp/runtime.ts | 19 +- packages/extension/mcp/semantic-tree.ts | 54 +- packages/extension/mcp/tools/canvas.ts | 761 -- packages/extension/mcp/tools/canvas/assets.ts | 290 + packages/extension/mcp/tools/canvas/errors.ts | 59 + packages/extension/mcp/tools/canvas/html.ts | 174 + .../extension/mcp/tools/canvas/identity.ts | 72 + packages/extension/mcp/tools/canvas/index.ts | 73 + packages/extension/mcp/tools/canvas/markup.ts | 1646 ++++ packages/extension/mcp/tools/canvas/model.ts | 154 + .../extension/mcp/tools/canvas/reconcile.ts | 5315 ++++++++++ .../extension/mcp/tools/canvas/resolve.ts | 139 + packages/extension/mcp/tools/canvas/styles.ts | 158 + .../extension/mcp/tools/canvas/tailwind.ts | 841 ++ .../extension/mcp/tools/canvas/variables.ts | 1089 +++ packages/extension/mcp/tools/canvas/vector.ts | 108 + .../extension/mcp/tools/code/assets/index.ts | 2 +- .../tools/code/assets/{image.ts => media.ts} | 159 +- .../extension/mcp/tools/code/assets/paint.ts | 4 +- .../extension/mcp/tools/code/assets/plan.ts | 3 +- .../extension/mcp/tools/code/assets/svg.ts | 20 +- .../mcp/tools/code/cache/node-semantics.ts | 8 +- .../extension/mcp/tools/code/cache/types.ts | 2 +- packages/extension/mcp/tools/code/collect.ts | 6 +- packages/extension/mcp/tools/code/index.ts | 9 +- .../extension/mcp/tools/code/render/index.ts | 3 +- .../mcp/tools/code/sanitize/stacking.ts | 3 +- .../mcp/tools/code/styles/background.ts | 1 + .../mcp/tools/code/styles/normalize.ts | 13 +- .../mcp/tools/code/styles/overflow.ts | 12 +- .../extension/mcp/tools/code/text/render.ts | 35 +- .../extension/mcp/tools/code/text/segments.ts | 42 +- .../extension/mcp/tools/code/text/style.ts | 29 +- .../mcp/tools/code/tokens/extract.ts | 3 +- .../mcp/tools/code/tokens/resolve.ts | 2 +- .../extension/mcp/tools/code/tokens/used.ts | 17 +- packages/extension/mcp/tools/code/tree.ts | 39 +- .../mcp/tools/design-system-catalog.ts | 145 + packages/extension/mcp/tools/design-system.ts | 1304 ++- packages/extension/mcp/tools/screenshot.ts | 22 +- packages/extension/mcp/tools/structure.ts | 68 +- packages/extension/mcp/tools/token/defs.ts | 11 +- packages/extension/mcp/tools/token/indexer.ts | 8 +- packages/extension/mcp/tools/token/mapping.ts | 2 +- packages/extension/mcp/variable-references.ts | 13 + packages/extension/package.json | 2 +- packages/extension/scripts/check-rewrite.ts | 4 +- .../tests/components/select.browser.test.ts | 4 +- .../extension/tests/composables/input.test.ts | 6 +- .../extension/tests/composables/mcp.test.ts | 114 +- packages/extension/tests/mcp/assets.test.ts | 138 +- .../tests/mcp/broker/hub-client.test.ts | 62 +- .../tests/mcp/broker/service-worker.test.ts | 126 +- packages/extension/tests/mcp/runtime.test.ts | 52 +- .../extension/tests/mcp/semantic-tree.test.ts | 55 +- .../tests/mcp/tools/canvas-assets.test.ts | 75 + .../tests/mcp/tools/canvas-markup.test.ts | 2364 +++++ .../tests/mcp/tools/canvas-resolve.test.ts | 317 + .../extension/tests/mcp/tools/canvas.test.ts | 8596 ++++++++++++++++- .../tests/mcp/tools/code/assets/index.test.ts | 6 +- .../assets/{image.test.ts => media.test.ts} | 187 +- .../tests/mcp/tools/code/collect.test.ts | 4 +- .../mcp/tools/code/sanitize/index.test.ts | 18 +- .../mcp/tools/code/text/segments.test.ts | 59 +- .../mcp/tools/code/tokens/process.test.ts | 4 +- .../tests/mcp/tools/code/tokens/used.test.ts | 3 + .../tests/mcp/tools/code/tree.test.ts | 14 +- .../tests/mcp/tools/design-system.test.ts | 725 +- .../tests/mcp/tools/screenshot.test.ts | 18 +- .../tests/mcp/tools/structure.test.ts | 47 + .../extension/tests/rewrite/config.test.ts | 11 +- .../extension/tests/rewrite/shared.test.ts | 6 +- packages/extension/tests/utils/module.test.ts | 4 +- packages/extension/tsconfig.json | 1 + packages/extension/ui/state.ts | 6 +- packages/extension/utils/color.ts | 11 +- packages/extension/utils/component.ts | 48 +- packages/extension/utils/css.ts | 126 +- .../extension/utils/figma-style/gradient.ts | 4 +- .../utils/figma-style/style-resolver.ts | 3 +- packages/extension/utils/figma-variables.ts | 2 +- packages/extension/utils/string.ts | 2 +- .../extension/utils/tailwind-semantics.ts | 47 + packages/extension/utils/tailwind.ts | 69 +- packages/extension/vitest.node.config.ts | 92 +- packages/mcp-server/AGENTS.md | 6 + packages/mcp-server/CHANGELOG.md | 28 +- packages/mcp-server/README.md | 19 +- packages/mcp-server/README.zh-Hans.md | 17 +- packages/mcp-server/package.json | 5 +- packages/mcp-server/src/asset-http-server.ts | 13 +- packages/mcp-server/src/asset-utils.ts | 9 +- packages/mcp-server/src/extension-socket.ts | 12 +- packages/mcp-server/src/hub.ts | 39 +- packages/mcp-server/src/instructions.md | 36 +- packages/mcp-server/src/shared.ts | 2 +- packages/mcp-server/src/tools.ts | 74 +- .../tests/asset-http-server.test.ts | 83 +- .../tests/asset-http-server.unit.test.ts | 79 +- packages/mcp-server/tests/asset-utils.test.ts | 13 +- .../mcp-server/tests/extension-socket.test.ts | 7 +- packages/mcp-server/tests/tools.test.ts | 173 +- packages/shared/AGENTS.md | 6 + packages/shared/package.json | 1 + packages/shared/src/mcp/browser-gateway.ts | 64 +- packages/shared/src/mcp/constants.ts | 9 +- packages/shared/src/mcp/errors.ts | 13 +- packages/shared/src/mcp/protocol.ts | 4 +- packages/shared/src/mcp/responses.ts | 78 +- packages/shared/src/mcp/tools.ts | 2113 +++- .../shared/tests/mcp/browser-gateway.test.ts | 27 +- .../shared/tests/mcp/constants-errors.test.ts | 25 +- packages/shared/tests/mcp/protocol.test.ts | 24 +- packages/shared/tests/mcp/responses.test.ts | 127 +- packages/shared/tests/mcp/tools.test.ts | 2157 ++++- pnpm-lock.yaml | 9 +- scripts/build-dev-agent-plugin.mjs | 100 + scripts/sync-agent-plugin-skill.mjs | 26 - skill/agents/openai.yaml | 7 + skill/assets/icon.svg | 16 + vitest.config.ts | 89 +- vitest.coverage.ts | 99 + 182 files changed, 33638 insertions(+), 3433 deletions(-) create mode 100644 agent-plugins/tempad-dev/assets/icon-padded.svg create mode 100644 agent-plugins/tempad-dev/assets/icon.png create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/assets/icon.svg create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/canvas-html.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/component-authoring.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/design-system-authoring.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/design-system-reuse.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/document-geometry.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/paints-effects.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/rich-text.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/style-grounding.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/styles.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/variables.md create mode 100644 agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/visual-assets.md create mode 100644 agent-plugins/tempad-dev/skills/figma-design-to-code/agents/openai.yaml create mode 100644 agent-plugins/tempad-dev/skills/figma-design-to-code/assets/icon.svg create mode 100644 docs/extension/mcp-canvas-assets-design.md create mode 100644 docs/extension/mcp-canvas-authoring-coverage.md create mode 100644 docs/mcp/provider-sdk-design.md create mode 100644 packages/extension/mcp/bounded-response.ts create mode 100644 packages/extension/mcp/encoding.ts create mode 100644 packages/extension/mcp/local-styles.ts create mode 100644 packages/extension/mcp/media.ts delete mode 100644 packages/extension/mcp/tools/canvas.ts create mode 100644 packages/extension/mcp/tools/canvas/assets.ts create mode 100644 packages/extension/mcp/tools/canvas/errors.ts create mode 100644 packages/extension/mcp/tools/canvas/html.ts create mode 100644 packages/extension/mcp/tools/canvas/identity.ts create mode 100644 packages/extension/mcp/tools/canvas/index.ts create mode 100644 packages/extension/mcp/tools/canvas/markup.ts create mode 100644 packages/extension/mcp/tools/canvas/model.ts create mode 100644 packages/extension/mcp/tools/canvas/reconcile.ts create mode 100644 packages/extension/mcp/tools/canvas/resolve.ts create mode 100644 packages/extension/mcp/tools/canvas/styles.ts create mode 100644 packages/extension/mcp/tools/canvas/tailwind.ts create mode 100644 packages/extension/mcp/tools/canvas/variables.ts create mode 100644 packages/extension/mcp/tools/canvas/vector.ts rename packages/extension/mcp/tools/code/assets/{image.ts => media.ts} (50%) create mode 100644 packages/extension/mcp/tools/design-system-catalog.ts create mode 100644 packages/extension/mcp/variable-references.ts create mode 100644 packages/extension/tests/mcp/tools/canvas-assets.test.ts create mode 100644 packages/extension/tests/mcp/tools/canvas-markup.test.ts create mode 100644 packages/extension/tests/mcp/tools/canvas-resolve.test.ts rename packages/extension/tests/mcp/tools/code/assets/{image.test.ts => media.test.ts} (57%) create mode 100644 packages/extension/utils/tailwind-semantics.ts create mode 100644 scripts/build-dev-agent-plugin.mjs delete mode 100644 scripts/sync-agent-plugin-skill.mjs create mode 100644 skill/agents/openai.yaml create mode 100644 skill/assets/icon.svg diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 22253231..5e360996 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -3,12 +3,12 @@ "owner": { "name": "TemPad Dev" }, - "description": "Agent plugins for using TemPad Dev design evidence in coding workflows.", + "description": "Agent plugins for reading Figma evidence and authoring native designs with TemPad Dev.", "plugins": [ { "name": "tempad-dev", "source": "./agent-plugins/tempad-dev", - "description": "Use selected Figma nodes as agent-ready evidence for project-consistent UI implementation.", + "description": "Turn Figma evidence into project-consistent UI code and create native Figma designs.", "category": "Design" } ] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d1125dfb..37ed4b78 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,13 @@ jobs: - name: Install browser runtime run: pnpm --filter @tempad-dev/extension test:setup + - name: Check agent plugin + run: pnpm agent-plugin:dev && test -z "$(git status --porcelain --untracked-files=all -- agent-plugins/tempad-dev)" + + - name: Verify published MCP version + if: github.event_name == 'push' + run: npm view "@tempad-dev/mcp@$(node -p "require('./packages/mcp-server/package.json').version")" version + - name: Type check run: pnpm typecheck diff --git a/.gitignore b/.gitignore index 82a32a28..02c9e332 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ stats-*.json .wxt web-ext.config.ts dist +.dev/ coverage .artifacts/ packages/*/coverage diff --git a/.lefthook.yml b/.lefthook.yml index 89dd61e1..1922aa67 100644 --- a/.lefthook.yml +++ b/.lefthook.yml @@ -5,10 +5,6 @@ pre-commit: group: piped: true jobs: - - name: sync-agent-plugin - glob: '{skill/SKILL.md,agent-plugins/tempad-dev/skills/figma-design-to-code/SKILL.md}' - run: pnpm sync:agent-plugin - stage_fixed: true - name: lint glob: '*.{ts,js,mjs,cjs,mts,cts,vue}' run: pnpm exec eslint --fix {staged_files} diff --git a/AGENTS.md b/AGENTS.md index 18ff785b..b78f9e2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,10 +32,29 @@ Provide a single entry point for coding agents. This file links to package-level - Test (watch): `pnpm test` - Test (run): `pnpm test:run` - Test (coverage): `pnpm test:coverage` +- Generate the local agent plugin: `pnpm agent-plugin:dev` - Extension node tests: `pnpm --filter @tempad-dev/extension test:node` - Extension browser tests: `pnpm --filter @tempad-dev/extension test:browser` - Extension browser setup: `pnpm --filter @tempad-dev/extension test:setup` +## Agent plugin workflow + +- `agent-plugins/tempad-dev/` is the tracked release source shared by Codex and Claude. The agent + plugin is distributed through the Git marketplace, not npm. +- `.dev/plugins/tempad-dev-dev/` is the ignored local build. Generate it with + `pnpm agent-plugin:dev`; do not edit generated files under `.dev/`. +- Run `pnpm agent-plugin:dev` after changing the shared skill, agent-plugin manifests, icons, or + marketplace metadata. Ordinary `pnpm build` must not modify agent-plugin artifacts. +- `pnpm dev` watches the extension, shared package, and MCP server. The generated development + plugin points directly at the current checkout's MCP build, so MCP-only changes require a new + agent task or plugin reload, not an agent-plugin rebuild or reinstall. +- Keep Codex and Claude support equivalent. Both development manifests must launch the same + working-tree MCP runtime. +- Release MCP configuration must use the exact version from `packages/mcp-server/package.json`, + never a movable npm dist-tag or a local path. Publish that MCP version before exposing the + matching Git marketplace commit. +- See `agent-plugins/tempad-dev/README.md` for the Codex and Claude installation and refresh commands. + ## Doc index - `TESTING.md` @@ -43,6 +62,7 @@ Provide a single entry point for coding agents. This file links to package-level - `docs/extension/mcp-get-code-requirements.md` - `docs/extension/mcp-get-code-design.md` - `docs/extension/mcp-canvas-authoring-design.md` +- `docs/extension/mcp-canvas-assets-design.md` - `docs/extension/mcp-browser-gateway-design.md` - `docs/marketing-screenshots.md` @@ -116,7 +136,8 @@ Pick the checks that match your change. - Testing runbook and required checks: `TESTING.md`. - Testing architecture and coverage model: `docs/testing/architecture.md`. -- Root coverage scope is configured in `vitest.config.ts` as the single source of truth. +- Root coverage composition is configured in `vitest.config.ts`; shared thresholds and the extension + node source list live in `vitest.coverage.ts`. - Root coverage excludes build artifacts (`**/dist/**`, `**/.output/**`) to avoid polluted reports. - Root coverage provider is `istanbul` to avoid V8 remap parse failures under Vite 8 dependency trees. - Extension browser tests run in Playwright via `packages/extension/vitest.browser.config.ts`. diff --git a/README.md b/README.md index b60ef43f..0aa2da02 100644 --- a/README.md +++ b/README.md @@ -205,16 +205,24 @@ Current available plugins: TemPad Dev ships an agent integration for coding agents and IDEs. The integration combines: -- an [MCP](https://modelcontextprotocol.io/) server that lets agents inspect Figma and, with an explicit write toggle, apply declarative canvas results -- two agent skills: one for implementing Figma evidence in code, and one for designing on the Figma canvas with the active file's design system +- an [MCP](https://modelcontextprotocol.io/) server that lets agents inspect Figma and apply + declarative canvas results when the current Figma Design file is editable +- two agent skills: one for implementing Figma evidence in code, and one for designing on the Figma canvas with accessible component definitions and bounded design-system resources -Figma also provides official [remote and desktop MCP servers](https://developers.figma.com/docs/figma-mcp-server/), with the remote server recommended for most users. TemPad Dev is an open, local-control complement for teams that specifically want an inspectable browser-extension pipeline, local inspection and opt-in declarative canvas authoring, programmable output plugins, canonical agent-facing code/token IR, and an explicit context budget. It provides design evidence and a code starting point; the coding agent remains responsible for adapting that evidence to the repository, validating behavior, and producing the final implementation. +Figma also provides official [remote and desktop MCP servers](https://developers.figma.com/docs/figma-mcp-server/), with the remote server recommended for most users. TemPad Dev is an open, local-control complement for teams that specifically want an inspectable browser-extension pipeline, local inspection and MCP-gated declarative canvas authoring, programmable output plugins, canonical agent-facing code/token IR, and an explicit context budget. It provides design evidence and a code starting point; the coding agent remains responsible for adapting that evidence to the repository, validating behavior, and producing the final implementation. With the TemPad Dev panel open and MCP enabled, the MCP server exposes: - `get_code`: High-fidelity JSX/Vue + TailwindCSS code output by default, plus attached assets and the codegen preset/config used. -- `get_design_system`: Query-ranked native Figma component and variable references. -- `apply_canvas`: A declarative desired result that the extension safely reconciles with the live canvas. This requires the separate, session-only **Canvas writes** toggle. +- `get_design_system`: An immutable, deterministic catalog. It returns compact pages of component + definitions on accessible pages plus local or directly referenced variable, collection/mode, + style, and shader definitions without inspecting canvas usage or loading every page. Cursor + continuation exposes omitted definitions; exact-ref lookup returns one bounded definition. +- `apply_canvas`: One restricted HTML + deterministic Tailwind utility desired result using primitives, catalog + component tags, short design-system refs, typed Figma-only state, sanitized SVG, and + content-addressed images. The extension resolves, validates, diffs, applies, and structurally + verifies the result. Authoring requires edit access to the current Figma Design file. +- `get_screenshot`: A bounded rendered PNG for selective visual validation. - `get_structure`: A structural outline (ids, types, geometry) for the current selection. - Binary assets are returned as metadata + HTTP download URLs (`asset.url`) in tool responses. Asset MCP resources are not exposed. @@ -226,7 +234,7 @@ With the TemPad Dev panel open and MCP enabled, the MCP server exposes: TemPad Dev agent setup dialog. -1. Install Node.js 18.20.0 or later with `npx`. Keep TemPad Dev open in the Figma tab you want the agent to inspect, then enable **Preferences → Agent integration → MCP access**. When prompted, allow the loopback connection to `127.0.0.1`. Enable **Canvas writes** separately only when you want the agent to modify that file. +1. Install Node.js 18.20.0 or later with `npx`. Keep TemPad Dev open in the Figma tab you want the agent to inspect, then enable **Preferences → Agent integration → MCP access**. When prompted, allow the loopback connection to `127.0.0.1`. Canvas authoring is available while MCP access is enabled and the current Figma Design file is editable. 2. Select **Set up agents**, choose Codex, Cursor, Claude Code, Gemini, VS Code, OpenCode, or TRAE, and follow the displayed path. Use **Other** for another compatible client. The choice only changes the instructions shown; it does not bind or activate an agent. 3. Prefer the direct action when offered. Every fallback command or config is shown in full for review and copying. Codex and Claude Code plugins include MCP plus the `figma-design-to-code` and `figma-canvas-authoring` skills; the other paths show MCP and standalone skill setup separately. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index a975df34..071d00c0 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -201,16 +201,23 @@ sandboxed extension page 内启动一个全新的 Worker,并在完成或五秒 TemPad Dev 内置了面向编码 agent 和 IDE 的 Agent 集成。该集成包含: -- 一个 [MCP](https://modelcontextprotocol.io/) 服务器,使 agent 可以检查 Figma,并在显式启用写入后提交声明式画布结果 -- 两个 agent skill:一个用于根据 Figma 证据实现代码,另一个用于基于当前文件的 design system 在 Figma 画布上进行设计 +- 一个 [MCP](https://modelcontextprotocol.io/) 服务器,使 agent 可以检查 Figma,并在当前 Figma Design 文件可编辑时提交声明式画布结果 +- 两个 agent skill:一个用于根据 Figma 证据实现代码,另一个用于基于可访问页面中的组件定义和文件级设计资源在 Figma 画布上进行设计 -Figma 也提供官方的 [remote 与 desktop MCP server](https://developers.figma.com/docs/figma-mcp-server/),并建议大多数用户优先使用 remote server。TemPad Dev 的定位是一个开放、强调本地控制的补充方案,适合明确需要可审计的浏览器扩展链路、本地检查与按需启用的声明式画布创作、可编程输出插件、规范化的 agent-facing 代码/token IR,以及显式上下文预算的团队。TemPad Dev 提供设计证据与代码起点;最终仍由 coding agent 结合目标仓库完成适配、验证和实现。 +Figma 也提供官方的 [remote 与 desktop MCP server](https://developers.figma.com/docs/figma-mcp-server/),并建议大多数用户优先使用 remote server。TemPad Dev 的定位是一个开放、强调本地控制的补充方案,适合明确需要可审计的浏览器扩展链路、本地检查与由 MCP access 控制的声明式画布创作、可编程输出插件、规范化的 agent-facing 代码/token IR,以及显式上下文预算的团队。TemPad Dev 提供设计证据与代码起点;最终仍由 coding agent 结合目标仓库完成适配、验证和实现。 打开 TemPad Dev 面板并启用 MCP 后,MCP 服务器会暴露以下能力: - `get_code`:默认输出高保真的 JSX/Vue + TailwindCSS 代码,同时包含相关资源以及使用的 codegen 预设和配置。 -- `get_design_system`:返回按查询排序的原生 Figma 组件和变量引用。 -- `apply_canvas`:提交声明式目标结果,由扩展与实时画布安全地进行增量协调;需要单独启用仅当前会话有效的 **Canvas writes**。 +- `get_design_system`:创建不可变、确定性的紧凑目录,按资源类型平衡分页返回可访问页面的 + 组件定义,以及本地或被定义直接引用的变量、集合/模式、样式和 shader 定义;既不扫描 + 画布中的使用情况,也不加载所有页面。游标可继续读取遗漏定义;使用同一目录精确查询 + 某个引用时,返回该资源的有界定义。 +- `apply_canvas`:提交一次受限 HTML + 可确定转换的 Tailwind utility 目标结果,其中可以使用基础元素、 + 目录组件标签、设计系统短引用、类型化的 Figma 专有状态、经过净化的 SVG 和内容寻址图片。 + 扩展会在本地解析、验证、计算与实时画布的差异、应用修改并校验结构。画布创作要求当前 + Figma Design 文件具有编辑权限。 +- `get_screenshot`:返回一张有大小限制的渲染 PNG,用于按需视觉验证。 - `get_structure`:当前选中节点的结构信息(id、类型、几何数据)。 - 二进制资源会通过工具响应中的元数据 + HTTP 下载地址(`asset.url`)提供;MCP 不再暴露 asset 资源模板。 @@ -222,7 +229,7 @@ Figma 也提供官方的 [remote 与 desktop MCP server](https://developers.figm TemPad Dev agent setup 对话框。 -1. 安装 Node.js 18.20.0 或更高版本并确保 `npx` 可用。在希望 agent 检查的 Figma 标签页中保持 TemPad Dev 打开,然后启用 **Preferences → Agent integration → MCP access**。出现提示时,请允许连接到 loopback 地址 `127.0.0.1`。只有在希望 agent 修改该文件时,才另外启用 **Canvas writes**。 +1. 安装 Node.js 18.20.0 或更高版本并确保 `npx` 可用。在希望 agent 检查的 Figma 标签页中保持 TemPad Dev 打开,然后启用 **Preferences → Agent integration → MCP access**。出现提示时,请允许连接到 loopback 地址 `127.0.0.1`。启用 MCP access 且当前 Figma Design 文件可编辑时,即可进行画布创作。 2. 点击 **Set up agents**,选择 Codex、Cursor、Claude Code、Gemini、VS Code、OpenCode 或 TRAE,然后按界面显示的路径配置。其它兼容客户端请选择 **Other**。这里的选择只会切换说明,不会绑定或激活 agent。 3. 如果界面提供直接操作,请优先使用。所有备用命令和 config 都会完整显示,便于检查和复制。Codex 与 Claude Code 的 plugin 同时包含 MCP、`figma-design-to-code` 和 `figma-canvas-authoring` skill;其它路径会分别展示 MCP 与独立 skill 的配置步骤。 diff --git a/agent-plugins/tempad-dev/.claude-plugin/plugin.json b/agent-plugins/tempad-dev/.claude-plugin/plugin.json index ed2ed73b..9b902f72 100644 --- a/agent-plugins/tempad-dev/.claude-plugin/plugin.json +++ b/agent-plugins/tempad-dev/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "tempad-dev", - "version": "0.1.0", - "description": "Turn Figma evidence into UI code and create native designs from an existing Figma design system.", + "version": "0.1.2", + "description": "Turn Figma evidence into UI code and create native Figma designs.", "author": { "name": "TemPad Dev" }, diff --git a/agent-plugins/tempad-dev/.codex-plugin/plugin.json b/agent-plugins/tempad-dev/.codex-plugin/plugin.json index 16fccc11..a57e4ee9 100644 --- a/agent-plugins/tempad-dev/.codex-plugin/plugin.json +++ b/agent-plugins/tempad-dev/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "tempad-dev", - "version": "0.1.1", - "description": "Use TemPad Dev to turn Figma evidence into UI code and create native designs from an existing Figma design system.", + "version": "0.1.2", + "description": "Use TemPad Dev to turn Figma evidence into UI code and create native Figma designs.", "author": { "name": "TemPad Dev" }, @@ -21,8 +21,8 @@ "skills": "./skills/", "interface": { "displayName": "TemPad Dev", - "shortDescription": "Read Figma evidence and create design-system-grounded canvas content.", - "longDescription": "TemPad Dev packages skills for implementing Figma designs in code and authoring native Figma content from the active file's design system, together with its MCP server configuration.", + "shortDescription": "Read Figma evidence and author native canvas content.", + "longDescription": "TemPad Dev packages skills for implementing Figma designs in code and authoring native Figma content with optional accessible design-system resources, together with its MCP server configuration.", "developerName": "TemPad Dev", "category": "Design", "capabilities": [ @@ -37,9 +37,11 @@ "defaultPrompt": [ "Use TemPad Dev to implement the selected Figma node.", "Inspect the selected Figma node with TemPad Dev.", - "Create a Figma design using the active file's components and variables." + "Create a native Figma design while following my resource constraints." ], - "brandColor": "#0098FF" + "brandColor": "#0098FF", + "composerIcon": "./assets/icon-padded.svg", + "logo": "./assets/icon-padded.svg" }, "mcpServers": "./.mcp.json" } diff --git a/agent-plugins/tempad-dev/.mcp.json b/agent-plugins/tempad-dev/.mcp.json index e8471748..60dbe602 100644 --- a/agent-plugins/tempad-dev/.mcp.json +++ b/agent-plugins/tempad-dev/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "tempad-dev": { "command": "npx", - "args": ["-y", "@tempad-dev/mcp@alpha"] + "args": ["-y", "@tempad-dev/mcp@0.8.0-alpha.0"] } } } diff --git a/agent-plugins/tempad-dev/README.md b/agent-plugins/tempad-dev/README.md index b12fbba5..38333891 100644 --- a/agent-plugins/tempad-dev/README.md +++ b/agent-plugins/tempad-dev/README.md @@ -3,8 +3,42 @@ This plugin packages the TemPad Dev agent integration for Codex and Claude Code. It bundles: - `figma-design-to-code` for turning Figma evidence into project-consistent UI code -- `figma-canvas-authoring` for designing in Figma with the active file's components and variables -- the TemPad Dev MCP server configuration for design evidence and opt-in canvas authoring +- `figma-canvas-authoring` for grounded native Figma design with accessible component definitions, + file resources, and progressive style guidance +- the TemPad Dev MCP server configuration for design evidence and MCP-gated canvas authoring + +This tracked directory is the source used by the Git marketplace. Its MCP configuration pins an +exact published `@tempad-dev/mcp` version, so installing the plugin never follows a movable npm +dist-tag. The agent plugin itself is not published to npm. + +`pnpm agent-plugin:dev` is the only agent-plugin build command. It synchronizes the shared skill, +icons, and exact MCP package version into this directory, then creates an ignored +`tempad-dev-dev` marketplace under `.dev/`. The development plugin points directly at the current +checkout's MCP build. Its ignored MCP configuration therefore contains a machine-local absolute +path; this is what lets an installed plugin use the latest workspace build without being rebuilt or +reinstalled. Ordinary `pnpm build` does not modify either plugin. + +Run `pnpm dev` while developing. It watches the extension, shared package, and MCP server. MCP-only +changes are picked up when Codex starts a new task or Claude Code reloads plugins; rerun +`pnpm agent-plugin:dev` only when the skills, manifests, icons, or marketplace metadata change. + +Add the development marketplace and plugin once for each client: + +```bash +codex plugin marketplace add ./.dev +codex plugin add tempad-dev-dev@tempad-dev-dev + +claude plugin marketplace add ./.dev +claude plugin install tempad-dev-dev@tempad-dev-dev --scope local +``` + +After rebuilding the agent plugin, refresh Codex with +`codex plugin add tempad-dev-dev@tempad-dev-dev` and start a new task. Refresh Claude Code with +`claude plugin marketplace update tempad-dev-dev && claude plugin update tempad-dev-dev@tempad-dev-dev --scope local`, +then run `/reload-plugins`. + +The release order is: publish the exact MCP package version from the release commit, then merge the +same commit so the Git marketplace exposes the plugin that pins that version. Install it for Codex: @@ -26,7 +60,7 @@ The plugin appears in Claude Desktop after the marketplace is added. Both client skill and MCP server configuration from this directory. Before using the integration, open TemPad Dev in Figma, then open **Preferences -> Agent -integration** and enable **MCP access**. Enable **Canvas writes** separately only when the agent -should modify the active Figma file. +integration** and enable **MCP access**. Canvas authoring is then available when the active Figma +Design file is editable. For app, CLI, direct MCP, and manual fallbacks, see the [complete setup guide](../../README.md#agent-integration). diff --git a/agent-plugins/tempad-dev/assets/icon-padded.svg b/agent-plugins/tempad-dev/assets/icon-padded.svg new file mode 100644 index 00000000..bdbdf027 --- /dev/null +++ b/agent-plugins/tempad-dev/assets/icon-padded.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/agent-plugins/tempad-dev/assets/icon.png b/agent-plugins/tempad-dev/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..67c2135c8f2d8b61bb919cd9fc1f1a71f7d99c46 GIT binary patch literal 752 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSoCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&di3{0(_E{-7;jBoGQ`-KFGG<+;h)$-Z0++at` z&BorfTg_4~$Oim=u4(Jt(AgSsSxUIvzjU!-PtUYKCL=fl0q``=%C=qaki zKf5!t^82)mFQ0ENmJwbJ`>fgNrvltIZGRz^FxTCX4^J(;* zrPua{zn<)|`fM-9oc_j*AOt$DWHfuPxZP z*D9t(?J(3*YPX{>N85>huow9dB8AVl&R- eu^i!`@8U0JdNgw8H;MxjD1)b~pUXO@geCxQ)F0>o literal 0 HcmV?d00001 diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/SKILL.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/SKILL.md index 74d25cdf..f9c1587c 100644 --- a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/SKILL.md +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/SKILL.md @@ -1,161 +1,150 @@ --- name: figma-canvas-authoring description: >- - Create or update native Figma designs with TemPad Dev MCP using components - and variables from the active file's design system. Use when the user asks - an agent to design, compose, draft, or refine screens or components directly - on the Figma canvas, including an empty canvas or document. Do not use for - Figma-to-code implementation, critique without canvas edits, raw Plugin API - automation, or unapproved design-system invention. + Create or update native Figma designs with TemPad Dev MCP, following the + user's explicit resource constraints. Reuse accessible components, variables, + and styles only when allowed; create or extend a local design system only + when the user explicitly requests it. Use for screens, drafts, and reusable + components on the Figma canvas, including an empty document. Do not use for + Figma-to-code, critique without edits, raw Plugin API automation, or + unapproved design-system invention. --- -# TemPad Dev: Figma Canvas Authoring +# Design on the Figma canvas -Turn product intent into native, editable Figma content. Make design decisions -from user intent and available evidence; let TemPad Dev perform deterministic -canvas reconciliation. +Turn product intent into one native, editable Figma result. Decide what to +design; let TemPad Dev resolve resources, validate native state, diff the latest +canvas, and apply the safe patch. -TemPad Dev MCP must be connected to the intended Figma file. Canvas writes must -be enabled before calling `tempad-dev:apply_canvas`. If either is unavailable, -stop and tell the user how to reconnect or enable it; never work around the -write boundary. - -## Sources of truth - -Use each source for a different job: - -- **User input** defines the product goal, content, scope, and acceptable - creative freedom. -- **Figma design-system evidence** from `tempad-dev:get_design_system` defines - reusable component and variable identities. -- **Existing canvas evidence** from `tempad-dev:get_code` and - `tempad-dev:get_structure` defines visible composition and the exact update - scope when editing existing work. -- **Project evidence**, when a repository is available, supplies higher-level - design principles, product patterns, terminology, and constraints. - -Never invent a Figma component or variable `id` or `key`. A familiar name is -not proof that two design-system resources are equivalent. - -## Workflow - -### 1. Establish the task and scope - -Determine whether the user wants to create new content or update an existing -subtree. - -- For an update, resolve one explicit target node. Use a user-provided - `nodeId`, or call `tempad-dev:get_structure` on the current selection when - the exact root identity is needed. -- Inspect existing content with `tempad-dev:get_code` only when its visual - composition matters to the requested design. -- Do not add speculative screens, states, interactions, or content outside the - requested scope. -- Ask only when missing product intent would materially change the design. - -### 2. Read the available design system - -Call `tempad-dev:get_design_system` with one concrete task query such as -`settings form`, `checkout summary`, or `数据表格`. - -Treat returned components, component properties, variables, scopes, IDs, and -keys as design facts. Prefer: +Require the intended Figma tab to have MCP access and the current Figma Design +file to be editable. Never bypass that boundary or send raw Plugin API +operations. -1. an existing component instance for a product control or repeated pattern -2. an exposed component property for its supported variation -3. a semantic variable for a supported visual or layout field -4. a primitive or literal only when the design system has a real gap +## Instruction priority -The result is intentionally scoped and ranked; it is not proof that every -subscribed Figma library was searched. +Apply this order: -If a query returns no matches but does not report that components and variables -are absent, retry once without a query to distinguish a query miss from missing -design-system evidence. Do not repeatedly broaden searches. +1. explicit user requirements and prohibitions; +2. permitted project and file evidence; +3. this skill's defaults; +4. general design heuristics. -### 3. Handle an empty document +Never let a default workflow override the user. Safety, edit permission, exact +scope, and declarative-only writes remain hard boundaries. -An empty canvas is not automatically a blocker. Base the decision on available -design-system evidence: +## Establish only the necessary context -- **Components or variables are returned:** create the requested design - normally with `apply_canvas` in `create` mode. -- **No resources are discoverable, but the user or trusted project - documentation provides real component or variable keys:** use those - references and let Figma validate or import them. -- **No resources or trusted references exist:** do not pretend the result - follows a Figma design system. Ask the user to choose one of these paths: - - open or seed a page containing representative design-system instances and - bound variables - - provide a reference file or real library component/variable keys - - explicitly authorize a primitive draft that can be migrated later +Before writing, determine: -When a primitive draft is explicitly authorized: +- the task, content hierarchy, primary action, and smallest complete scope; +- whether this is a create or an update, and the exact update target; +- the visual direction and the evidence permitted to ground it; +- whether to reuse existing resources, compose directly, or explicitly author + a requested design-system resource; +- which native capabilities the result actually needs. -- label it as a draft rather than design-system-compliant work -- use only user-provided or neutral values -- keep the structure small and easy to replace -- do not invent brand tokens, logos, icons, or component identities +Infer low-consequence gaps from evidence. Ask only when a missing choice would +materially change the deliverable. Do not collect broad context merely because +it is available. -The important distinction is not “empty document” versus “non-empty document”; -it is “grounded design-system evidence” versus “no such evidence.” +## Quality floor -### 4. Compose one desired result +- Make the primary task and action obvious before adding secondary content. +- Reuse local terminology, interaction patterns, density, and visual rhythm + when evidence exists. +- Establish hierarchy through layout, alignment, spacing, and type before + borders, shadows, or decorative containers. +- Use a small, consistent set of type, spacing, and color roles. Avoid generic + card grids and unsupported visual conventions. +- Keep repeated elements and states consistent, and use concise realistic copy. +- Use real component or library icons and real or generated imagery. Never + imitate them with text glyphs or primitive mosaics. +- Design the smallest result that feels complete for the requested task. -Describe the result as a `CanvasNodeSpec` tree, not as a sequence of Figma API -operations. +## Workflow -- Use stable, semantic, unique `key` values and reuse them in later updates. -- Prefer `INSTANCE` nodes over redrawing available components. -- Bind returned variables wherever their semantics and scopes match. -- When a literal and variable binding target the same field, expect the - variable binding to win. Keep a valid solid fallback paint for bound fill or - stroke fields. -- Use component properties instead of detaching or rebuilding an instance. -- Keep hierarchy native and editable. Use `FRAME` for containers and auto - layout where the design calls for it. -- Stay within the current authoring surface. Do not approximate unsupported - images, logos, icons, gradients, effects, or arbitrary vector artwork with - unrelated primitives. - -Favor the smallest coherent design that satisfies the request. Consistency with -the available design system matters more than novelty. - -### 5. Apply once - -Send one `tempad-dev:apply_canvas` call: - -- Use `create` for a new tree. Its root must be a `FRAME`. -- Use `update` with one explicit `targetNodeId` for an existing subtree. -- Supply the desired result, not individual mutation steps. -- Remember that omitted fields and existing omitted children are preserved. - Deletion is not supported. - -Do not split a design into repeated tool calls merely to mimic Plugin API -operations. Split only when the tool's documented size or depth limits require -independent, meaningful subtrees. - -### 6. Verify and refine - -Read `rootNodeId`, `nodeIdsByKey`, `mutationCount`, and any warnings from the -result. - -- Retain `nodeIdsByKey` and reuse the returned identities for later refinement. -- Use `tempad-dev:get_structure` only to verify hierarchy, ordering, or - geometry. -- Use `tempad-dev:get_code` when exact rendered style evidence is needed. -- Make at most one evidence-based refinement pass unless the user asks for - further iteration. - -If a component, variable, font, or component property cannot be resolved, fix -the reference or ask the user. Do not silently replace it with an imitation. - -## Safety boundaries - -- Never bypass the session-only Canvas writes toggle. -- Never update outside the explicit target subtree. -- Never use names as identity when an ID or stable key is required. -- Never delete, detach, publish, or create design-system resources. -- Never send arbitrary JavaScript or emulate raw Figma Plugin API calls. -- Rely on `apply_canvas` validation and rollback, but still keep each requested - change narrowly scoped. +1. **Fix the scope.** Choose one create or update target. Use `get_code` only + when existing visual composition matters. Use `get_structure` only when + hierarchy, ordering, an intentional spatial relationship, or managed + `data-key` identity is unclear—not merely to find an empty create position. + Use the active Figma page as the default create destination. Omit top-level + `page` unless the user explicitly requests another existing or new page, or + available task evidence clearly requires one. If page context is missing or + ambiguous, do not ask, infer, create, rename, reorder, or target another + page; write to the current page. +2. **Ground the visual direction.** Follow the user first, then permitted file + or project evidence and a clearly applicable installed skill. If material + visual invention remains underspecified, read + [style-grounding.md](references/style-grounding.md). Skip this branch for + exact reproduction and mechanical edits. +3. **Choose one resource path.** + - **Reuse:** when existing design-system consistency is allowed and relevant, + read [design-system-reuse.md](references/design-system-reuse.md). + - **Direct:** use primitives, literal values, and allowed external assets. + Do not call `get_design_system`, send `catalogId`, or use catalog refs. + - **Author:** only when the user explicitly requests a local reusable + component, variable, style, or design-system extension. Read + [design-system-authoring.md](references/design-system-authoring.md). An + empty file or repeated UI does not imply this request. +4. **Load only exact syntax needed.** Always read + [canvas-html.md](references/canvas-html.md), then only the capability + references selected below. Copy complete examples for private native shapes; + never infer them from the Figma Plugin API or from validation failures. +5. **Apply one desired result.** Call `apply_canvas` once per coherent root. If + a genuinely large result must be split, divide it at meaningful screen or + section boundaries, never into node-level operations. On create, omit root + translation unless exact placement is part of the request; TemPad Dev places + unspecified roots on the current page without overlap. +6. **Verify once.** Read structural verification. For a new composition or + material visual change, normally call `get_screenshot` once on the result + root; skip it for mechanical text, token, prop, or hierarchy-only edits. + Make at most one evidence-based correction. + +Do not turn this workflow into repeated API-like mutations. + +## Reference routing + +Load a reference only when its capability is part of the requested result: + +- page, section, group, Boolean, masks, transforms, shapes, or vectors: + [document-geometry.md](references/document-geometry.md) +- native paints, media, effects, shaders, grids, or guides: + [paints-effects.md](references/paints-effects.md) +- rich text, range styles, lists, or hyperlinks: + [rich-text.md](references/rich-text.md) +- icon sources, typeface choice, or generated imagery: + [visual-assets.md](references/visual-assets.md) +- authored components, variant sets, properties, or Slots: + [component-authoring.md](references/component-authoring.md) +- local variables, collections, modes, or bindings: + [variables.md](references/variables.md) +- local Paint, Text, Effect, or Grid styles and bindings: + [styles.md](references/styles.md) + +## Create and update + +Create mode describes one complete new root. Update mode is an incremental +declarative patch: + +- `targetNodeId` is the only mutable subtree; +- supplied nodes and fields state desired values; +- omitted live children and fields are preserved; +- `removeKeys` explicitly makes owned descendants absent; +- `markup: null` is the isolated assertion that the managed update root itself + must be absent. + +Never infer deletion from omission. Keep `data-key` stable across updates; +names are presentation only. After context loss, recover managed identities +from `get_structure.authoringKey` instead of inventing replacements. + +## Safety + +- Never write outside the target scope or use names as identity. +- Never remove unkeyed or manual content, externally referenced nodes, + unmanaged resources, or a component that still has instances. +- Never mutate remote resources, publish, detach or reset instances, or execute + arbitrary JavaScript. +- Use explicit `null` only for supported links or managed resources that the + requested result truly removes. +- Treat validation failure as evidence to fix the result, not permission to + imitate an unresolved design-system resource. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/agents/openai.yaml b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/agents/openai.yaml index 30d070b9..25e5075e 100644 --- a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/agents/openai.yaml +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/agents/openai.yaml @@ -1,4 +1,7 @@ interface: display_name: 'Design in Figma' - short_description: 'Create Figma designs from its existing design system' - default_prompt: 'Use $figma-canvas-authoring to create a settings screen in the active Figma file using its existing components and variables.' + short_description: 'Create user-directed native Figma designs' + icon_small: './assets/icon.svg' + icon_large: './assets/icon.svg' + brand_color: '#0098FF' + default_prompt: 'Use $figma-canvas-authoring to create a native Figma design while following my resource constraints.' diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/assets/icon.svg b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/assets/icon.svg new file mode 100644 index 00000000..bdbdf027 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/assets/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/canvas-html.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/canvas-html.md new file mode 100644 index 00000000..b3e4d416 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/canvas-html.md @@ -0,0 +1,124 @@ +# Canvas HTML and Tailwind subset + +Canvas HTML is a desired-result language, not browser rendering. + +Prefer the supported native Tailwind utilities below; use arbitrary pixel values only when the +result is off the default scale. Numeric spacing utilities use Tailwind v4's default `4px` unit. Theme +extensions, variants, plugins, and utilities whose meaning depends on a browser viewport or CSS +cascade remain unsupported. + +This complete Direct call creates one primitive result without catalog state: + +```json +{ + "mode": "create", + "markup": "
Update availableRestart when you are ready.
" +} +``` + +## Elements and identity + +- Use `div`, `span`, or a component tag returned by the active catalog. +- Give every element one unique `data-key` of letters, numbers, `. / : _ -`. +- Use `data-node-id` only in update mode to adopt an exact live node. +- Use no arbitrary attributes on `div` or `span`. Common catalog links use + `data-var-="vN"` and `data-style-="sN"`; `"none"` explicitly + unlinks that field. +- A `span` contains text only. Add `whitespace-pre-wrap` when repeated spaces + or line breaks are intentional. +- A component tag is childless, includes its returned `data-ref`, and accepts + returned props plus the shared class, identity, variable, and style + attributes. + +Variable attribute names are the native field in kebab case: fill, stroke, +characters, visible, width/height and min/max bounds, gap and grid/counter +gaps, four paddings, corner radius and four corners, stroke weight and four +sides, opacity, and the whole-node font/line-height/letter-spacing/paragraph +fields. Style attributes are `data-style-fill`, `stroke`, `text`, `effect`, +and `grid`. Node-type and fallback requirements still apply. + +Every primitive needs one width and one height. Supported fixed forms are: + +- default spacing: `w-N`, `h-N`, `size-N` (`N * 4px`), plus `w-px`, `h-px`, `size-px` +- default width containers: `w-3xs|2xs|xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl` +- exact: `w-[Npx]`, `h-[Npx]`, `size-[Npx]` +- hug: `w-fit`, `h-fit` +- hug both axes: `size-fit` +- fill: `w-full`, `h-full`, or `size-full` for both axes +- bounds: numeric, `px`, or arbitrary-pixel values with `min-w`, `max-w`, `min-h`, or `max-h`; + width bounds also accept the default container names; use `min-w-none`, `max-w-none`, + `min-h-none`, or `max-h-none` to clear a bound in an update + +Use `w-full` only on the cross axis of `flex-col`, `h-full` only on the cross +axis of `flex-row`, and `grow` on the main axis; use `grow-0` to clear growth. Grid children may +fill their cell. Direct width and height variables require fixed-size fallbacks. Fixed +sizes are at least `0.01px`; native lines use `h-[0px]`. + +## Layout + +Use Auto Layout for ordinary product UI: + +- `flex flex-row` or `flex flex-col` +- `items-start|center|end|baseline` +- `justify-start|center|end|between` +- `flex-wrap`, `flex-nowrap`, `content-between`, `content-normal` +- `gap-N`, `gap-x-N`, `gap-y-N`, or exact `[Npx]` +- `p`, `px`, `py`, `pt`, `pr`, `pb`, `pl` with `-N`, `-px`, or `-[Npx]` +- `box-border`, `box-content` + +For grid use: + +- `grid grid-cols-N` +- optional `grid-rows-N` +- custom tracks: `grid-cols-[1fr_240px_fit-content(100%)]` +- optional `grid-flow-row` or `grid-flow-none` +- child placement: `col-start-N`, `row-start-N`, `col-span-N`, `row-span-N` +- child alignment: `justify-self-auto|start|center|end`, + `self-auto|start|center|end` + +Give a manual grid child both row and column starts or neither. Auto-flow +children use source order and cannot set explicit starts. + +For deliberate freeform composition, omit layout classes and give every described child +`absolute left-N top-N`, the negative forms `-left-N -top-N`, exact `[Npx]` values, or a native +relative transform. +An absolute child cannot grow or fill an axis. Use `static` to return an existing absolute child to +Auto Layout during an update. + +## Appearance and text + +Frame appearance: + +- `bg-transparent|white|black`, or an exact CSS hex value +- `border`, `border-N`, `border-[Npx]`; use `border-x|y|t|r|b|l` with the same widths +- `border-white|black`, or an exact CSS hex value +- `rounded`, `rounded-none|xs|sm|md|lg|xl|2xl|3xl|4xl|full`, or `rounded-[Npx]`; + prefix the value with `t`, `r`, `b`, `l`, `tl`, `tr`, `br`, or `bl` for individual sides/corners +- `overflow-hidden`, `overflow-visible` + +Shared appearance: + +- `opacity-N` (`N%`) or `opacity-[0..1]`, `hidden`, `visible` +- `rotate-N`, `-rotate-N`, `rotate-none`, or `rotate-[Ndeg]` +- `mix-blend-` with `pass-through`, `normal`, `darken`, `multiply`, + `plus-darker`, `color-burn`, `lighten`, `screen`, `plus-lighter`, + `color-dodge`, `overlay`, `soft-light`, `hard-light`, `difference`, + `exclusion`, `hue`, `saturation`, `color`, or `luminosity` + +Text: + +- `font-sans`, `font-thin|extralight|light|normal|medium|semibold|bold|extrabold|black` +- `text-xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl` with their default line + heights, `text-SIZE/N`, or `text-[Npx]` +- `leading-none|tight|snug|normal|relaxed|loose`, `leading-N`, `leading-[Npx]`, + `leading-[N%]`, or a unitless arbitrary ratio +- `tracking-tighter|tight|normal|wide|wider|widest`, `tracking-[Npx]`, + `tracking-[N%]`, or `tracking-[Nem]` +- `text-left|center|right|justify` +- `normal-case`, `uppercase`, `lowercase`, `capitalize` +- `no-underline`, `underline`, `line-through` +- `truncate`, `line-clamp-N`, `line-clamp-none` +- `text-white|black`, an exact CSS hex value, `whitespace-pre-wrap` + +Unknown elements, attributes, classes, CSS, responsive/state prefixes, custom theme names, margins, +percentage sizing, and plugins fail closed instead of being ignored. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/component-authoring.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/component-authoring.md new file mode 100644 index 00000000..937ee7a1 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/component-authoring.md @@ -0,0 +1,112 @@ +# Author reusable components + +Use this reference only when the user explicitly requests a reusable local +component or an explicitly requested design-system task requires one. This +reference explains representation; it is not a reason to componentize an +ordinary screen or create a component library. New local components do not +require `get_design_system`; a catalog is needed only when a property or nested +instance deliberately references an existing component. + +Copy a complete recipe and change its design facts. Do not infer TemPad's +component shape from raw Plugin API calls. + +## Component and properties + +This complete call creates a component with TEXT and BOOLEAN properties and +connects both properties to its label layer. + +```json +{ + "mode": "create", + "markup": "
Continue
", + "native": { + "button": { + "figma": { + "name": "Button", + "component": { + "type": "COMPONENT", + "properties": { + "label": { + "type": "TEXT", + "name": "Label", + "defaultValue": "Continue" + }, + "show-label": { + "type": "BOOLEAN", + "name": "Show label", + "defaultValue": true + } + } + } + } + }, + "button/label": { + "figma": { + "componentPropertyReferences": { + "characters": "label", + "visible": "show-label" + } + } + } + } +} +``` + +Stable property keys such as `label` connect definitions and sublayer +references inside the same result. They are not the generated Figma property +names. Supported authored property definitions are `BOOLEAN`, `TEXT`, and +`INSTANCE_SWAP`. Link sublayers with `visible`, `characters`, or +`mainComponent` respectively. + +## Variant set + +This complete call creates two components and combines them into one variant +set. Direct children of a new set must all be authored components. Variant +names encode axes using Figma's `Property=Value` convention. + +```json +{ + "mode": "create", + "markup": "
Continue
Continue
", + "native": { + "button-set": { + "figma": { + "name": "Button", + "component": { "type": "COMPONENT_SET" } + } + }, + "button/default": { + "figma": { + "name": "State=Default", + "component": { "type": "COMPONENT" } + } + }, + "button/hover": { + "figma": { + "name": "State=Hover", + "component": { "type": "COMPONENT" } + } + } + } +} +``` + +Use `descriptionMarkdown` and `documentationLink` only for real guidance. +Define shared properties on the component set rather than on one variant. + +## Slots and instances + +Use `figma.slot` only when flexible nested content is an intentional component +API. A new slot must be inside a local authored component and must include +`property.name`; its existing markup children become default slot content. +Optional settings cover stretching, empty display, child limits, and preferred +values. + +An `INSTANCE_SWAP` default or preferred value must resolve to a real component +or set by exact local ID, library key, or catalog `{ "ref": "cN" }`. Never +invent any of those identities. Advanced instance state belongs under +`figma.instance`; omission preserves normal Figma override behavior. + +Never edit a remote component, nest a main component inside another main +component, delete a component with surviving instances, or create properties +and variants that the requested component API does not need. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/design-system-authoring.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/design-system-authoring.md new file mode 100644 index 00000000..f8179d06 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/design-system-authoring.md @@ -0,0 +1,55 @@ +# Create or extend a design system + +Read this reference only when the user explicitly asks to create or supplement +a design system. Do not enter this workflow for ordinary canvas composition, +an empty file, or repeated visual elements alone. + +## Establish the basis + +Treat the user's requested scope as the boundary. Use permitted existing +designs, brand material, and named product requirements as evidence. Do not +claim project-specific intent when none exists; make the smallest necessary +general choice and keep it easy to revise. + +Before writing, identify: + +- the concrete screens or usage cases the system must support; +- the visual roles that actually recur; +- the reusable component responsibilities and real variation axes; +- anything the user explicitly excludes. + +Do not design a complete hypothetical system around one example. + +## Model only demonstrated decisions + +- Create a variable for a repeated semantic decision, not merely every repeated + literal. Name it by role rather than current value. +- Create a style when a reusable native style is part of the requested system; + do not duplicate the same decision as unrelated styles and variables without + a concrete need. +- Create a component when it represents a reusable responsibility with a + stable anatomy. Expose only content, state, or substitution that real usages + need. +- Add a variant axis only for a supported categorical choice. Do not encode + arbitrary content as variants or generate an unused Cartesian product. +- Prefer composition and Slots for genuinely variable nested content. Avoid + speculative properties and premature abstraction. + +Read [variables.md](variables.md), [styles.md](styles.md), or +[component-authoring.md](component-authoring.md) only for resource types the +requested scope requires. + +## Validate through use + +Judge a resource in representative composition, not from its definition alone. +Create only the smallest examples needed to exercise meaningful content, +states, and layout behavior. Check native bindings, Auto Layout, text resizing, +property behavior, and visual consistency. In the workflow's final visual +check, inspect only visually consequential properties. + +Keep examples only when the requested deliverable includes documentation or a +specimen; otherwise avoid leaving verification scaffolding on the canvas. + +Finish when the requested usage cases are supported. Do not expand the token +taxonomy, component inventory, modes, variants, or documentation for imagined +future needs. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/design-system-reuse.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/design-system-reuse.md new file mode 100644 index 00000000..f2017987 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/design-system-reuse.md @@ -0,0 +1,68 @@ +# Reuse an existing design system + +Read this reference only when reuse is allowed and relevant to the requested +result. If the user rejects a design system, use the Direct path instead. + +## Discover definitions + +Call `get_design_system` without arguments. It returns an immutable, +deterministic catalog of definitions already accessible to Figma: + +- `catalogId` scopes all short refs; +- components provide a tag, props, source page, and native size; +- variables, collections, modes, styles, and shaders use short refs such as + `v1`, `k1`, `m1_2`, `s1`, and `h1`; +- `omitted` and `nextCursor` mean more definitions remain in the same catalog. + +The catalog does not scan canvas usage, load pages, or rank resources for the +task. Select only from names, source pages, summaries, props, types, scopes, +and default values. Continue a cursor or inspect an exact ref only until enough +evidence exists. + +Use this preference order: + +1. real catalog component; +2. supported component prop; +3. matching native style; +4. semantic variable; +5. primitive or literal for a real gap. + +When valid variants, component anatomy, layout, or semantic meaning affects +the result, call `get_design_system` again with the exact `ref` and same +`catalogId`. Use its `previewNodeId` with `get_screenshot` only when visual form +affects the choice. Read an existing composition with `get_code` or +`get_screenshot`; the catalog cannot infer usage conventions. + +Do not invent refs, native IDs, keys, component props, or variant values. + +## Apply catalog resources + +Component tags are childless, include the returned `data-ref`, and use exact +returned prop names and values. Omitted size classes preserve native component +size. Bind common variables and styles beside the affected element with +`data-var-="vN"` and `data-style-="sN"`. Put collection modes or +strict native links in `native[data-key]`. + +This complete example illustrates the contract; replace every illustrative ref +with one returned by the active catalog: + +```json +{ + "mode": "create", + "catalogId": "ds_example", + "markup": "
Team settings
", + "native": { + "settings": { + "variableModes": { "k1": "m1_1" } + } + } +} +``` + +If an exact required component is absent, do not assume it exists on an +unloaded page. Ask the user to open its definition page when that design system +is mandatory; otherwise use the normal primitive fallback. + +An empty canvas is not a blocker. Reuse discoverable definitions when allowed; +otherwise create a small coherent primitive draft. Do not create a token or +component library merely to make one screen. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/document-geometry.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/document-geometry.md new file mode 100644 index 00000000..a6c9d3ef --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/document-geometry.md @@ -0,0 +1,61 @@ +# Document and native geometry + +Use `native[key].figma` only for native state that HTML and classes cannot +express honestly. This remains declarative desired state. + +## Page and containers + +Top-level `page` can set a name, exact zero-based document index, solid RGBA +background, ordered guides, and explicit variable modes. In create mode it may +target an existing `id`, adopt/reuse `pageKey`, or create a named page for a +missing key. Updates stay on the target node's page. + +Use: + +- `figma.section: { contentsHidden? }` for native canvas organization; +- `figma.group: true` for an intrinsic layer group; +- `figma.booleanOperation: "UNION" | "SUBTRACT" | "INTERSECT" | "EXCLUDE"` + for non-destructive geometry. + +Sections use fixed pixel dimensions and freeform children. Groups and Boolean +operations use `w-fit h-fit`; their direct children are freeform. A new group +needs one child and a new Boolean operation needs two. When supplying children +of an existing intrinsic container, describe every live direct child because +order is semantic. + +## Shapes and vectors + +Use a childless `div` with `figma.shape`: + +- `{ "type": "RECTANGLE" }` +- `{ "type": "LINE" }` +- `{ "type": "ELLIPSE", "arc": { "startAngle", "endAngle", "innerRadius" } }` +- `{ "type": "POLYGON", "pointCount": 3 }` +- `{ "type": "STAR", "pointCount": 5, "innerRadius": 0.5 }` +- `{ "type": "VECTOR", "paths": [...] }` +- `{ "type": "VECTOR", "network": {...}, "handleMirroring": "..." }` + +Use exact vector paths with uppercase `M L Q C Z` for ordinary icons. Use a +vector network only for branching segments, per-vertex state, or +region-specific fills/styles. Do not supply paths and a network together. New +vectors need geometry; omission preserves it on update and an empty +path/network clears it. + +## Transform, masks, and native state + +- `figma.name` sets the display-layer name; `data-key` remains identity. +- `locked` and `aspectRatioLocked` set native interaction state. +- `relativeTransform` is a complete native 2×3 unit-axis transform. Width and + height carry scale. Do not combine it with `rotate-*`. +- `stroke` carries weight(s), alignment, caps, joins, miter, and dashes. +- `corners` carries radius/radii and smoothing. +- `mask` is `"ALPHA"`, `"VECTOR"`, `"LUMINANCE"`, or `null`. + +Put a mask before the siblings it masks, keep the mask group in one dedicated +frame, and describe every direct sibling during an update. A non-null mask +must have at least one following sibling. Omission preserves mask state; null +disables it. + +Use `{ "ref": "…" }` for catalog resources nested in advanced native state. +Use `sourceCanvasKey` or `{ "canvasKey": "…" }` for same-result forward node +references. Never insert raw Plugin API calls. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/paints-effects.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/paints-effects.md new file mode 100644 index 00000000..300380ee --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/paints-effects.md @@ -0,0 +1,68 @@ +# Paints, effects, grids, guides, and media + +Prefer a matching catalog style. Use direct native arrays only when no style +expresses the required state. + +## Catalog links + +Keep common refs on the element: + +```html +
+``` + +A style owns its channel. Do not combine a non-null fill/stroke style with a +whole-node variable on the same paint. A styled stroke still needs literal, +typed, or variable-bound geometry. `null` unlinks; omission preserves. + +## Native paint and effect stacks + +`figma.fills` and `figma.strokes` support ordered native: + +- solid paints; +- linear, radial, angular, and diamond gradients; +- image and video paints; +- Pattern paints; +- fill shaders. + +`figma.effects` supports ordered shadows, normal/progressive blur, noise, +texture, glass, and effect shaders. + +Omission preserves a stack; `[]` clears it. A direct stack cannot share its +channel with a literal class, whole-node variable, or native style. + +Use `{ "ref": "v1" }` for nested color/effect variables and +`{ "ref": "h1" }` for a shader ID. Use only returned shader property IDs and +declared value shapes. + +For images use exactly one same-file `imageHash`, HTTP(S) `imageUrl`, or +call-scoped `assetKey` declared as a full-SHA-256 Hub IMAGE asset. PNG, JPEG, +and GIF retain Figma's 4096×4096 limit. For videos use exactly one same-file +`videoHash` or HTTP(S) `videoUrl` for MP4, MOV, or WebM up to 100 MB. URLs must +be fetchable without credentials. Reuse `figmaImageHash`, +`figmaImageHashes`, or `figmaVideoHashes` from `get_code` only in the same +Figma file; these identify native media, not preview bytes. + +A Pattern uses exactly one existing `sourceNodeId` or same-result +`sourceCanvasKey`. + +## Layout aids + +Use a catalog Grid style when one matches. Otherwise `figma.layoutGrids` +declares ordered row, column, or square grids on frames, components, component +sets, and instances. Use `"AUTO"` for automatic row/column count. Do not bind +`sectionSize` with `STRETCH` or `offset` with `CENTER`. + +`figma.guides` carries the complete ordered X/Y guide list. Omission preserves; +`[]` clears. Page guides live under top-level `page.guides`. + +On wrapping linear Auto Layout, `figma.autoLayout` may set signed +`itemSpacing`, positive or synchronized-null `counterAxisSpacing`, and +`itemReverseZIndex`. Do not declare the same physical gap in classes and native +state. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/rich-text.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/rich-text.md new file mode 100644 index 00000000..677185eb --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/rich-text.md @@ -0,0 +1,58 @@ +# Rich text and hyperlinks + +Use a `span` for editable text. Whole-node typography normally belongs in +classes, a catalog Text style, or semantic variable bindings. + +`native[key].figma.text` carries Figma-only state: + +- exact whole-node `fontName`, `autoRename`, vertical alignment, leading trim; +- paragraph indent/spacing, list spacing, hanging punctuation/list; +- whole-node hyperlink; +- ordered rich-text `ranges`. + +Do not combine `autoRename: true` with a fixed `figma.name`. + +Use an exact whole-node font when no catalog Text style or typography variable +expresses the intended family and style: + +```json +{ + "fontName": { "family": "IBM Plex Sans", "style": "Medium" } +} +``` + +Do not combine it with `font-*` classes, a linked Text style, or font family or +style variables. Figma must have the exact family and style available. + +Range `start` and `end` are UTF-16 offsets into the final span characters. +Ranges must be ordered, non-overlapping, and contain at least one actual +property. Split overlapping intentions into non-overlapping intervals. + +A range can set: + +- font name/size, case, letter spacing, line height; +- complete underline state; +- native fills; +- Text/Paint style; +- list options, indentation, and paragraph spacing; +- hyperlink; +- supported text-range variables. + +Use `{ "ref": "s1" }` for a catalog range style and `{ "ref": "v1" }` for +a range variable. `null` unlinks a supported style or hyperlink; omission +preserves live state. + +Hyperlinks support URL and node targets. For a same-result node target, use: + +```json +{ + "type": "NODE", + "value": { "canvasKey": "settings/help" } +} +``` + +The target may appear later in markup. Do not remove a node that remains a +hyperlink target. + +If a component exposes text through a catalog prop, set the component prop +instead of reaching into its internal text layers. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/style-grounding.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/style-grounding.md new file mode 100644 index 00000000..f9268b68 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/style-grounding.md @@ -0,0 +1,80 @@ +# Ground an unspecified visual direction + +Use this reference only for new or materially redesigned UI whose visual style +is not sufficiently established by the user, permitted file or project +evidence, or a clearly applicable installed skill. Skip exact reproduction and +mechanical edits. + +## Decide whether evidence is sufficient + +A named brand or design system, representative screens, concrete visual +references, or a relevant skill with real conventions can establish direction. +Broad adjectives such as “clean,” “modern,” or “premium” cannot do so alone. + +Apply evidence in this order: + +1. explicit user requirements and prohibitions; +2. permitted project, product, and Figma evidence; +3. a clearly relevant installed brand, domain, or visual-design skill; +4. targeted current research. + +Use a skill as design evidence only when its stated scope matches the task. Do +not install, invoke, or combine unrelated skills merely to add style. If the +user forbids external research, honor that boundary. When no sufficient local +evidence or applicable skill remains, ask for direction instead of using an +unacknowledged generic default. + +## Research narrowly + +Identify the product domain, primary audience, core task, platform, and desired +tone. Then inspect two or three current, distinct references with explicit +roles: + +- use a real production product, official domain design system, or documented + pattern library for interaction, hierarchy, density, and trust conventions; +- use a strong product or editorial reference for visual language, typography, + composition, iconography, and imagery; +- include an authoritative accessibility or regulatory source when the domain + makes it material. + +Prefer deployed products, official systems, and expert case studies that match +the actual domain. Inspiration galleries and template marketplaces may inform +art direction, but never use them as the sole source for product behavior or +quality. Avoid AI-generated galleries and generic trend lists. + +Do not imitate one reference wholesale. Assign each source a question, extract +the relevant principle, and synthesize one direction for this task. This keeps +examples subsidiary to the design problem and reduces fixation on a single +surface treatment. + +Retain only a compact working brief: + +```txt +Audience + task: +Reference roles: 2–3 titles or URLs, one purpose each +Domain conventions: +Visual stance: +Decisions: typography; color; density/shape; icon language; imagery +Avoid: unsupported domain risks and visual clichés +``` + +Keep the brief under twelve short lines. Do not paste page dumps, long quotes, +or a mood board into the design task. If a relevant installed skill already +settles part of the direction, research only the missing domain facts. + +## Apply and verify + +Make the chosen direction visible in the hierarchy, spacing rhythm, type, +color, shape language, icons, and imagery. Prefer one coherent point of view to +an average of the references. Do not copy protected brand assets or distinctive +trade dress. + +Reject unsupported model defaults such as interchangeable card grids, +decorative gradients, excessive pills and rounding, glass panels, generic +illustrations, and inflated marketing copy. Any of these may still be correct +when the user, domain, or references support it. + +Use the final screenshot to compare the canvas—not the rationale—against the +brief. Check whether the domain conventions, visual stance, and concrete +decisions are actually present, and whether an unsupported generic pattern has +reappeared. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/styles.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/styles.md new file mode 100644 index 00000000..6004933d --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/styles.md @@ -0,0 +1,53 @@ +# Author local styles + +Use this reference only when the user explicitly requests a local style or a +design-system extension that needs one. Do not extract styles from an ordinary +screen. New local resources do not require a catalog; use `catalogId` only when +a nested `{ "ref": "…" }` deliberately reuses an existing catalog resource. + +Copy this complete recipe and change its design facts. Stable object keys +connect same-call resources; they are not Figma names or IDs. + +```json +{ + "mode": "create", + "markup": "
Account
", + "styles": { + "surface": { + "type": "PAINT", + "name": "Color/Surface", + "paints": [{ "type": "SOLID", "color": { "r": 1, "g": 1, "b": 1 } }] + }, + "heading": { + "type": "TEXT", + "name": "Typography/Heading", + "fontName": { "family": "Inter", "style": "Semi Bold" }, + "fontSize": 20, + "lineHeight": { "unit": "PIXELS", "value": 28 } + } + }, + "native": { + "card": { + "styles": { + "fill": { "styleKey": "surface" } + } + }, + "card/title": { + "styles": { + "text": { "styleKey": "heading" } + } + } + } +} +``` + +Style types are `PAINT`, `TEXT`, `EFFECT`, and `GRID`. Use the matching native +definition: `paints`, text fields, `effects`, or `layoutGrids`. Exact Paint, +Effect, and Grid shapes live in [paints-effects.md](paints-effects.md); read it +when the requested definition goes beyond the simple recipe above. + +Omitted fields preserve managed resource state. A top-level `null` removes a +managed style only when the user explicitly requires absence and every live +consumer is cleared or removed in the same result. Never mutate or delete a +remote resource, invent a library key, or create a broad style library for a +one-off screen. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/variables.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/variables.md new file mode 100644 index 00000000..dadd8d6a --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/variables.md @@ -0,0 +1,74 @@ +# Author local variables + +Use this reference only when the user explicitly requests a local variable or +design-system extension that needs one. Do not extract tokens from an ordinary +screen. New local resources do not require a catalog; use `catalogId` only when +a nested `{ "ref": "…" }` deliberately reuses an existing catalog resource. + +Copy this complete recipe and change its design facts. Stable object keys +connect same-call resources; they are not Figma names or IDs. + +```json +{ + "mode": "create", + "markup": "
Account
", + "variableCollections": { + "theme": { + "name": "Theme", + "modes": { + "light": { "name": "Light" }, + "dark": { "name": "Dark" } + }, + "variables": { + "surface": { + "name": "Color/Surface", + "type": "COLOR", + "scopes": ["ALL_FILLS"], + "values": { + "light": { "r": 1, "g": 1, "b": 1 }, + "dark": { "r": 0.08, "g": 0.09, "b": 0.11 } + } + }, + "space-md": { + "name": "Spacing/Medium", + "type": "FLOAT", + "scopes": ["GAP"], + "values": { + "light": 16, + "dark": 16 + } + } + } + } + }, + "native": { + "card": { + "variables": { + "fill": { "variableKey": "surface" }, + "gap": { "variableKey": "space-md" } + }, + "variableModes": { + "theme": "dark" + } + } + } +} +``` + +A new collection needs `name` and at least one named mode. A variable needs +`name`, `type`, and a value for every mode. Types are `BOOLEAN`, `COLOR`, +`FLOAT`, or `STRING`. Values may alias another variable with +`{ "variable": { "variableKey": "…" } }`. + +`native[key].variables` binds same-call variables. Use the exact supported +field name, such as `fill`, `stroke`, `gap`, `paddingTop`, `width`, `visible`, +`fontSize`, or `characters`. Keep the matching literal class when Figma needs +an initial paint or numeric fallback. + +Omitted fields preserve managed resource state. A top-level `null` removes a +managed variable, mode, or collection only when the user explicitly requires +absence and every live consumer is cleared or removed in the same result. +Never mutate remote resources, invent a parent collection or library key, or +create a broad token system for a one-off screen. Extended collections must +inherit from a real local or catalog collection and remain subject to plan +limits. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/visual-assets.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/visual-assets.md new file mode 100644 index 00000000..7bd71bd8 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/visual-assets.md @@ -0,0 +1,100 @@ +# Icons, type, and imagery + +Use real visual sources; a plausible-looking substitute is not the intended asset. + +## Icons + +Choose icon sources in this order: + +1. a suitable icon component returned by the catalog; +2. the project's established icon library or supplied asset; +3. one established, permissively licensed library whose stroke/fill style, + corner treatment, and optical weight fit the surrounding design. + +When the file has no icon assets, use a frontend icon library as the fallback. +Prefer one established by the target product. Otherwise infer outline, fill, or +duotone; weight; geometry; size/density; and states. Choose by fit and coverage. + +Use these families as reference points, not a preferred set or allowlist: + +- Lucide: neutral rounded outlines with adjustable stroke; +- Phosphor: expressive geometry; thin, light, regular, bold, fill, and duotone; +- Material Symbols: systematic outlined, rounded, and sharp styles; fill and weight variation; +- Radix Icons: compact, crisp 15x15 interface icons for dense controls; +- Iconoir: light, airy outlines with a characteristic 1.5 stroke. + +If none fits or lacks required semantics or variants, inspect two or three +different libraries in official documentation. Other trustworthy, permissively +licensed sources remain valid; never force a listed family or treat +“general-purpose” as a visual direction. Use the selected family consistently +without claiming design-system provenance or adding a frontend dependency. + +Keep one coherent icon family within a composition. Preserve source geometry +from its installed package or official distribution; do not redraw from memory, +use Unicode UI icons, or assemble icons from frames and text. Import a compact +trusted SVG directly: + +```json +{ + "assets": { + "search": { "type": "SVG", "svg": "..." } + }, + "native": { + "search-icon": { + "figma": { "svg": { "assetKey": "search", "color": "#334155" } } + } + } +} +``` + +The matching `div` must be childless and supplies the wrapper size and layout. +`color` resolves SVG `currentColor`; omit it for complete explicit-color SVGs. +Use a Hub `{ "type": "SVG", "assetHash": "" }` +declaration for larger exact SVG content. If no trustworthy source is +available, omit a nonessential icon instead of inventing one. + +## Typefaces + +Prefer a catalog Text style or typography variables because they carry the +file's real type system. Otherwise infer type from trusted project evidence. +For an empty file without such evidence, choose a small, coherent type palette +for the product and content rather than defaulting every design to the same +family. + +Use one primary family unless the concept clearly benefits from a deliberate +display/body pairing. Confirm that every exact Figma family/style is available; +do not guess style names. Express exact whole-node fonts through +`figma.text.fontName` and use ranges only for intentional mixed typography. + +## Images and illustrations + +Choose imagery in this order: + +1. a real project, user-supplied, or current-file asset; +2. an appropriate licensed source; +3. generated imagery when the agent can return a source accepted by the + connected Canvas tool. + +For generation, specify the subject, role in the layout, aspect ratio, palette, +lighting or rendering style, and important empty space. Match the surrounding +art direction instead of generating a generic stock image. + +When subagents are available and generation needs real visual exploration, +delegate it with that compact brief. Ask for one selected importable reference, +its MIME type and dimensions, and a short description—not bytes, discarded +candidates, or the generation transcript. Keep exact asset reuse, icon SVGs, +and direct URL imports in the main task. Do not delegate when the subagent +cannot return a source the Canvas tool can import. + +Apply a public generated result as an IMAGE paint using `imageUrl`; an existing +current-file `imageHash` is also valid. For content already in the local Hub, +declare `{ "type": "IMAGE", "assetHash": "" }` and use +its alias as the paint's `assetKey`. Inline bytes and local-only paths are not +supported. The main agent still owns placement and crop and should judge the +result in the final composition rather than loading intermediate candidates +into its context. + +If no usable asset can be imported, never fake a photo or illustration with +DOM-like frames, gradients, emoji, or primitive mosaics. Omit optional imagery. +When the layout must reserve media space, use one honest neutral asset frame +and report that it remains an unfilled slot. diff --git a/agent-plugins/tempad-dev/skills/figma-design-to-code/agents/openai.yaml b/agent-plugins/tempad-dev/skills/figma-design-to-code/agents/openai.yaml new file mode 100644 index 00000000..406fbf6e --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-design-to-code/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: 'Figma Design to Code' + short_description: 'Implement project-consistent UI code from Figma' + icon_small: './assets/icon.svg' + icon_large: './assets/icon.svg' + brand_color: '#0098FF' + default_prompt: 'Use $figma-design-to-code to implement the selected Figma design in the current project.' diff --git a/agent-plugins/tempad-dev/skills/figma-design-to-code/assets/icon.svg b/agent-plugins/tempad-dev/skills/figma-design-to-code/assets/icon.svg new file mode 100644 index 00000000..bdbdf027 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-design-to-code/assets/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/docs/engineering/optimization-audit.md b/docs/engineering/optimization-audit.md index 714c7193..9e19c8d2 100644 --- a/docs/engineering/optimization-audit.md +++ b/docs/engineering/optimization-audit.md @@ -78,9 +78,10 @@ satisfy, so those package commands failed despite high aggregate coverage. active route exists. Mandatory pairing would add setup and recovery burden to every MCP client, so it is not part of the current hardening path. If a higher-threat deployment appears later, pairing must be opt-in and version-negotiated rather than changing the default flow. -- **Pending compatible migration: asset hash length.** The current 8-hex content identifier is useful - for lookup, not authorization. Negotiate longer new-write ids, dual-read during TTL cleanup, then - remove short writes. +- **Completed: full asset content identity.** Extension, Hub, store paths, browser bridge, and shared + contracts use one complete lowercase SHA-256 digest and verify it after upload and download. + Internal callers migrated together; no short-ID compatibility path remains. The random capability + URL—not the digest—continues to authorize loopback access. - **Completed: Hub admission and activation extraction.** Port selection and handshake admission now live in a testable WebSocket server module with real loopback integration tests for accepted and rejected Origins/paths, connection limits, occupied-port fallback, and exhaustion. Registration, diff --git a/docs/extension/mcp-browser-gateway-design.md b/docs/extension/mcp-browser-gateway-design.md index c4c651ee..59c327a8 100644 --- a/docs/extension/mcp-browser-gateway-design.md +++ b/docs/extension/mcp-browser-gateway-design.md @@ -21,8 +21,9 @@ use a separate narrow runtime message validated by the background worker. 2. The content bridge opens a named runtime port and registers the page session with the broker. 3. The broker starts one WebSocket client for all Figma tabs in the extension context. 4. The client probes the known ports, then accepts a candidate only after receiving both - `registered` and `state` messages from the hub. The advertised asset URL must use an explicit - loopback IPv4 port and cannot contain credentials, a query, or a fragment. + `registered` and `state` messages from the hub. Registration carries an exact `protocolVersion`; + a mismatched server is rejected with an upgrade-together error. The advertised asset URL must use + an explicit loopback IPv4 port and cannot contain credentials, a query, or a fragment. 5. Every later `state` message is validated by the same rule and must keep the handshake's exact asset endpoint. Malformed traffic, a second registration, or an endpoint change closes that socket and resumes the existing reconnect loop. @@ -40,11 +41,13 @@ a differently identified extension cannot take over the established route. ## Assets -The page computes asset hashes and descriptors, then sends at most `MCP_MAX_ASSET_BYTES` through the -bridge. The service worker decodes the payload and uploads it to the hub's loopback asset server. -The page never fetches the loopback server directly. The asset URL contains a random capability -path generated for the hub process; the server also enforces per-asset, aggregate-store, concurrent -upload, header, and request-time limits. It does not emit wildcard CORS. +For outbound assets, the page computes hashes and descriptors and sends at most +`MCP_MAX_ASSET_BYTES` through the bridge; the service worker decodes and uploads the bytes to the +hub. For inbound canvas assets, the page sends only the hash; the service worker downloads a bounded +body, verifies its digest, and returns the bytes through the same validated bridge. The page never +fetches the loopback server directly. The asset URL contains a random capability path generated for +the hub process; the server also enforces per-asset, aggregate-store, concurrent upload, header, and +request-time limits. It does not emit wildcard CORS. ## Trust boundary diff --git a/docs/extension/mcp-canvas-assets-design.md b/docs/extension/mcp-canvas-assets-design.md new file mode 100644 index 00000000..018b3a48 --- /dev/null +++ b/docs/extension/mcp-canvas-assets-design.md @@ -0,0 +1,495 @@ +# MCP Canvas SVG and image assets + +Status: implemented for Canvas import; generated-asset upload remains host-dependent +Date: 2026-07-31 + +## Decision + +Keep `apply_canvas` as the only mutating tool. Add a call-scoped asset manifest, one SVG placement +field, and one content-addressed image source: + +```txt +agent or host asset + -> small inline SVG or Hub asset hash + -> apply_canvas desired result + -> deterministic asset resolution + -> Figma-native SVG import or image fill + -> normal diff, Undo, rollback, and verification +``` + +Do not add icon, image-search, SVG-operation, or upload tools to the model-visible surface. Do not +put raster bytes or large SVG documents in MCP JSON. A capable host may upload generated binary +assets to the Hub outside model context; the model sees only a content hash. + +Image generation may run in an isolated subagent when the host supports it. That is an optional +agent-orchestration optimization, not part of the TemPad protocol. + +This extends the existing declarative language rather than creating a second asset dialect. + +## Figma facts + +Figma provides two different vector paths: + +- [`figma.createNodeFromSvg(svg)`](https://developers.figma.com/docs/plugins/api/figma/) imports an + SVG string as editable Figma layers inside a `FrameNode`, equivalent to editor SVG import. +- [`VectorPath.data`](https://developers.figma.com/docs/plugins/api/properties/VectorPath-data/) + accepts only absolute `M`, `L`, `Q`, `C`, and `Z` commands. + +Direct SVG import is therefore the correct path for frontend icon-library SVG. Requiring the agent +to translate arbitrary SVG into `VectorPath` would spend context, invite geometry errors, and lose +supported SVG structure. + +Figma has no image node. Images are content handles used by +[`ImagePaint`](https://developers.figma.com/docs/plugins/api/Paint/). The Plugin API accepts: + +- PNG, JPEG, or GIF bytes through + [`figma.createImage`](https://developers.figma.com/docs/plugins/api/properties/figma-createimage/); +- a public PNG, JPEG, or GIF URL through + [`figma.createImageAsync`](https://developers.figma.com/docs/plugins/api/properties/figma-createimageasync/); +- existing current-file image hashes through `figma.getImageByHash`. + +Both byte and URL imports are limited to 4096 pixels on each axis. SVG import produces editable +vector layers rather than an image fill. + +MCP resources and resource links let a server send large data to a client. The protocol does not +define a general client-to-server binary upload handle. Generated-image ingestion must therefore be +a host capability, not something TemPad can assume every agent client supports. + +## Public desired-result contract + +Add one optional top-level field to the compact public schema: + +```ts +type ApplyCanvasInput = { + // existing fields + assets?: unknown +} +``` + +As with `styles`, `variableCollections`, and advanced `native` state, the public schema keeps this +field opaque so the always-visible `apply_canvas` schema remains below 8 KiB. The resolver validates +the complete private shape: + +```ts +type CanvasAssets = Record< + CanvasStableKey, + | { + type: 'SVG' + svg: string + } + | { + type: 'SVG' + assetHash: string + } + | { + type: 'IMAGE' + assetHash: string + } +> +``` + +Asset keys are call-scoped aliases. They deduplicate one source used by several nodes, but do not +create a Figma design-system resource and do not need to remain stable across calls. + +Allow at most 32 declarations and 64 KiB of inline SVG across one call. Every declaration must be +referenced, every reference must exist and match the required type, and `markup: null` cannot carry +assets. These rules prevent an asset manifest from becoming hidden general-purpose payload storage. + +### SVG placement + +A childless `div` may carry: + +```ts +type CanvasSvgPlacement = { + assetKey: string + color?: string // exactly #RRGGBB or #RRGGBBAA +} +``` + +under `native[key].figma.svg`. + +Example: + +```jsx +
+``` + +```json +{ + "assets": { + "search": { + "type": "SVG", + "svg": "..." + } + }, + "native": { + "search-icon": { + "figma": { + "svg": { + "assetKey": "search", + "color": "#334155" + } + } + } + } +} +``` + +`color` resolves CSS `currentColor` before import. It is a literal in the first version: + +- it makes common frontend icon SVG deterministic; +- it does not pretend a paint variable can be reliably propagated through importer-generated + descendants; +- catalog icon components remain the correct choice when native token linkage matters. + +Reject unresolved `currentColor`. Do not silently import it as black. Omit `color` for SVGs with +complete explicit colors. + +The SVG placement: + +- compiles to a managed `FRAME` wrapper; +- must be childless in Canvas HTML; +- cannot combine with a component binding, native shape, group, Boolean operation, section, + authored component, or Slot; +- may use normal layout, size, position, visibility, opacity, blend, and rotation on the wrapper; +- preserves the SVG aspect ratio, centers it, and contains it inside the declared width and height; +- does not reinterpret wrapper fills, strokes, or variables as descendant SVG colors. + +Only contain-and-center is supported initially. Cover, stretch, arbitrary SVG viewport alignment, +and descendant paint remapping need real use cases before becoming protocol concepts. + +### Image paint source + +Keep the existing `IMAGE` paint model and add `assetKey` as a third source: + +```ts +type CanvasImageSource = { imageHash: string | null } | { imageUrl: string } | { assetKey: string } +``` + +Exactly one source remains required. All existing `FILL`, `FIT`, `CROP`, and `TILE` placement, +transform, rotation, filter, visibility, opacity, and blend fields remain unchanged. + +```json +{ + "assets": { + "hero": { + "type": "IMAGE", + "assetHash": "full-sha256" + } + }, + "native": { + "hero-frame": { + "figma": { + "fills": [ + { + "type": "IMAGE", + "assetKey": "hero", + "scaleMode": "FILL" + } + ] + } + } + } +} +``` + +Use: + +- `imageHash` to reuse exact bytes already present in the current Figma file; +- `imageUrl` for a public HTTP(S) PNG, JPEG, or GIF; +- `assetKey` for content already stored in the local Hub, including host-uploaded generated images. + +Do not infer node geometry from image dimensions. Canvas HTML remains the source of layout size; +the paint scale mode controls placement within that geometry. + +## Asset transport + +### Existing paths + +The current pipeline already supports: + +- Figma-to-Hub asset upload for `get_code` and `get_screenshot`; +- content-addressed storage behind a random loopback capability URL; +- linked output instead of binary model context; +- public image URL import through Figma. + +Reuse that store for authoring assets. + +### Hub-to-Figma bytes + +Add a narrow reverse path: + +```txt +Figma page requests assetHash + -> content bridge + -> extension service worker + -> authenticated loopback GET + -> MIME, size, and SHA-256 verification + -> bounded internal base64 message + -> page Uint8Array + -> createImage(bytes) or createNodeFromSvg(text) +``` + +The page never uses the Hub capability URL for inbound fetches. The broker accepts only an exact +content hash, builds the URL from its validated Hub state, and cannot be used as an arbitrary URL +proxy. Binary encoding exists only inside the extension bridge; it never enters an MCP tool call or +result. The page still receives the existing capability URL solely to describe outbound assets +already uploaded by `get_code` and `get_screenshot`. + +Cache resolved bytes and imported Figma image hashes by content hash for the active session. + +### Agent-generated images + +Support three factual capability levels: + +1. The generator returns a public HTTPS PNG/JPEG/GIF URL: use `imageUrl`. +2. The host can access generated bytes and implements the optional TemPad asset-upload capability: + it uploads bytes directly to the Hub and gives the agent only `assetHash`, MIME type, dimensions, + and size. +3. Neither path exists: TemPad cannot import the generated image. The agent must omit optional + imagery or leave one honest asset slot; it must not synthesize an illustration from Figma + primitives. + +The optional host upload capability should be advertised through client-private MCP experimental +metadata, not server instructions or tool results. It reuses a separate random loopback upload +capability and the existing asset limits. It is not another model-callable tool. + +When the host supports subagents, delegate nontrivial image generation: + +1. The main design agent sends a compact brief: layout role, subject, aspect ratio, palette/style, + important empty space, and negative constraints. +2. The image subagent generates and iterates independently, then uploads the selected bytes + directly through the host capability. If it cannot upload but has a public result URL, it returns + that URL instead. +3. It returns only an importable `assetHash` or `imageUrl` plus MIME type, dimensions, and a short + description. It does not return bytes, candidate history, or its generation transcript. +4. The main agent owns placement and crop, and verifies the final composition when pixels can + change the decision. It does not need to inspect intermediate candidates. + +Do not delegate exact project assets, icon-library SVGs, existing Figma images, or direct URL +imports. Do not spawn an image subagent when it cannot return an importable reference. Clients +without subagents follow the same asset contract directly; TemPad neither exposes a subagent tool +nor assumes one exists. + +Do not accept: + +- base64 or data URLs in `apply_canvas`; +- arbitrary local file paths; +- credentials, headers, cookies, or signed-request recipes; +- a server-side “fetch any URL” endpoint. + +These alternatives respectively consume model context, expose local files, leak secrets, or create +an SSRF surface. + +### Content identity + +All asset descriptors and store paths use the complete lowercase SHA-256 digest. The extension and +Hub validate the digest again after every upload and download. There is no parallel short +model-facing asset ID. + +The additional characters are negligible beside the bytes they replace. + +## SVG validation + +SVG is code-like input even when Figma turns it into design layers. Validate before mutation: + +- UTF-8 only; +- `` document root; +- inline SVG at most 32 KiB; +- Hub-backed SVG at most 1 MiB; +- at most 500 XML elements and depth 32; +- a finite positive `viewBox`, or finite positive intrinsic width and height; +- no `DOCTYPE`, entity declarations, scripts, event-handler attributes, `foreignObject`, embedded + HTML, audio, video, or iframe content; +- no embedded raster ``; +- no external `href`, `src`, CSS import, font URL, or `url(...)`; local `#id` references remain + valid for gradients, masks, clipping, and ``; +- no `
+ + Team settings + + +
``` -Design-system references require at least one real `id` or `key`. Component references are allowed -only on `INSTANCE` nodes. Text properties are allowed only on `TEXT`; layout and children are -allowed only on `FRAME`. - -`CanvasVariableBindings` maps `fill`, `stroke`, `width`, `height`, `gap`, four padding fields, -`cornerRadius`, `opacity`, and the five font fields to the same `{ id?, key? }` reference shape. - -### Create mode - -- `root.type` must be `FRAME`. -- Existing `nodeId` values and `targetNodeId` are rejected. -- Figma creates one frame tree on the current page. -- If neither root coordinate is supplied, the root is centered in the current viewport. - -### Update mode - -- `targetNodeId` is required. -- The live target must have the same type as the desired root. -- Existing nodes may be matched by explicit `nodeId` or stable `key`. -- Every referenced existing node must be the target or its descendant. -- Omitted fields remain unchanged. -- Omitted children remain in Figma; v1 never deletes them. -- Supplied children are reconciled in their supplied order. A node is moved only when its current - parent or index differs. - -Output: - -```ts -type ApplyCanvasResult = { - rootNodeId: string - nodeIdsByKey: Record - createdNodeIds: string[] - updatedNodeIds: string[] - mutationCount: number - warnings?: string[] +```json +{ + "catalogId": "ds_…", + "native": { + "settings": { + "variableModes": { "k1": "m1_2" } + } + } } ``` -The agent should retain `nodeIdsByKey` and reuse those IDs during later updates. - -## Identity - -Names are presentation, not identity. The reconciler never finds a target by node name. - -Identity uses: - -1. `nodeId` when the agent supplies one -2. otherwise the stable `key` stored as shared plugin data on generated or adopted nodes - -Keys must be unique within a result. Node IDs must also be unique. If a key already identifies a -different live node, the write fails instead of guessing. - -No identity database or background synchronization service is needed. Figma node IDs plus local -shared plugin data are enough for the first version. +Primitive tags are case-insensitive. Catalog tags preserve case, are childless, require their +returned `data-ref`, accept only returned props, and default to the component's native width and +height when sizing classes are omitted. + +The parser fails closed on unknown elements, attributes, classes, or contradictory state. Native +Tailwind v4 utilities are accepted when their default value has a deterministic Figma equivalent; +arbitrary pixel/color values remain available off scale. It is not a browser and does not execute +CSS, JavaScript, project theme extensions, Tailwind variants/plugins, or remote page content. The +exact supported subset is documented in the canvas-authoring skill. + +## Native extension + +`native[data-key].figma` covers persistent Figma Design state with no honest HTML equivalent, +including: + +- sections, intrinsic groups, and non-destructive Boolean operations; +- rectangles, lines, ellipses, polygons, stars, vector paths, and vector networks; +- native transforms, masks, corners, stroke geometry, blends, and aspect-ratio state; +- Paint, Effect, and Grid stacks, media, Pattern paints, and shaders; +- guides and wrapping/grid-specific layout state; +- rich-text ranges, lists, decorations, and node/URL hyperlinks; +- authored components, component sets, properties, sublayer references, Slots, and instance state; +- explicit variable modes and same-result node/resource references. + +Top-level `variableCollections`, `styles`, and `page` support the corresponding native resources and +document state. These are advanced result fields, not additional mutation tools. + +The private schema remains the source of truth for the exact shapes and contradictions. Full +capability boundaries are recorded in +[Canvas authoring coverage](./mcp-canvas-authoring-coverage.md). + +Small inline or Hub-backed SVG documents and Hub-backed PNG/JPEG/GIF paints use the same +declarative result. The extension resolves them before mutation, imports them through Figma's native +SVG/image APIs, and keeps bytes out of model-visible payloads. Exact limits, ownership, and transport +rules are recorded in [Canvas SVG and image assets](./mcp-canvas-assets-design.md). + +## Create and update semantics + +Create describes one complete new root. Without an explicit root `relativeTransform`, the extension +centers the result near the current viewport and, when occupied, places it in the first available +position to the right. It reads only top-level bounds from that destination page; the model does not +maintain a coordinate ledger. An explicit transform remains authoritative when placement is part +of the requested result. + +Update is an incremental declarative patch scoped by `targetNodeId`: + +- supplied nodes and fields state desired values; +- omitted live fields and children are preserved; +- `removeKeys` explicitly asserts that owned descendants must be absent; +- `markup: null` is the isolated assertion that the managed update root itself must be absent. + +Omission never means deletion. Stable identity comes from `data-key`, not layer names. Repeating an +identical desired result is a no-op. `get_structure` returns that key as `authoringKey` on +TemPad-managed nodes, so a later session can recover identity from the live canvas instead of +guessing or recreating it. + +The extension reads the latest canvas immediately before reconciliation, so the diff is between the +new desired result and current live state—not between two model messages. It minimizes mutations +subject to stronger constraints: + +1. correct native result; +2. scope and ownership safety; +3. dependency-safe ordering; +4. one Undo boundary and rollback on failure; +5. no-op convergence; +6. only then, fewer Plugin API calls. ## Reconciliation -The extension performs the diff against current live Figma state at call time: - -1. Parse and validate the complete input. -2. Resolve the explicit update scope, if any. -3. Index stable keys inside that scope. -4. Walk the desired tree. -5. Reuse a matching live node or create the requested native node. -6. Move it only when its parent or supplied index differs. -7. Compare each supplied property and write only changed values. -8. Resolve components and variables by live ID or importable key. -9. Return stable identities and the actual mutation count. - -Component and variable lookups are cached within the call. Repeated identical input against -unchanged live state produces zero mutations. - -When both a literal field and a variable binding describe the same property, the variable binding -wins. The executor does not repeatedly overwrite a binding with its literal and then bind it again. - -This is a safe-minimal patch, not a graph-search problem. The executor avoids unnecessary writes, -but it will not trade away validation, scope checks, deterministic ordering, or rollback just to -reduce the API-call count. +The local deterministic pipeline is: -## Safety floor +1. parse and validate the public input; +2. load the catalog and expand short/deep refs; +3. validate the resolved input with the complete native schema; +4. normalize component tags into native instance bindings; +5. parse Canvas HTML and utility classes into a typed tree; +6. preflight identity, scope, resources, fonts, media, dependencies, and deletion safety; +7. create or adopt nodes and resources; +8. apply layout, content, appearance, links, bindings, and supplied child order in dependency order; +9. apply late references and stabilize deterministic geometry after derived-layout setters; +10. remove explicitly absent owned state; +11. verify the live result; +12. commit one Undo boundary, or undo the whole attempt on failure. -### Explicit write capability +The agent is not involved in any of these Plugin API steps. -Canvas writes have a separate session-only toggle under Agent integration. It defaults to disabled -and is reset when MCP access is disabled or unavailable. Read tools remain usable without enabling -writes. +Resolved native-schema failures return a bounded list of field paths and messages rather than the +complete validator diagnostic. This preserves enough evidence to repair advanced state without +consuming the next turn with repetitive union errors. -### Editor and schema checks +## Verification -- Authoring runs only in Figma Design files. -- One result contains at most 100 nodes. -- A result is at most 12 levels deep. -- Colors use `#RRGGBB` or `#RRGGBBAA`. -- Unknown input fields are rejected. -- Only the six supported native node types can be authored. +Structural verification is mandatory. It checks: -### Scope and concurrency +- native node type and stable key; +- identity map; +- parent and child order; +- finite geometry; +- declared sizing modes, fixed dimensions, and deterministic cross-axis fill geometry; +- Text auto-resize mode and non-empty intrinsic geometry; +- direct component identity; +- direct variable, style, and mode links; +- mask state. -- Update mode requires one explicit root. -- Existing targets outside that root are rejected. -- Only one `apply_canvas` call runs at a time in the active extension instance. -- No delete operation exists. +`apply_canvas` returns counts and factual warnings: -### References and fallback - -- Missing components, variables, fonts, or component properties fail the call. -- The executor does not redraw a missing component from primitives. -- It does not replace a failed variable binding with a literal. -- Mixed-font text is preserved when font fields are omitted. Replacing mixed fonts requires both - `fontFamily` and `fontStyle`. - -### Undo and failure behavior - -The extension starts a Figma undo boundary before mutation and commits one boundary after success. -If an operation fails, it triggers Figma Undo before returning the error. If automatic rollback is -not available, the error says so and directs recovery through Figma Undo. - -The actual editor mutation remains the final edit-permission check: a read-only or otherwise -unsupported Figma context rejects the write and returns a coded failure. - -## Agent workflow - -The intended flow is short: - -```txt -1. Read the repository's design-system rules and nearby implementation. -2. Call get_design_system with the concrete task. -3. Prefer returned components and semantic variables. -4. Send one apply_canvas result. -5. Inspect the result with get_code or get_structure when useful. -6. Send one updated result only if refinement is needed. +```ts +type Verification = { + status: 'passed' | 'warning' + nodesChecked: number + referencesChecked: number + warnings: Array<{ + code: string + message: string + key?: string + }> +} ``` -Repository evidence supplies high-level principles. Figma supplies native identities and live canvas -state. TemPad Dev should not invent a design language when neither source provides one. - -## Implementation map - -- Shared contracts and coded errors: `packages/shared/src/mcp/` -- MCP tool definitions and agent instructions: `packages/mcp-server/src/` -- Runtime routing: `packages/extension/mcp/runtime.ts` -- Design-system discovery: `packages/extension/mcp/tools/design-system.ts` -- Canvas reconciliation: `packages/extension/mcp/tools/canvas.ts` -- Session write toggle: `packages/extension/components/sections/AgentIntegrationSection.vue` - -Both authoring tools are in the extension's node-coverage scope, with behavioral tests for their -contracts, reconciliation, and safety boundaries. - -Do not add more tools merely because the Plugin API has more methods. Extend this surface only when a -real authoring task cannot be expressed safely. Prefer extending the same discovery-and-apply model -unless the workflow is genuinely different. +`get_screenshot` is a separate read-only validation tool. It returns one bounded PNG as a linked MCP +resource backed by the existing capability URL; structured content contains metadata, not binary +bytes. A new composition or material visual change normally receives one final check. Routine text, +token, prop, and hierarchy-only edits do not need it, and any correction remains bounded to one +evidence-based pass. + +## Safety boundaries + +- MCP access is disabled by default. While it is enabled, authoring is available in editable Figma + Design files; Dev Mode and native read-only rejections fail with stable errors. +- The Plugin API exposes the editor surface but not the current file permission. The extension + rejects non-Design editors before parsing and normalizes Figma's native read-only mutation + rejection to `CANVAS_READ_ONLY`; it does not infer permission from unstable DOM or private app + state. +- Only one apply may run per connected session. +- Update cannot write outside `targetNodeId` or its explicitly declared resource/page scope. +- Remote resources are imported or referenced, never edited or deleted. +- Managed resources are removed only after every live consumer is cleared or removed. +- Manual or unkeyed content is never deleted by omission. +- Components with surviving instances, dependency targets, masks, intrinsic-container operands, and + other live references block unsafe removal. +- Unsupported, ambiguous, or internally contradictory inputs fail before mutation. +- Any mutation-stage failure rolls back the entire apply. +- MCP annotations mark all reads as read-only and `apply_canvas` as potentially destructive and + non-idempotent because its create mode can add another root. These hints improve client routing; + deterministic scope, ownership, validation, and rollback remain the actual safety boundary. + +## Deliberate non-goals + +- no tool per Plugin API method; +- no imperative patch language; +- no agent-side diff planning; +- no browser-grade HTML/CSS renderer; +- no automatic design-system invention; +- no routine screenshot loop; +- no Dev Mode metadata, Dev Resources, exports, prototypes, FigJam, Slides, Widgets, Draw, Motion, + or Make authoring. + +The core product remains a small bridge: retrieve the right design facts, describe one result, and +let deterministic local code make it native and safe. diff --git a/docs/extension/mcp-context-strategy.md b/docs/extension/mcp-context-strategy.md index 774312a8..0a54e800 100644 --- a/docs/extension/mcp-context-strategy.md +++ b/docs/extension/mcp-context-strategy.md @@ -1,16 +1,30 @@ -# MCP context strategy (v2) +# MCP context strategy This document records the current context-control strategy for TemPad Dev MCP outputs. ## Goals - Reduce tool outputs that trigger upstream client/model truncation. -- Keep MCP APIs stable (no additional tool params/outputs). +- Keep the model-visible tool count and call flow stable; prefer compact additive metadata on + existing read tools over new retrieval tools. - Prefer lightweight metadata in MCP responses; avoid shipping large image payloads through context. ## Decisions -1. `get_code` keeps existing API but uses a shared inline budget guard. +1. Always-on context contains only universal routing and safety rules. + - Tool descriptions explain when to call a tool and its essential contract. + - Explicit user requirements override workflow defaults; design-system discovery is conditional, + not a required authoring preflight. + - Design-system authoring is a separate progressive branch entered only on an explicit user + request; an empty file or ordinary composition never triggers it. + - Material visual invention must have a grounded direction. Explicit user and project evidence + come first, then an applicable installed skill; only an unresolved style loads the targeted + research reference. The agent retains a brief under twelve lines instead of page dumps or a + mood board. + - Task-specific workflows, syntax, examples, and advanced native features live in skills and + their progressive references. + - Tool results carry factual recovery instructions only when that condition occurs. +2. `get_code` keeps existing API but uses a shared inline budget guard. - Budget is computed on the final `CallToolResult` UTF-8 bytes (`64 KiB` default). - If over budget, prefer a shell response that preserves the current node wrapper and omits direct children. - Warnings stay lightweight (`type + message` only); shell continuation lives in the inline omitted-child comment, and depth-cap recovery relies on returned `data-hint-id` values. @@ -18,25 +32,68 @@ This document records the current context-control strategy for TemPad Dev MCP ou the response cannot fit, avoiding descendant variables, plugins, collection, assets, and full rendering. Other overflow causes still reuse full-tree context for correctness. - Only fail fast when a usable shell cannot be generated. -2. `get_structure` keeps existing API but output is compacted by default. +3. `get_structure` keeps the same call shape and compacts output by default. - Limit total nodes. - Normalize/trim long names. - Round geometry values. + - Include `authoringKey` only on TemPad-managed nodes, allowing later sessions to resume updates + without retaining an earlier apply response. - Iteratively reduce node cap until the formatted result enters the shared inline budget. -3. `get_screenshot` is internal-only (`exposed: false`) and removed from normal tool guidance. -4. Image/SVG asset bytes are downloaded via `asset.url`. - - Asset resources are not exposed via MCP `resources/read`. +4. `get_screenshot` is visible but selective. + - It returns one bounded PNG through an MCP `resource_link` to the existing capability URL. + - Normally use one final check for a new composition or material visual change; skip mechanical + text, token, prop, and hierarchy-only edits. +5. Image/SVG bytes do not enter tool JSON. + - Read-tool outputs expose temporary `asset.url` links; asset resources are not exposed via MCP + `resources/read`. + - Canvas inputs use small inline SVG or a full Hub SHA-256 hash. Hash-addressed bytes cross only + the bounded extension bridge. +6. `get_design_system` returns a deterministic immutable catalog rather than a file dump. + - It is called only when existing-resource reuse is permitted and relevant; direct or new local + resource authoring does not require it. + - Normal discovery targets 16 KiB and uses short catalog-scoped refs. + - It reads definitions only and performs no canvas-usage, text, semantic, or relevance retrieval. + - Components, variables, and styles are interleaved before advanced collections and shaders. + - Component discovery uses optimized type-filtered queries on already-accessible pages and never + loads a page; variables, styles, and shaders use their file-level definition APIs. + - Omitted counts and a cursor expose the remaining immutable catalog without another read. + - An exact component ref returns a bounded usage contract with valid variants, default layout, + semantic anatomy, and a node id for selective visual inspection; every exact result remains + under the shared 64 KiB limit. +7. `apply_canvas` always performs structural verification without adding a read call. + - The public schema exposes stable outer object boundaries but keeps the full native schema out of + always-on context. + - Complete, executable variable/style and component recipes live in matching progressive skill + references and are contract-tested against the public and resolved schemas. + - Visual verification is one explicit `get_screenshot` call after new or materially changed + visual work, not an automatic response payload or iterative loop. + - Resolved native-schema failures return at most four actionable field paths and bounded + messages. +8. Every tool declares MCP read-only, destructive, idempotence, and open-world hints. + - The hints improve client planning but never replace deterministic write controls. ## Why - Different agent clients apply their own MCP/tool output limits before model context limits. +- Repeating task workflows in server instructions, tool descriptions, skills, and every response + spends attention without adding evidence. +- Leaving advanced fields completely untyped gives a private dialect no usable model prior; fully + expanding the native schema overwhelms the task. Stable outer types plus on-demand executable + examples preserve both callability and attention. - Partial/truncated code increases hallucination risk in downstream agents. - Shell responses preserve parent composition facts without relying on arbitrary string truncation. - Character-only truncation does not map well to tool response byte budgets. - Image and SVG payloads are high-context-cost and do not need to be embedded in model input. +- An unreliable relevance heuristic is worse than explicit deterministic paging because it can hide + valid design-system facts while presenting its output as task-specific. +- Style is contextual judgment rather than protocol state. Keeping its evidence and synthesis in a + progressive skill reference avoids a permanent style taxonomy while preventing an unspecified + request from collapsing to the model's highest-probability visual defaults. ## Non-goals - No chunked `get_code` protocol. - No artifact manifests or additional retrieval abstractions. -- No schema expansion for existing MCP tools. +- No separate style-definition or pagination tools; continuation and exact-ref lookup reuse the + same immutable catalog and tool. +- No `style` field, fixed domain-to-aesthetic map, or automatic reference payload in MCP. diff --git a/docs/extension/mcp-get-code-design.md b/docs/extension/mcp-get-code-design.md index bdc70838..0d45575c 100644 --- a/docs/extension/mcp-get-code-design.md +++ b/docs/extension/mcp-get-code-design.md @@ -231,6 +231,13 @@ The request context is threaded through: ## SVG and asset strategy +- Exact native image-fill assets include `figmaImageHash` so a canvas-authoring client can reuse + the same current-file resource. If any native bytes cannot be read, the rendered bitmap fallback + instead includes ordered unique `figmaImageHashes` for every visible image fill. +- Native video fills share one composited PNG preview per node and record their ordered unique + current-file identities as `figmaVideoHashes`. Figma exposes the hash on each `VideoPaint` but no + API for reading the original video bytes, so the descriptor does not present the preview as the + source video. - Vector-like nodes may be exported to SVG. - Vector containers can be converted to a single SVG when their subtree is vector-like and the container itself does not carry wrapper semantics such as its own fill/stroke/effects/clipping or design-component hints. - Themeable vectors are single-color vectors that can safely use one contextual color channel. The current implementation keeps node-sized `width`/`height` plus `viewBox`, uploads the SVG asset, and injects the instance color onto the emitted placeholder `svg` root markup. diff --git a/docs/extension/mcp-get-code-requirements.md b/docs/extension/mcp-get-code-requirements.md index f8d27ab8..a7176517 100644 --- a/docs/extension/mcp-get-code-requirements.md +++ b/docs/extension/mcp-get-code-requirements.md @@ -39,6 +39,9 @@ This document records the requirements and hard constraints for the MCP `get_cod - `tokens`: one-layer map of token entries keyed by canonical token name. - `warnings`: lightweight `type + message` guidance for inferred auto layout, depth-cap, or shell fallback. - SVG assets may include `themeable: true` when the vector can safely adopt a single contextual color channel. +- Exact native-byte image assets include their current-file `figmaImageHash`. When any native bytes + are unavailable, the rendered-node fallback instead includes ordered unique + `figmaImageHashes` for every visible image fill. ## Size and budget guard @@ -114,7 +117,13 @@ Figma `relativeTransform` is relative to the container parent, not to a GROUP/BO - Vector-only nodes or containers are classified before render as either: - themeable single-color vectors, which preserve one contextual color channel on the emitted placeholder `svg` root markup. - fixed-color vectors, which keep their internal palette in the exported SVG asset. -- Images are exported as PNG/JPEG when the node is an image fill. +- Image fills are exported from their exact native bytes when available. +- Native image fills retain current-file identities as `figmaImageHash` on exact native-byte assets + or ordered unique `figmaImageHashes` on a composited fallback; preview bytes do not replace those + separately reported identities. +- Video fills use one composited PNG node preview because the Plugin API has no video-byte reader. + Its asset descriptor retains ordered unique current-file identities as `figmaVideoHashes`; those + hashes describe the native fills, not the preview bytes. - Vector placeholders use the form ``, keep `viewBox`, retain node-sized `width`/`height`, and expose the uploaded asset URL via `data-src` on the emitted `svg` root markup. - Themeable vector placeholders preserve the instance color on the emitted `svg` root markup, preferring token/class output when available. - Themeable-vector eligibility and single-channel color detection must share the same paint/effect visibility semantics used elsewhere in the asset pipeline; do not maintain a separate vector-only interpretation of visible paints, effects, or variable-backed solid colors. diff --git a/docs/extension/multi-fill-background-design.md b/docs/extension/multi-fill-background-design.md index 448f040f..536519c2 100644 --- a/docs/extension/multi-fill-background-design.md +++ b/docs/extension/multi-fill-background-design.md @@ -428,7 +428,7 @@ For v1: - leave current image-fill behavior unchanged - only generate layered backgrounds when the preserved fill stack is composed entirely of representable solid/gradient paints -This keeps the first implementation focused and avoids reworking `replaceImageUrlsWithAssets()` and shorthand merging at the same time. +This keeps the first implementation focused and avoids reworking `replaceMediaUrlsWithAssets()` and shorthand merging at the same time. ### Phase 6: Optional follow-up for mixed image/fill stacks @@ -561,7 +561,7 @@ Target files: - `packages/extension/tests/mcp/tools/code/styles/background.test.ts` - optionally `packages/extension/tests/mcp/tools/code/cache/context.test.ts` -- optionally `packages/extension/tests/mcp/tools/code/assets/image.test.ts` if the normalization path needs extra coverage +- optionally `packages/extension/tests/mcp/tools/code/assets/media.test.ts` if the normalization path needs extra coverage Add tests for: diff --git a/docs/mcp/provider-sdk-design.md b/docs/mcp/provider-sdk-design.md new file mode 100644 index 00000000..bd66bf41 --- /dev/null +++ b/docs/mcp/provider-sdk-design.md @@ -0,0 +1,1444 @@ +# TemPad Context Provider Runtime and SDK Design + +Status: draft + +## Summary + +TemPad Dev MCP should evolve from a Figma-oriented MCP bridge into a +provider-oriented Context Hub for coding agents. + +The hub remains the MCP-facing gateway used by Claude Code and similar clients. +External runtimes connect to the hub as context providers. A provider can be a +Chrome extension, web prototype runtime, Storybook addon, Figma bridge, mock API, +product notes adapter, or any future source of implementation context. + +The core design decision is to keep context content flexible while standardizing +the provider contract: + +- Do not define one universal design handoff schema. +- Do define a stable provider protocol for connection, identity, activation, + self-describing context retrieval, lifecycle, errors, and assets. +- Do provide an SDK so provider authors do not implement WebSocket lifecycle, + activation, dispatch, and error handling themselves. +- Do expose a very small stable MCP surface to agents. For v0, third-party + provider integration should be centered on one self-describing `get_context` + tool rather than provider-specific RPC actions. + +Success for v0 is not a perfect context schema. Success is that one real +provider can plug in easily, the hub can expose it cleanly, and a coding agent +can retrieve useful context without relying on a rigid universal data model. + +## Problem Statement + +Vibe-coded prototypes and other interactive runtimes often know things static +design artifacts do not: + +- what is currently visible +- what the user has selected +- what state the runtime is in +- what mock data is being rendered +- what interactions exist +- which constraints matter for implementation +- which visual details require fidelity +- which prototype code is only scaffolding + +Today, TemPad Dev MCP exposes useful Figma-derived context through a small set +of tools. That model is valuable, but it is too narrow for a provider ecosystem. +The runtime that knows the useful context may not be Figma. It may be a browser +prototype, Storybook story, dev server, product notes adapter, or repository +component mapper. + +The key challenge is not to define a universal screen/component/token schema. +The key challenge is to define: + +1. how providers connect +2. how providers identify themselves +3. how providers are activated for an agent session +4. how agents retrieve self-describing provider context +5. how large follow-up context is referenced and expanded +6. how provider integrations are made easy and safe through an SDK + +## Goals + +### Primary Goals + +- Define a stable external provider contract. +- Keep provider context content flexible and provider-defined. +- Provide a provider SDK for browser and Node-like runtimes. +- Let TemPad Dev MCP aggregate multiple providers and expose them through MCP. +- Support both first-party and third-party providers. +- Minimize v0 protocol surface. +- Preserve the existing Figma MCP tools; do not force them into the third-party + provider SDK abstraction in v0. + +### Secondary Goals + +- Support explicit activation and user-visible active provider state. +- Support permission scopes. +- Leave room for provider-specific actions later, without making them part of + the v0 third-party provider contract. +- Support provider capability updates after registration. +- Make the system usable with agent skills. +- Keep large assets and binary resources out of inline tool results. + +## Non-Goals + +- Defining a universal design handoff schema. +- Requiring every provider to return structured screen, component, token, or flow + objects. +- Requiring providers to implement MCP directly. +- Replacing the current Figma MCP tools in v0. +- Making provider-specific actions part of the v0 third-party provider contract. +- Treating prototype source code as authoritative production architecture. +- Solving full visual diffing, end-to-end testing, or Figma interoperability in + v0. +- Supporting remote multi-tenant provider hosting in v0. + +## Core Principles + +### Context Is Flexible + +The content returned by providers should remain flexible. A provider may return: + +- natural language +- Markdown +- JSON +- source snippets +- DOM summaries +- component usage notes +- screenshots +- mock data +- runtime state +- design tokens +- resource links +- provider-specific blobs + +The protocol should only standardize the transport-level envelope for mixed +content. It should not standardize provider business semantics unless a later +feature requires deterministic handling. + +### Provider Contract Is Stable + +The provider-facing contract should be stable and versioned. It should cover: + +- connection +- handshake +- provider identity +- capability discovery +- activation and deactivation +- self-describing context retrieval +- follow-up ref expansion +- lifecycle and health +- errors and cancellation +- asset/resource references + +### MCP-Facing and Provider-Facing Protocols Are Separate + +The agent-facing protocol is MCP. The provider-facing protocol is a TemPad +provider protocol, initially JSON-RPC over WebSocket. + +This separation keeps compatibility with MCP clients while allowing lightweight +runtime integrations that do not need to know MCP details. + +### The Hub Is Not a Provider + +The MCP server package should act as a Context Hub and MCP Gateway. It should: + +- accept provider connections +- manage provider sessions +- manage active providers +- route MCP calls to providers +- aggregate or group provider results +- enforce permissions and budgets +- host asset/resource indirection + +The TemPad Dev Chrome extension may become a provider implementation over time, +but the third-party provider SDK should be validated without changing the +current Figma MCP tools in v0. + +### Instructions Matter + +Provider instructions are first-class. Since context content is flexible, the +agent needs guidance about how to use each provider. Provider instructions are +more valuable than rigid schema names for many integration scenarios. + +The generic MCP server instructions should teach the agent to list providers, +call `get_context` for active providers, read the returned self-description, and +treat provider-specific content as contextual evidence. + +Provider-specific instructions should teach the agent how to interpret that +provider's context. + +## High-Level Architecture + +```txt +Claude Code / Cursor / other MCP clients + | + | MCP over stdio or Streamable HTTP + v +TemPad Dev MCP +Context Hub + MCP Gateway + | + | Provider Protocol over WebSocket + | In-process provider adapter + | Future adapter types + v +Context Providers + - TemPad Dev Chrome extension + - Web prototype runtime + - Storybook provider + - Figma bridge + - Mock API provider + - Product notes provider + - Repository component mapper +``` + +## Terminology + +### Provider + +A runtime or adapter that can return context to the hub. Providers do not need +to implement MCP. They implement the TemPad provider protocol or use the +provider SDK. + +### Provider Session + +A concrete connection between one provider instance and the hub. For example, +two browser tabs using the same provider package are two provider sessions. + +### Provider Manifest + +The provider's self-description: id, title, version, instructions, capabilities, +supported scopes, and optional metadata. + +### Capability + +A provider-declared capability. In v0, the only required context capability is +self-describing context retrieval. Provider-specific actions are deferred. + +### Context Content + +Mixed content returned by a provider. The transport-level shape is standardized, +but the meaning of the content is provider-defined. + +### Resource + +A larger context item or asset referenced by a provider. In v0, provider-owned +follow-up context uses provider-local refs that can be expanded through +`get_context`. Asset bytes can be downloaded through hub-hosted HTTP asset URLs +when the provider returns them. + +### Active Provider + +A connected provider session that the user has chosen as the current context +source. v0 supports a single active provider. Connected does not imply active. + +## Current Implementation Baseline + +The current implementation already has the shape of a provider protocol: + +- The hub starts an MCP server for consumer sessions. +- The hub starts a local WebSocket server for the Chrome extension. +- The extension connects to one of several candidate localhost ports. +- The hub assigns an id and sends `registered`. +- The hub broadcasts `state` with active id, provider count, port, and asset + server URL. +- The extension sends `activate`. +- The hub forwards `toolCall` messages to the active extension. +- The extension returns `toolResult`. + +This should be generalized: + +- Rename extension concepts to provider concepts. +- Add provider-initiated `hello` registration. +- Add provider manifest and capabilities. +- Add a generic self-describing `get_context` flow for third-party providers. +- Move connection and dispatch logic from provider implementations into an SDK. +- Keep current Figma tools unchanged in v0. + +## Compatibility Research + +This design is constrained by current coding-agent MCP support rather than the +ideal MCP feature set. + +As of April 2026, mainstream coding agents do not expose MCP capabilities +uniformly: + +| Host / agent | Observed MCP support | Design consequence | +| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | Supports stdio, Streamable HTTP, SSE, dynamic `list_changed`, resources through `@` references, prompts as slash commands, elicitation, and tool-search. It also applies MCP output limits and truncates tool descriptions and server instructions. | Claude Code can use the richer MCP surface, but v0 should still keep tool descriptions concise, keep outputs small, and avoid requiring resources/prompts for the core workflow. | +| OpenAI Codex CLI / IDE | Supports configured MCP servers through Codex configuration and supports per-server/per-tool approval settings for MCP tools. | Treat tools as the portable interface. Do not require MCP resources, prompts, sampling, or dynamic top-level tools for v0. | +| VS Code / GitHub Copilot agent mode | VS Code supports MCP tools and now supports authorization, prompts, resources, and sampling. It also has explicit tool pickers, approval controls, and a maximum enabled-tool count per request. | The design can offer optional MCP resources, but the default path should be a small stable tool surface to avoid tool-count and approval friction. | +| GitHub Copilot cloud agent | Repository-configured MCP support is tool-only; resources and prompts are not supported, and configured tools may be used autonomously without per-call approval. | Do not expose provider activation as a normal MCP tool. Keep `get_context` read-only, and support tool allowlisting. | +| Cursor | Native MCP configuration is supported and the agent uses configured MCP tools when relevant; users can enable or disable tools. | Tool descriptions and agent/project instructions must be enough for the model to discover the workflow. Resources cannot be the only path. | +| Gemini CLI | MCP discovery primarily registers tools; docs explicitly say Gemini CLI primarily focuses on tool execution and closes connections that provide no usable tools. Tool results can include rich content blocks and resource links. | A resource-only provider would fail or be ignored. `get_context` must be a tool, and it must carry enough self-description for the model to use without MCP resources. | +| Cline | MCP tools and resources are surfaced in Cline's system prompt. Tools are invoked through `use_mcp_tool`; resources through `access_mcp_resource`. | Tool-first design works, and optional resource exposure can improve UX. | +| Continue | MCP servers only work in Agent mode and are used to give the agent more tools. | Treat MCP tools as the dependable integration path. | + +Sources: + +- MCP specification: tools, resources, prompts, capability negotiation, resource + links, and optional `listChanged` support: + https://modelcontextprotocol.io/specification/2025-06-18/basic/index, + https://modelcontextprotocol.io/specification/2025-06-18/server/tools, + https://modelcontextprotocol.io/specification/2025-06-18/schema +- Claude Code MCP documentation: + https://code.claude.com/docs/en/mcp +- OpenAI Codex MCP configuration: + https://developers.openai.com/codex/config-reference +- VS Code MCP and agent-tool documentation: + https://code.visualstudio.com/blogs/2025/06/12/full-mcp-spec-support, + https://code.visualstudio.com/docs/copilot/agents/agent-tools +- GitHub Copilot cloud agent MCP documentation: + https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/extend-cloud-agent-with-mcp +- Cursor MCP documentation: + https://docs.cursor.com/advanced/model-context-protocol +- Gemini CLI MCP documentation: + https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html +- Cline MCP documentation: + https://docs.cline.bot/mcp/mcp-marketplace, + https://docs.cline.bot/mcp/adding-and-configuring-servers +- Continue MCP documentation: + https://docs.continue.dev/reference/continue-mcp + +### Compatibility Conclusions + +The v0 MCP surface must be tool-first. + +MCP resources, prompts, subscriptions, elicitation, sampling, dynamic +`list_changed`, and resource links are useful enhancements, but at least one +important coding-agent host either does not support them or does not make them +model-initiated in the way this design needs. + +Therefore: + +- `get_context` is the only new third-party provider MCP tool in v0. +- Provider-owned resources may also be exposed through MCP resources when a host + supports them, but every important resource must be reachable through + `get_context`. +- Provider prompts may be added later, but no v0 workflow depends on them. +- Provider activation is user-mediated, not a normal agent tool. +- Top-level MCP tool names are stable; provider-specific context semantics are + data returned by `get_context`. +- The tool count stays small to work in hosts with tool-count limits and to + avoid noisy selection. +- Dynamic provider capability changes do not require dynamic MCP tool + registration in v0. +- Large content is paged, linked, or expanded through provider-defined follow-up + `get_context` input; inline tool outputs stay below conservative limits. +- Provider instructions and tool descriptions must be concise, with critical + guidance first. + +## MCP-Facing Layer + +TemPad Dev MCP should expose a small stable tool surface to coding agents. + +### v0 MCP Tools + +All v0 MCP tools should be stable, low-count, and safe to expose in clients that +only support tools. Their descriptions should explicitly say that providers do +not parse natural language. The agent should ask for context, read the returned +self-description, and use any returned refs for follow-up expansion. + +Safety profile: + +- `get_context`: read-only, but may expose external/runtime data. + +#### `get_context` + +Returns a self-describing context package from the active provider. +This is the core third-party provider tool in v0. + +`get_context` is not a natural-language query API. Providers should not be +expected to parse arbitrary agent prompts. The input may carry structured hints, +but providers may ignore them and return their current best self-description. + +Input: + +```ts +type GetContextInput = { + input?: unknown +} +``` + +Rules: + +- If there is no active provider, return a clear error telling the user to + activate a provider from the TemPad or provider UI. +- The hub does not synthesize or summarize provider content in v0. +- `input` is provider-defined. The hub forwards it unchanged and does not + validate provider-specific semantics. +- Calling `get_context` with no input asks the active provider for its default + self-describing context package. +- Providers may use `input` for follow-up expansion, filtering, snapshot + options, viewport choices, or provider-specific commands. +- Providers should keep the default response useful and bounded. They should + include critical summary, instructions, warnings, and provider-defined refs for + heavier data. + +Output: + +```ts +type GetContextOutput = { + providerSessionId: string + title: string + context: ProviderContextPackage + warnings?: string[] +} +``` + +```ts +type ProviderContextPackage = { + provider: { + manifestId: string + title: string + version?: string + } + scope?: Record + summary?: string + instructions?: string + items: ContextItem[] + refs?: ContextRef[] + warnings?: string[] + generatedAt?: string +} + +type ContextItem = { + id: string + title: string + kind?: string + mimeType?: string + content?: ContextContent[] + description?: string + priority?: 'high' | 'medium' | 'low' + freshness?: 'static' | 'snapshot' | 'live' + tags?: string[] +} + +type ContextRef = { + id: string + title: string + description?: string + kind?: string + mimeType?: string + tags?: string[] + sizeHint?: 'small' | 'medium' | 'large' + input?: unknown +} +``` + +`refs` are provider-defined follow-up handles, not public URI contracts. A ref +may include an `input` object that can be passed back to `get_context`. + +### Activation Is User-Mediated + +Provider activation is not a default MCP tool in v0. Activation grants access to +runtime context and should be user-visible. + +Providers can be activated from: + +- provider-side UI, such as "Connect to Agent" +- a TemPad Hub UI +- trusted first-party auto-activation policies + +A future `providers_request_activation` MCP tool may ask the host UI to request +user approval, but agents should not silently activate providers themselves. + +### Why Not Provider-Specific Actions in v0 + +Provider-specific actions may be useful later, but they are not required for the +third-party provider v0. A self-describing context package gives the agent a +simple and portable way to consume provider context without turning the provider +SDK into a generic RPC system. + +Promoting provider actions into top-level MCP tools creates several problems: + +- MCP tool space becomes noisy. +- Tool names can conflict across providers. +- Providers are pressured into over-designing action schemas. +- Some MCP clients do not handle tool list changes consistently across a live + session. +- The hub's public contract becomes provider-dependent. + +Wrapping every provider action behind one generic RPC tool has a different +problem: the model loses the native affordances of explicit tool names and +schemas, and host-level approvals become coarse. + +The v0 design therefore keeps third-party providers focused on `get_context`. +Common actions can be added later only after they prove stable and broadly +useful. + +## Provider-Facing Protocol + +### Transport + +v0 uses JSON-RPC 2.0 over WebSocket for external runtime providers. + +Reasons: + +- Browser runtimes can implement it easily. +- It supports request/response and notifications. +- It maps well to lifecycle events and context requests. +- It is easy to wrap in an SDK. + +The hub may also support in-process provider adapters for built-in providers. +Those adapters should implement the same provider interface internally. + +### WebSocket Endpoint + +The hub listens on localhost using configured candidate ports. v0 should keep +the existing port candidate strategy and add a provider path if useful: + +```txt +ws://127.0.0.1:/provider +``` + +The current implementation does not require a path; v0 can maintain backwards +compatibility by accepting both root and `/provider`. + +### Authentication and Pairing + +Generic browser-based providers must not be trusted only because they can reach +localhost. + +v0 should include a pairing token: + +- The hub generates a random token at startup. +- Provider SDK sends the token during handshake. +- The hub rejects providers without a valid token. +- First-party extension distribution can hide token retrieval behind existing + setup UI. +- Third-party providers can obtain the token from a user-visible pairing flow or + local config. + +The hub should also apply origin checks where available: + +- allow known extension origins for first-party providers +- allow configured localhost origins for dev servers +- reject unexpected browser origins unless paired + +### Provider Manifest + +```ts +type ProviderManifest = { + id: string + title: string + version?: string + instructions?: string + capabilities: { + context?: boolean + subscribe?: boolean + } + scopes?: PermissionScope[] + metadata?: Record +} +``` + +`id` identifies the provider implementation, not the connection. The hub assigns +a separate `providerSessionId` for each connection. + +Examples: + +- `tempad.chrome` +- `prototype.runtime` +- `storybook.local` +- `product-notes` + +Provider-specific actions are intentionally not part of this v0 manifest. + +### Permission Scopes + +Recommended v0 scopes: + +- `context:read` +- `runtime:inspect` + +Reserved for later: + +- `runtime:control` +- `filesystem:read` +- `network:read` + +The default activation scope should be: + +```txt +context:read +runtime:inspect +``` + +`runtime:control` should not be granted by default in v0. + +## Provider Protocol Methods + +### `provider.hello` + +Provider registers itself with the hub. + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "provider.hello", + "params": { + "protocolVersion": "tempad-context-provider/0.1", + "token": "", + "manifest": { + "id": "tempad.chrome", + "title": "TemPad Dev Chrome Extension", + "version": "0.1.0", + "instructions": "Provides runtime context from the active browser tab.", + "capabilities": { + "context": true, + "subscribe": false + } + } + } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "providerSessionId": "provider-session-abc", + "status": "registered", + "requiresActivation": true, + "assetServerUrl": "http://127.0.0.1:8128" + } +} +``` + +### `provider.activate` + +Hub asks the provider to become active for a consumer session. + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "provider.activate", + "params": { + "agentSession": { + "id": "consumer-session-123", + "projectRoot": "/Users/yiling/project" + }, + "scopes": ["context:read", "runtime:inspect"] + } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "status": "activated", + "summary": "Active tab is a web prototype at http://localhost:5173/settings/mcp" + } +} +``` + +### `provider.deactivate` + +Hub asks the provider to leave the active pool for a consumer session. + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "provider.deactivate", + "params": { + "agentSession": { + "id": "consumer-session-123" + } + } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "status": "deactivated" + } +} +``` + +### `provider.getContext` + +Hub asks a provider for a self-describing context package. The request is +structured and providers should not parse natural-language prompts. + +Request: + +```ts +type ProviderGetContextRequest = { + input?: unknown +} +``` + +Response: + +```ts +type ProviderGetContextResponse = ProviderContextPackage +``` + +Provider guidance: + +- Return a useful default package when `input` is omitted. +- Keep the default package concise and directly readable by a coding agent. +- Include self-describing titles, kinds, MIME types, summaries, warnings, and + instructions where useful. +- Use provider-defined refs for heavier follow-up context instead of returning + everything inline. +- Do not generate summaries from the agent's prompt. +- Treat `input` as provider-defined data. If the provider does not understand + it, return a clear provider-level warning or fall back to the default package. + +```ts +type ProviderContextPackage = { + provider: { + manifestId: string + title: string + version?: string + } + scope?: Record + summary?: string + instructions?: string + items: ContextItem[] + refs?: ContextRef[] + warnings?: string[] + generatedAt?: string +} +``` + +### `provider.ping` + +Health check. Either side may send a ping request. + +Response: + +```ts +type ProviderPingResponse = { + ok: boolean + timestamp: number +} +``` + +### `provider.capabilitiesChanged` + +Provider notification. Sent when instructions, metadata, or capabilities change. + +Notification: + +```json +{ + "jsonrpc": "2.0", + "method": "provider.capabilitiesChanged", + "params": { + "manifest": { + "id": "prototype.runtime", + "title": "Prototype Runtime", + "capabilities": { + "context": true + } + } + } +} +``` + +The hub should update provider metadata. The hub does not need to create new +top-level MCP tools in v0. + +### `provider.contextChanged` + +Provider notification. Sent when current runtime context changes, such as route, +selection, story, or active frame. + +Notification: + +```json +{ + "jsonrpc": "2.0", + "method": "provider.contextChanged", + "params": { + "summary": "Current route changed to /settings/mcp", + "hints": { + "route": "/settings/mcp" + } + } +} +``` + +v0 may record this state for debugging or provider UI, but does not need push +notifications to MCP clients. + +### `provider.dispose` + +Provider tells the hub it is intentionally disconnecting. + +Notification: + +```json +{ + "jsonrpc": "2.0", + "method": "provider.dispose", + "params": { + "reason": "tab closed" + } +} +``` + +## Context Content Model + +The content model is a transport envelope, not a design schema. + +```ts +type ContextContent = TextContent | JsonContent | ImageContent | ResourceContent + +type TextContent = { + type: 'text' + text: string + mimeType?: string + title?: string +} + +type JsonContent = { + type: 'json' + value: unknown + title?: string +} + +type ImageContent = { + type: 'image' + data?: string + uri?: string + mimeType: string + title?: string +} + +type ResourceContent = { + type: 'resource' + uri: string + title?: string + mimeType?: string + description?: string +} +``` + +Rules: + +- Prefer `resource` for large content. +- Avoid inline base64 images by default. +- Inline `image.data` may be allowed for small thumbnails, subject to hub limits. +- `json.value` is provider-defined. +- `text.mimeType` can be `text/markdown`, `text/plain`, `text/typescript`, + `text/css`, or another useful type. +- Linked resources should be stable for the provider session. +- Provider-local follow-up context should normally use `ContextRef`, not + `ResourceContent`. + +### MCP Resources as an Optional Mirror + +When the host supports MCP resources well, the hub may also expose provider +resources through MCP `resources/list`, `resources/read`, and resource +templates. This is an enhancement only. + +The compatibility baseline remains: + +- `get_context` returns the initial self-describing context package. +- `get_context({ input })` sends provider-defined follow-up input. + +No provider should require the MCP host to support resources for basic use. + +## Context Ref and Asset Design + +Third-party provider follow-up context can use provider-defined refs returned +inside `ProviderContextPackage.refs`. A ref may include the exact input needed +to expand it. + +Example: + +```ts +{ + refs: [ + { + id: 'current-page/mock-data', + title: 'Mock data for current page', + kind: 'mock-data', + mimeType: 'application/json', + sizeHint: 'medium', + input: { + ref: 'current-page/mock-data' + } + } + ] +} +``` + +The agent expands a ref by calling: + +```ts +get_context({ input: { ref: 'current-page/mock-data' } }) +``` + +Refs are intentionally not a global resource URI system in v0. They are +provider-defined hints that are only meaningful when passed back to that +provider through `get_context`. + +For asset bytes hosted by the hub, providers should upload assets to the hub's +asset server and return asset descriptors or HTTP URLs as `ContextContent` +resources. This keeps MCP tool results small and consistent with the existing +asset indirection strategy. + +## Figma Tools Stay Separate in v0 + +The existing TemPad Dev Chrome extension and Figma MCP tools should not be +forced into the third-party provider SDK abstraction in v0. + +Keep the current top-level Figma tools unchanged: + +- `get_code` +- `get_structure` +- `get_screenshot` (internal or hidden where appropriate) +- `get_token_defs` (internal or hidden where appropriate) +- `get_assets` (hub-owned asset resolver) + +Reasons: + +- They are already high-value, stable, schema-rich MCP tools. +- They are already usable by current clients and instructions. +- Wrapping them behind a generic provider RPC would reduce tool-name and schema + clarity for agents. +- The third-party provider SDK can be validated independently with simpler + prototype/runtime providers. + +Future work may add a Figma context provider that returns a self-describing +`get_context` package for the active selection, but that should be additive. It +should not replace or hide the current Figma MCP tools until the provider model +has proven useful. + +## Activation Model + +Activation is explicit and user-visible. + +Connected providers are available. Activated providers are used by default for +the current MCP consumer session. + +### Provider-Side Activation + +A provider UI may expose actions such as: + +- Connect to Agent +- Activate for TemPad MCP +- Use this prototype as context + +Provider-side activation sends a provider notification or request to the hub. The +hub updates active state and broadcasts it to providers. + +### Agent-Side Behavior + +An agent should not silently activate providers through MCP in v0. It only calls +`get_context`. If no provider is active, the tool returns an actionable error +asking the user to activate a provider in the TemPad or provider UI. + +### Auto Activation + +The existing behavior of auto-activating the sole provider after a grace period +is useful for first-party flows. For generic providers, v0 should keep this +configurable: + +- enabled by default for trusted first-party providers +- disabled or approval-gated for untrusted third-party providers + +### Active Provider + +v0 intentionally supports only one active provider for the MCP context-provider +flow. Multi-provider aggregation can be added later if real workflows need it. + +## Provider SDK Design + +### Package Name + +Recommended package: + +```txt +@tempad-dev/context-provider +``` + +Optional internal packages: + +```txt +@tempad-dev/context-protocol +@tempad-dev/provider-ws +``` + +The public provider author experience should start with +`@tempad-dev/context-provider`. + +### SDK Responsibilities + +- WebSocket connection and reconnect. +- Port candidate probing. +- Pairing token handling. +- Provider handshake. +- Active state tracking. +- Activation and deactivation helpers. +- Context provider callback registration. +- JSON-RPC request/response handling. +- Error serialization. +- Timeout and cancellation handling. +- Asset upload helper. +- Capability update notifications. +- Context change notifications. +- Browser and Node transport adapters. + +### Provider Author API + +```ts +import { createContextProvider } from '@tempad-dev/context-provider' + +const provider = createContextProvider({ + id: 'prototype.runtime', + title: 'Local Prototype Runtime', + version: '0.1.0', + instructions: [ + 'Provides context from the currently running prototype.', + 'Use this provider to understand visible UI, runtime state, mock data,', + 'interactions, screenshots, and implementation notes.', + 'Prototype code is reference behavior, not final architecture.' + ].join('\n') +}) + +provider.getContext(async ({ input }) => { + if (isObject(input) && input.ref === 'current-page/mock-data') { + return { + summary: 'Mock data used by the current prototype page.', + items: [ + { + id: 'current-page/mock-data', + title: 'Mock data for current page', + kind: 'mock-data', + mimeType: 'application/json', + content: [ + { + type: 'text', + mimeType: 'application/json', + text: JSON.stringify(collectMockData(), null, 2) + } + ] + } + ] + } + } + + return { + summary: 'Current page is the MCP server settings prototype.', + instructions: + 'Use this context as implementation guidance. Prototype code is reference behavior, not final architecture.', + items: [ + { + id: 'current-page/overview', + title: 'Current page implementation overview', + kind: 'implementation-overview', + mimeType: 'text/markdown', + priority: 'high', + content: [ + { + type: 'text', + mimeType: 'text/markdown', + text: [ + 'Implementation context:', + '- Render configured servers from mock data.', + '- Empty state shows an Add Server call to action.', + '- Failed state keeps Retry visible.', + '- Visual precision is less important than behavior for this task.' + ].join('\n') + } + ] + } + ], + refs: [ + { + id: 'current-page/mock-data', + title: 'Mock data for current page', + kind: 'mock-data', + mimeType: 'application/json', + sizeHint: 'medium', + input: { + ref: 'current-page/mock-data' + } + } + ] + } +}) + +await provider.connect({ + ports: [6220, 7431, 8127], + token: await getPairingToken() +}) +``` + +The SDK should fill provider metadata, normalize returned packages, enforce size +budgets, and forward provider-defined follow-up input through the same +`getContext` callback. + +### SDK Runtime Adapters + +Core SDK logic should be environment-neutral. Transport adapters can cover: + +- browser WebSocket +- Node WebSocket +- extension bridge +- in-process adapter for built-in providers + +Vue/React-specific hooks should be thin wrappers, not part of the core protocol. + +### Error API + +Provider authors should be able to throw normal errors. The SDK serializes them +into protocol errors with stable error codes where possible. + +Recommended error shape: + +```ts +type ProviderErrorPayload = { + code: string + message: string + retryable?: boolean + details?: unknown +} +``` + +Common codes: + +- `PROVIDER_NOT_ACTIVE` +- `CAPABILITY_NOT_SUPPORTED` +- `RESOURCE_NOT_FOUND` +- `PERMISSION_DENIED` +- `INVALID_ARGS` +- `TIMEOUT` +- `PAYLOAD_TOO_LARGE` +- `INTERNAL_ERROR` + +## Hub SDK / Internal API Design + +The hub should expose an internal provider registry API used by both WebSocket +transport and in-process adapters. + +```ts +type ContextHub = { + registerProvider(provider: ProviderConnection): ProviderSession + unregisterProvider(providerSessionId: string): void + activateProvider(input: ActivateProviderInput): Promise + deactivateProvider(input: DeactivateProviderInput): Promise + getContext(input: HubGetContextInput): Promise +} +``` + +Provider transports should plug into this registry: + +```ts +const hub = createContextHubMcpServer({ + name: 'tempad-context', + version: '0.1.0', + instructions: GENERIC_CONTEXT_HUB_INSTRUCTIONS +}) + +hub.useProviderTransport(createWebSocketProviderTransport({ host: '127.0.0.1' })) +hub.registerProvider(createProductNotesProvider()) +await hub.startMcpStdio() +``` + +This keeps WebSocket concerns separate from MCP tool implementation. + +## Instructions and Skills + +The generic MCP instructions should no longer be Figma-specific. They should +tell agents: + +1. list providers when implementation context may exist +2. call `get_context` to retrieve a self-describing context package +3. expand returned refs only when needed +4. treat prototype code as reference behavior, not final architecture +5. preserve provider warnings and uncertainties in implementation planning + +In the minimal v0, this guidance can live in the `get_context` tool +description, hub instructions, and provider companion skills. There is no +separate provider-listing tool. + +An optional `tempad-context` skill can encode the recommended workflow for +Claude Code and similar agents. + +## Security Model + +v0 is local-first, but still needs explicit security controls. + +### Threats + +- Any local web page may attempt to connect to `127.0.0.1`. +- A malicious provider may claim a trusted id. +- A provider may expose sensitive runtime data. +- A future provider action may control runtime state. +- Large payloads may exhaust MCP client limits. + +### Controls + +- Pairing token required for provider handshake. +- Origin allowlist for browser providers where possible. +- Hub-assigned `providerSessionId` is authoritative. +- Provider manifest id is descriptive, not trusted identity by itself. +- Activation is explicit and user-visible. +- Permission scopes are checked before `get_context` calls. +- Default scopes are read/inspect only. +- Runtime control is out of scope for v0. +- Payload size limits and inline budgets are enforced at hub boundaries. +- Large binary content uses resource or asset indirection. +- Provider errors are sanitized before reaching MCP clients. + +## Result Budgeting and Resources + +The existing MCP inline budget strategy should apply to provider results. + +Rules: + +- The hub measures final MCP tool results before returning them. +- Providers may define their own size/budget hints inside `input`, but the hub + does not standardize those hints in v0. +- Providers should return refs or asset URLs for large content. +- The hub should fail with a compact, actionable error if a result is still too + large. +- Image and binary content should normally be returned by asset URL, not inline + base64. + +## Dynamic Capability Updates + +Capabilities should be dynamic at the provider protocol layer. + +v0 behavior: + +- Providers can send `provider.capabilitiesChanged`. +- The hub updates provider metadata. +- MCP tool surface stays unchanged. +- Agents discover updated context behavior through `get_context` results or + companion skills. + +Future behavior: + +- The hub may introduce stable provider actions later. +- The hub may notify MCP clients that tool metadata changed when client support + is reliable enough. + +## Provider Injection Modes + +### Mode A: Runtime Provider over WebSocket + +Best for: + +- Chrome extension +- web prototype runtime +- browser playground +- local Storybook runtime +- Figma bridge + +Flow: + +1. Hub starts provider WebSocket endpoint. +2. Provider SDK connects. +3. Provider sends `provider.hello`. +4. Hub registers provider session. +5. User activates provider. +6. Agent retrieves context through `get_context`. + +### Mode B: In-Process Provider Adapter + +Best for: + +- built-in providers +- local notes or docs +- mock API providers +- server-side adapters + +Example: + +```ts +hub.registerProvider({ + id: 'product-notes', + title: 'Product Notes', + instructions: 'Provides product requirements and edge cases.', + capabilities: { context: true }, + async getContext(req) { + return { + summary: 'Product notes relevant to the current workspace.', + items: [ + { + id: 'product-notes/current', + title: 'Relevant product notes', + kind: 'product-notes', + mimeType: 'text/markdown', + content: [ + { + type: 'text', + mimeType: 'text/markdown', + text: await readCurrentProductNotes() + } + ] + } + ] + } + } +}) +``` + +### Mode C: Existing MCP Server Adapter + +Future direction. An existing MCP server can be wrapped as a provider through an +adapter layer inside the hub. This is out of scope for v0. + +## Package Layout + +The repo can evolve in two phases. + +### v0 Minimal Package Changes + +Keep the current package layout and add abstractions inside existing packages: + +```txt +packages/shared + provider protocol schemas and types + +packages/mcp-server + context hub registry + MCP kernel tools + WebSocket provider transport + +packages/extension + TemPad Chrome provider implementation using provider SDK +``` + +This avoids a large package split before the design is validated. + +### Future Package Split + +After v0 proves useful, split public packages: + +```txt +packages/context-protocol + shared protocol types, method names, error codes + +packages/context-provider + public provider SDK for browser and Node runtimes + +packages/provider-ws + WebSocket transport implementation + +packages/mcp-hub + provider registry and MCP gateway helpers + +packages/mcp-server + actual CLI/server package built on mcp-hub + +packages/claude-skill + SKILL.md and usage examples +``` + +## Migration Plan + +### Phase 1: Extract Generic Provider Protocol + +- Add provider protocol schemas to shared. +- Keep existing extension message support for compatibility. +- Introduce `ProviderConnection` and `ProviderSession` terminology. +- Add `provider.hello` handshake. +- Keep current Figma tools operational. + +### Phase 2: Add MCP Kernel Tools + +- Add `get_context`. +- Keep activation user-mediated through provider-side or hub UI. +- Update MCP instructions to describe the generic context hub workflow. + +### Phase 3: Build Provider SDK + +- Extract connection/reconnect/activation/dispatch logic from the extension + composable. +- Publish a browser-compatible SDK entry. +- Build a sample browser prototype provider with the SDK. +- Add tests for handshake, activation, get-context, ref expansion, and reconnect. + +### Phase 4: Validate with Prototype Runtime + +- Build a simple web prototype provider using `get_context`. +- Return current page summary, mock data follow-up refs, screenshot asset refs, + and interaction notes. +- Validate with a coding agent implementing a real screen. + +### Phase 5: Optional Figma Context Package + +- Add an optional Figma `get_context` package only after the third-party + provider flow is validated. +- Keep current Figma MCP tools operational. +- Do not hide Figma tool schemas behind generic provider RPC. + +## Recommended v0 Scope + +### Must Have + +- WebSocket provider connection. +- Provider registration through `provider.hello`. +- User-mediated provider activation. +- One MCP tool: `get_context`. +- MCP gateway with small stable tool surface. +- Provider SDK. +- Generic MCP instructions and basic skill guidance. +- Pairing token or equivalent local security gate. + +### Nice to Have + +- Provider-originated context change notifications. +- Capability update notifications. +- In-process provider adapter. +- Asset upload helper in SDK. +- Provider listing or switching tools. +- Multiple active providers per MCP consumer session. + +### Out of Scope for v0 + +- Universal context schema. +- Strict screen/component/token protocol. +- Auto-generation of provider-specific MCP tools. +- Complete visual comparison workflow. +- Runtime control actions. +- Provider-specific action RPC. +- Existing MCP server adapter. + +## Open Questions + +1. Should `get_context` ever inline small image data, or should all images use + asset URLs? +2. What is the exact pairing token UX for browser prototypes that are not + installed extensions? +3. How should trusted first-party providers be distinguished from third-party + providers? +4. When, if ever, should provider-specific actions be added after v0? +5. Should provider capability updates be persisted across reconnects? + +## One-Sentence Framing + +TemPad Dev MCP should become an MCP-facing Context Hub, while third-party +runtimes integrate through a lightweight Context Provider Protocol and SDK, with +flexible context content and a stable provider contract. diff --git a/docs/security/local-mcp-threat-model.md b/docs/security/local-mcp-threat-model.md index b93f323f..e7e6f106 100644 --- a/docs/security/local-mcp-threat-model.md +++ b/docs/security/local-mcp-threat-model.md @@ -71,6 +71,8 @@ are therefore required even though the listeners bind only to `127.0.0.1`. - Uploads enforce per-asset size, reserve aggregate quota before concurrent bodies are accepted, and cap concurrent uploads. Server connections and concurrent downloads are capped; HTTP headers, header wait time, request/response time, and keep-alive time are bounded. +- New uploads use full lowercase SHA-256 identifiers. Legacy 8-character identifiers remain + download-only until the asset TTL expires so upgrades do not strand cached assets. - Downloads use attachment disposition, `nosniff`, a restrictive CSP, no referrer, and private cache semantics. - Asset responses remain ephemeral and are not exposed as MCP resources. @@ -126,9 +128,9 @@ are therefore required even though the listeners bind only to `127.0.0.1`. take over an already-active route, and connection-bound results prevent guessed-id completion. This is a useful no-config containment boundary, but it cannot distinguish a same-user process that deliberately forges the same Origin header. -3. **Short asset hashes.** The asset protocol uses the existing 8-hex-character content identifier. - Uploads recompute and verify it, but the collision space is too small to treat the hash as a - security identity. The random URL capability is the authorization control. +3. **Asset hashes are not authorization.** The protocol uses and verifies a complete lowercase + SHA-256 digest as content identity. Possession of that digest still grants no network access; the + random loopback URL capability remains the authorization control. 4. **Plugin sandbox scope.** The boundary is designed to contain hostile application-level plugin behavior, but it is not a virtual machine or a browser security proof. Browser/JavaScript-engine vulnerabilities, side channels, and hard renderer-wide memory exhaustion are out of scope. The @@ -152,22 +154,16 @@ The following changes intentionally remain proposals because they affect setup o ### Optional high-threat pairing mode The normal local workflow must remain zero-config. If a managed or higher-threat deployment later -needs mutual identity, first add protocol-version and feature negotiation, then design pairing as an -opt-in mode with explicit setup, rotation, recovery, and legacy fallback. It must not silently become -mandatory for Codex, VS Code, Cursor, Claude Code, or manual stdio configurations. +needs mutual identity, extend the versioned handshake with feature negotiation, then design pairing +as an opt-in mode with explicit setup, rotation, recovery, and legacy fallback. It must not silently +become mandatory for Codex, VS Code, Cursor, Claude Code, or manual stdio configurations. There is no reliable no-config substitute for a shared credential or OS-mediated identity channel: Origin is public metadata and a same-user process can forge it. The current design therefore uses Origin partitioning for transparent containment and documents the remaining first-connection race instead of claiming mutual authentication. -### P1: asset identifier migration - -Move new writes from 8 hex characters to at least 32 hex characters while accepting both lengths -during a transition. Advertise the negotiated length in the registration feature list, keep old -catalog entries readable until TTL cleanup, then remove short-hash writes before short-hash reads. - -### P2: response continuation contract +### Response continuation contract If shell responses evolve into opaque cursor-based continuation, add a new optional response field behind protocol negotiation. Keep the current inline child-id comment during the compatibility diff --git a/docs/testing/architecture.md b/docs/testing/architecture.md index 7826aaf6..56ba095e 100644 --- a/docs/testing/architecture.md +++ b/docs/testing/architecture.md @@ -22,7 +22,8 @@ For contributor workflow and commands, see `TESTING.md`. - Packages own their runtime-sensitive scripts such as `test`, `test:run`, and browser-specific commands. - Root owns repo-level checks for root-only files and provides thin aggregation scripts such as `lint`, `format`, `typecheck`, and `test:run`. -- Root coverage remains centralized in `vitest.config.ts` because coverage policy is shared across packages. +- Root coverage remains centralized in `vitest.config.ts`; shared thresholds and the extension node + source list live in `vitest.coverage.ts` so package and workspace coverage cannot drift. ## Coverage model @@ -40,11 +41,9 @@ For contributor workflow and commands, see `TESTING.md`. There is no manually maintained pure-function matrix in docs anymore. Coverage scope is defined only in executable Vitest configuration: -- root: `vitest.config.ts` -- shared aggregate thresholds: `vitest.coverage.ts` -- extension node: `packages/extension/vitest.node.config.ts` +- workspace and package composition: `vitest.config.ts` and package-level configs +- shared aggregate thresholds and extension node source list: `vitest.coverage.ts` - extension browser: `packages/extension/vitest.browser.config.ts` -- package-level configs where applicable If a file should enter or leave strict coverage scope, update config + tests in the same PR. diff --git a/package.json b/package.json index e942d699..425b1aad 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "TemPad Dev monorepo", "type": "module", "scripts": { - "dev": "pnpm --filter @tempad-dev/extension dev", + "dev": "pnpm --filter @tempad-dev/shared build && pnpm -r --parallel --filter @tempad-dev/shared --filter @tempad-dev/mcp --filter @tempad-dev/extension dev", "dev:site": "pnpm --filter @tempad-dev/site dev", "build": "pnpm -r build", "build:site": "pnpm --filter @tempad-dev/site build", @@ -15,12 +15,11 @@ "test": "pnpm -r --parallel --if-present test", "test:run": "pnpm -r --if-present test:run", "test:coverage": "vitest run --coverage", - "lint": "pnpm lint:agent-plugin && pnpm lint:root && pnpm -r --if-present lint", - "lint:fix": "pnpm sync:agent-plugin && pnpm lint:root:fix && pnpm -r --if-present lint:fix", - "lint:agent-plugin": "node scripts/sync-agent-plugin-skill.mjs --check", + "lint": "pnpm lint:root && pnpm -r --if-present lint", + "lint:fix": "pnpm lint:root:fix && pnpm -r --if-present lint:fix", "lint:root": "eslint . --ignore-pattern \"packages/**\"", "lint:root:fix": "eslint . --fix --ignore-pattern \"packages/**\"", - "sync:agent-plugin": "node scripts/sync-agent-plugin-skill.mjs", + "agent-plugin:dev": "node scripts/build-dev-agent-plugin.mjs", "format": "pnpm format:root && pnpm -r --if-present format", "format:check": "pnpm format:root:check && pnpm -r --if-present format:check", "format:root": "oxfmt --ignore-path ./.gitignore --ignore-path ./.oxfmtignore", diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 19fccc5a..a12f4601 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -94,6 +94,7 @@ Do not reuse UI codegen logic for MCP without a clear reason. | MCP `get_code` implementation or pipeline | `docs/extension/mcp-get-code-design.md` | | MCP context and output strategy | `docs/extension/mcp-context-strategy.md` | | MCP canvas authoring | `docs/extension/mcp-canvas-authoring-design.md` | +| MCP canvas SVG and image assets | `docs/extension/mcp-canvas-assets-design.md` | | Browser gateway, permissions, sessions, WebSocket, or asset transport | `docs/extension/mcp-browser-gateway-design.md` | | Test selection and required checks | `TESTING.md` | | Test architecture or coverage scope | `docs/testing/architecture.md` | diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index f16fea57..341a8c56 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -2,12 +2,36 @@ ## 0.21.0 -- Added opt-in Figma canvas authoring for agents with query-ranked design-system discovery and one - declarative create or update result. -- Added safe incremental reconciliation with explicit update scopes, stable node identities, - component and variable reuse, no-op detection, validation, and automatic undo on failure. -- Added the `figma-canvas-authoring` agent skill, including a grounded workflow for empty Figma - documents. +- Added Figma canvas authoring through one declarative result instead of agent-generated + Plugin API calls. +- Added an immutable deterministic design-system catalog with compact component tags, short refs, + deterministic cursor pagination, bounded plain/Markdown summaries, non-English prop labels, and + exact one-resource detail lookup. The catalog reads component definitions from pages Figma + already makes accessible and file-level resources without scanning usage or loading pages. +- Added safe native reconciliation against the latest canvas with stable identities, scoped + incremental updates, explicit deletion, no-op convergence, dependency preflight, one Undo + boundary and structural verification. Managed identities can be recovered through + `get_structure` in a later agent session. +- Added deterministic non-overlapping placement for separate create results without requiring the + agent to track prior canvas coordinates. +- Exposed bounded screenshots as a separate read-only validation step. +- Added a strict HTML + Tailwind utility common path, including native default scales wherever they + map deterministically to Figma, and typed Figma-only state for native geometry, layout, text, + paints/effects/media, variables, native styles, pages, authored + components/variants, instances, and Slots. +- Added a progressive `figma-canvas-authoring` skill that follows explicit user resource + constraints, conditionally reuses accessible design-system definitions, provides executable + recipes for authored local resources, handles empty documents without inventing a design system, + grounds unspecified visual direction in project evidence, applicable skills, or bounded domain + research, and requests visual verification only when it can change the next decision. +- Added bounded SVG and PNG/JPEG/GIF authoring through inline SVG or full-SHA-256 local asset + references, with a hash-only reverse bridge, SVG sanitization, stable wrappers, and cached native + image handles. +- Made Canvas authoring available whenever MCP access is enabled in an editable Figma Design file; + native read-only rejection remains the permission boundary. +- Kept native canvas-validation feedback compact and actionable with bounded field paths. +- Stabilized derived Auto Layout geometry after text reflow and other Figma setters, and verify + actual fixed and cross-axis fill dimensions instead of trusting sizing-mode labels alone. ## 0.20.0 diff --git a/packages/extension/codegen/worker.ts b/packages/extension/codegen/worker.ts index 910bbc4a..52cde20e 100644 --- a/packages/extension/codegen/worker.ts +++ b/packages/extension/codegen/worker.ts @@ -130,7 +130,7 @@ function generateCodegenPayload( ...Object.keys(rest) .map((name) => { const extraOptions = rest[name] - if (extraOptions === false) { + if (!extraOptions) { return null } diff --git a/packages/extension/components/AgentSetupDialog.vue b/packages/extension/components/AgentSetupDialog.vue index 1b879b38..8c9ae86c 100644 --- a/packages/extension/components/AgentSetupDialog.vue +++ b/packages/extension/components/AgentSetupDialog.vue @@ -105,7 +105,7 @@ function selectManualSetup(): void { } function getStepDescription(id: ActionGroupId): string { - if (id === 'plugin') return 'Adds the MCP server and design skill together.' + if (id === 'plugin') return 'Adds the MCP server and design skills together.' if (id === 'skill') return 'Adds the repo-aware workflow for implementing selected designs.' if (selectedSetup.value.id === 'other') return 'Adds the local TemPad Dev server.' return `Lets ${selectedSetup.value.name} access the Figma file open in this browser.` @@ -197,7 +197,7 @@ function getCopyTitle(action: AgentIntegrationAction): string {

{{ selectedSetup.name }}

-

Install the plugin to add both MCP access and the design skill.

+

Install the plugin to add MCP access and both design skills.

Use the same two parts with any compatible agent.

diff --git a/packages/extension/components/Code.vue b/packages/extension/components/Code.vue index 42cbb976..fa562cde 100644 --- a/packages/extension/components/Code.vue +++ b/packages/extension/components/Code.vue @@ -37,20 +37,17 @@ const prismRevision = shallowRef(0) const code = computed(() => props.code.replace(STRIP_TRAILING_WS_RE, '')) const lang = computed(() => { - if (prismAlias[props.lang]) { - return prismAlias[props.lang] - } - - return props.lang + return prismAlias[props.lang] ?? props.lang }) const highlighted = computed(() => { const Prism = prismRevision.value >= 0 ? window.Prism : window.Prism - if (!Prism || !Prism.languages[lang.value]) { + const language = Prism?.languages[lang.value] + if (!Prism || !language) { return escapeHTML(code.value) } - const html = Prism.highlight(code.value, Prism.languages[lang.value], lang.value) + const html = Prism.highlight(code.value, language, lang.value) return transformHTML(html, (tpl) => { tpl.querySelectorAll('.token.variable, .token.constant').forEach((el) => { diff --git a/packages/extension/components/sections/AgentIntegrationSection.vue b/packages/extension/components/sections/AgentIntegrationSection.vue index 4617c317..d1291f8d 100644 --- a/packages/extension/components/sections/AgentIntegrationSection.vue +++ b/packages/extension/components/sections/AgentIntegrationSection.vue @@ -8,9 +8,9 @@ import Tick from '@/components/icons/Tick.vue' import Section from '@/components/Section.vue' import SegmentedControl from '@/components/SegmentedControl.vue' import { MCP_PERMISSION_REQUEST_EVENT } from '@/mcp/permissions' -import { canvasWritesOn, options } from '@/ui/state' +import { options } from '@/ui/state' -const toggleOptions = [ +const mcpOptions = [ { label: 'Disabled', value: false, icon: Minus }, { label: 'Enabled', value: true, icon: Tick } ] @@ -22,9 +22,6 @@ function setMcpEnabled(enabled: boolean | undefined): void { window.dispatchEvent(new Event(MCP_PERMISSION_REQUEST_EVENT)) } options.value.mcpOn = enabled === true - if (!enabled) { - canvasWritesOn.value = false - } } @@ -38,23 +35,13 @@ function setMcpEnabled(enabled: boolean | undefined): void {
-
- - -
-
diff --git a/packages/extension/components/sections/MetaSection.vue b/packages/extension/components/sections/MetaSection.vue index e630a979..ab469cfc 100644 --- a/packages/extension/components/sections/MetaSection.vue +++ b/packages/extension/components/sections/MetaSection.vue @@ -10,8 +10,9 @@ import { selection, selectedNode, selectedTemPadComponent } from '@/ui/state' const title = computed(() => { const nodes = selection.value + const [node] = nodes - if (!nodes || nodes.length === 0) { + if (!node) { return null } @@ -24,7 +25,7 @@ const title = computed(() => { return component.name } - return nodes[0].name + return node.name }) const showFocusButton = computed( diff --git a/packages/extension/composables/key-lock.ts b/packages/extension/composables/key-lock.ts index 0db4070e..408ca0fd 100644 --- a/packages/extension/composables/key-lock.ts +++ b/packages/extension/composables/key-lock.ts @@ -124,8 +124,9 @@ function isDuplicateCursor(host: HTMLElement) { function learnDuplicateClass(host: HTMLElement) { if (duplicateClass) return const added = Array.from(host.classList).filter((c) => !classSnapshot.has(c)) - if (added.length === 1) { - duplicateClass = added[0] + const [addedClass] = added + if (added.length === 1 && addedClass) { + duplicateClass = addedClass } } diff --git a/packages/extension/composables/mcp.ts b/packages/extension/composables/mcp.ts index 02f1836b..3dbec617 100644 --- a/packages/extension/composables/mcp.ts +++ b/packages/extension/composables/mcp.ts @@ -14,22 +14,27 @@ import { createSharedComposable, useEventListener } from '@vueuse/core' import { computed, shallowRef, watch } from 'vue' import { + type AssetDownloader, type AssetUploadRequest, - resetUploadedAssets, + resetAssetCache, + setAssetDownloader, setAssetServerUrl, setAssetUploader } from '@/mcp/assets' +import { bytesToBase64 } from '@/mcp/encoding' import { coerceToolErrorPayload } from '@/mcp/errors' import { MCP_LOCAL_HOST_PERMISSION_ERROR, MCP_PERMISSION_REQUEST_EVENT } from '@/mcp/permissions' import { runMcpTool } from '@/mcp/runtime' -import { canvasWritesOn, layoutReady, options, runtimeMode } from '@/ui/state' +import { layoutReady, options, runtimeMode } from '@/ui/state' -type PendingAssetUpload = { +type PendingAssetRequest = { reject: (error: Error) => void - resolve: () => void + resolve: (result: Result) => void timer: ReturnType } type AssetUploadResultMessage = Extract +type AssetDownloadResultMessage = Extract +type AssetDownloadPayload = NonNullable export const useMcp = createSharedComposable(() => { const sessionId = crypto.randomUUID() @@ -45,7 +50,8 @@ export const useMcp = createSharedComposable(() => { const errorMessage = shallowRef(null) let enabled = false - const pendingAssetUploads = new Map() + const pendingAssetUploads = new Map>() + const pendingAssetDownloads = new Map>() const selfActive = computed(() => activeSessionId.value === sessionId) const needsLocalHostPermission = computed( @@ -77,11 +83,12 @@ export const useMcp = createSharedComposable(() => { type: 'mcp.disable' }) } - rejectPendingAssetUploads('MCP disabled before asset upload completed.') + rejectPending(pendingAssetUploads, 'MCP disabled before asset upload completed.') + rejectPending(pendingAssetDownloads, 'MCP disabled before asset download completed.') count.value = 0 activeSessionId.value = null setAssetServerUrl(null) - resetUploadedAssets() + resetAssetCache() status.value = 'disabled' errorMessage.value = null } @@ -97,6 +104,10 @@ export const useMcp = createSharedComposable(() => { handleAssetUploadResult(message) return } + if (message.type === 'mcp.assetDownloadResult') { + handleAssetDownloadResult(message) + return + } if (message.type === 'mcp.state') { const state = message.payload @@ -105,10 +116,9 @@ export const useMcp = createSharedComposable(() => { count.value = state.sessionCount errorMessage.value = state.errorMessage status.value = state.status - setAssetServerUrl(state.assetServerUrl ?? null) + setAssetServerUrl(state.assetServerUrl) if (state.status !== 'connected') { - canvasWritesOn.value = false - resetUploadedAssets() + resetAssetCache() } return } @@ -121,6 +131,7 @@ export const useMcp = createSharedComposable(() => { useEventListener(window, 'message', handleBridgeMessage) setAssetUploader(uploadAsset) + setAssetDownloader(downloadAsset) watch( canEnable, @@ -128,7 +139,6 @@ export const useMcp = createSharedComposable(() => { if (shouldEnable) { sendEnable() } else { - canvasWritesOn.value = false stop() } }, @@ -168,56 +178,99 @@ export const useMcp = createSharedComposable(() => { } function uploadAsset(request: AssetUploadRequest): Promise { + return sendAssetRequest(pendingAssetUploads, 'upload', (requestId) => + postPageMessage({ + ...pageMessageBase, + payload: { + base64: bytesToBase64(request.bytes), + hash: request.hash, + metadata: request.metadata, + mimeType: request.mimeType + }, + requestId, + type: 'mcp.uploadAsset' + }) + ) + } + + function handleAssetUploadResult(message: AssetUploadResultMessage): void { + if (message.sessionId !== sessionId) return + const pending = takePending(pendingAssetUploads, message.requestId) + if (!pending) return + if (message.error) { + pending.reject(new Error(message.error.message)) + return + } + pending.resolve() + } + + function downloadAsset(hash: string): ReturnType { + return sendAssetRequest(pendingAssetDownloads, 'download', (requestId) => + postPageMessage({ + ...pageMessageBase, + payload: { hash }, + requestId, + type: 'mcp.downloadAsset' + }) + ) + } + + function sendAssetRequest( + pendingRequests: Map>, + action: 'download' | 'upload', + send: (requestId: string) => void + ): Promise { if (!enabled) { return Promise.reject(new Error('MCP is not connected.')) } - const requestId = crypto.randomUUID() return new Promise((resolve, reject) => { const timer = setTimeout(() => { - pendingAssetUploads.delete(requestId) - reject(new Error('MCP asset upload timed out.')) + pendingRequests.delete(requestId) + reject(new Error(`MCP asset ${action} timed out.`)) }, MCP_TOOL_TIMEOUT_MS) - pendingAssetUploads.set(requestId, { reject, resolve, timer }) + pendingRequests.set(requestId, { reject, resolve, timer }) try { - postPageMessage({ - ...pageMessageBase, - payload: { - base64: bytesToBase64(request.bytes), - hash: request.hash, - metadata: request.metadata, - mimeType: request.mimeType - }, - requestId, - type: 'mcp.uploadAsset' - }) + send(requestId) } catch (error) { - pendingAssetUploads.delete(requestId) + pendingRequests.delete(requestId) clearTimeout(timer) - reject(error instanceof Error ? error : new Error('Failed to request asset upload.')) + reject(error instanceof Error ? error : new Error(`Failed to request asset ${action}.`)) } }) } - function handleAssetUploadResult(message: AssetUploadResultMessage): void { + function handleAssetDownloadResult(message: AssetDownloadResultMessage): void { if (message.sessionId !== sessionId) return - const pending = pendingAssetUploads.get(message.requestId) + const pending = takePending(pendingAssetDownloads, message.requestId) if (!pending) return - pendingAssetUploads.delete(message.requestId) - clearTimeout(pending.timer) if (message.error) { - pending.reject(new Error(message.error.message)) + pending.reject(Object.assign(new Error(message.error.message), { code: message.error.code })) return } - pending.resolve() + pending.resolve(message.payload!) + } + + function takePending( + requests: Map>, + requestId: string + ): PendingAssetRequest | undefined { + const pending = requests.get(requestId) + if (!pending) return undefined + requests.delete(requestId) + clearTimeout(pending.timer) + return pending } - function rejectPendingAssetUploads(message: string): void { - for (const pending of pendingAssetUploads.values()) { + function rejectPending( + requests: Map>, + message: string + ): void { + for (const pending of requests.values()) { clearTimeout(pending.timer) pending.reject(new Error(message)) } - pendingAssetUploads.clear() + requests.clear() } return { @@ -230,12 +283,3 @@ export const useMcp = createSharedComposable(() => { requestLocalHostPermission } }) - -function bytesToBase64(bytes: Uint8Array): string { - let binary = '' - const chunkSize = 0x8000 - for (let offset = 0; offset < bytes.length; offset += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) - } - return btoa(binary) -} diff --git a/packages/extension/mcp/assets.ts b/packages/extension/mcp/assets.ts index 76b0a54a..8ea82974 100644 --- a/packages/extension/mcp/assets.ts +++ b/packages/extension/mcp/assets.ts @@ -1,27 +1,35 @@ -import type { AssetDescriptor, PageToBridgeMessage } from '@tempad-dev/shared' +import type { AssetDescriptor, BridgeToPageMessage, PageToBridgeMessage } from '@tempad-dev/shared' -import { - MCP_HASH_HEX_LENGTH, - MCP_MAX_ASSET_BYTES, - TEMPAD_MCP_ERROR_CODES -} from '@tempad-dev/shared' +import { MCP_MAX_ASSET_BYTES, TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' import { logger } from '@/utils/log' +import { base64ToBytes, digestMatchesAssetHash, sha256Hex } from './encoding' import { createCodedError } from './errors' const uploadedAssets = new Set() const inflightUploads = new Map>() +const downloadedAssets = new Map>() +let assetCacheGeneration = 0 let assetServerUrl: string | null = null let assetUploader: AssetUploader | null = null +let assetDownloader: AssetDownloader | null = null type AssetUploadPayload = Extract['payload'] +type AssetDownloadPayload = NonNullable< + Extract['payload'] +> export type AssetUploadRequest = Omit & { bytes: Uint8Array } -export type AssetUploader = (request: AssetUploadRequest) => Promise +type AssetUploader = (request: AssetUploadRequest) => Promise +type DownloadedAsset = { + bytes: Uint8Array + mimeType: string +} +export type AssetDownloader = (hash: string) => Promise export function setAssetServerUrl(url: string | null): void { assetServerUrl = url @@ -31,10 +39,26 @@ export function setAssetUploader(uploader: AssetUploader | null): void { assetUploader = uploader } -export function resetUploadedAssets(): void { +export function setAssetDownloader(downloader: AssetDownloader | null): void { + assetDownloader = downloader +} + +export function resetAssetCache(): void { + assetCacheGeneration += 1 uploadedAssets.clear() inflightUploads.clear() - // We don't clear the URL here as it might be needed for subsequent calls + downloadedAssets.clear() +} + +export function downloadAsset(hash: string): Promise { + const cached = downloadedAssets.get(hash) + if (cached) return cached + const promise = requestAsset(hash).catch((error) => { + if (downloadedAssets.get(hash) === promise) downloadedAssets.delete(hash) + throw error + }) + downloadedAssets.set(hash, promise) + return promise } export async function ensureAssetUploaded( @@ -48,7 +72,7 @@ export async function ensureAssetUploaded( ) } - const hash = await hashBytes(bytes) + const hash = await sha256Hex(bytes) if (!assetServerUrl) { logger.error('Asset server URL is missing.') @@ -70,6 +94,7 @@ export async function ensureAssetUploaded( } const uploadKey = `${assetServerUrl}::${hash}` + const generation = assetCacheGeneration if (uploadedAssets.has(uploadKey)) { return descriptor @@ -83,11 +108,11 @@ export async function ensureAssetUploaded( const promise = uploadAsset({ bytes, hash, metadata, mimeType }) .then(() => { - uploadedAssets.add(uploadKey) + if (generation === assetCacheGeneration) uploadedAssets.add(uploadKey) logger.log(`Uploaded asset ${hash.slice(0, 8)} (${mimeType}, ${size} bytes) to ${url}`) }) .finally(() => { - inflightUploads.delete(uploadKey) + if (inflightUploads.get(uploadKey) === promise) inflightUploads.delete(uploadKey) }) inflightUploads.set(uploadKey, promise) @@ -111,28 +136,26 @@ async function uploadAsset(request: AssetUploadRequest): Promise { } } -async function hashBytes(bytes: Uint8Array): Promise { - if (typeof crypto?.subtle?.digest === 'function') { - const digest = await crypto.subtle.digest('SHA-256', toArrayBuffer(bytes)) - const fullHex = bufferToHex(new Uint8Array(digest)) - return fullHex.slice(0, MCP_HASH_HEX_LENGTH) +async function requestAsset(hash: string): Promise { + if (!assetDownloader) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.ASSET_BRIDGE_UNAVAILABLE, + 'MCP asset download bridge is not connected.' + ) } - throw new Error('crypto.subtle.digest is unavailable in this environment.') -} - -function bufferToHex(buffer: Uint8Array): string { - return Array.from(buffer) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') -} - -function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { - const buffer = bytes.buffer - const isArrayBuffer = typeof ArrayBuffer !== 'undefined' && buffer instanceof ArrayBuffer - if (bytes.byteOffset === 0 && bytes.byteLength === buffer.byteLength && isArrayBuffer) { - return buffer + const payload = await assetDownloader(hash) + const bytes = base64ToBytes(payload.base64) + if (bytes.byteLength !== payload.size) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.ASSET_HASH_MISMATCH, + `Asset "${hash}" size did not match its descriptor.` + ) + } + if (!digestMatchesAssetHash(await sha256Hex(bytes), hash)) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.ASSET_HASH_MISMATCH, + `Asset "${hash}" did not match its SHA-256 digest.` + ) } - const copy = new Uint8Array(bytes.byteLength) - copy.set(bytes) - return copy.buffer + return { bytes, mimeType: payload.mimeType } } diff --git a/packages/extension/mcp/bounded-response.ts b/packages/extension/mcp/bounded-response.ts new file mode 100644 index 00000000..1e8e4b3f --- /dev/null +++ b/packages/extension/mcp/bounded-response.ts @@ -0,0 +1,36 @@ +export async function readBoundedResponseBytes( + response: Response, + maxBytes: number, + tooLarge: () => Error +): Promise { + const contentLength = response.headers.get('content-length') + if (contentLength !== null && Number(contentLength) > maxBytes) throw tooLarge() + + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()) + if (bytes.byteLength > maxBytes) throw tooLarge() + return bytes + } + + const chunks: Uint8Array[] = [] + const reader = response.body.getReader() + let size = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > maxBytes) { + await reader.cancel().catch(() => undefined) + throw tooLarge() + } + chunks.push(value) + } + + const bytes = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} diff --git a/packages/extension/mcp/broker/hub-client.ts b/packages/extension/mcp/broker/hub-client.ts index f545b049..7e407d3b 100644 --- a/packages/extension/mcp/broker/hub-client.ts +++ b/packages/extension/mcp/broker/hub-client.ts @@ -6,7 +6,11 @@ import type { ToolResultMessage } from '@tempad-dev/shared' -import { MCP_PORT_CANDIDATES, parseMessageToExtension } from '@tempad-dev/shared' +import { + MCP_PORT_CANDIDATES, + TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION, + parseMessageToExtension +} from '@tempad-dev/shared' const RECONNECT_DELAY_MS = 3000 const KEEPALIVE_INTERVAL_MS = 20000 @@ -38,6 +42,8 @@ type HubConnection = { ws: WebSocket } +class McpBridgeProtocolMismatchError extends Error {} + export class McpHubClient { private activeId: string | null = null private assetServerUrl: string | null = null @@ -116,6 +122,7 @@ export class McpHubClient { this.errorMessage = null this.emitSnapshot() + let protocolMismatch: McpBridgeProtocolMismatchError | null = null for (const candidatePort of this.getPortCandidates()) { if (!this.isCurrentConnection(epoch)) return try { @@ -134,15 +141,18 @@ export class McpHubClient { this.lastSuccessfulPort = candidatePort this.startKeepalive() return - } catch { + } catch (error) { if (!this.isCurrentConnection(epoch)) return + if (error instanceof McpBridgeProtocolMismatchError) { + protocolMismatch = error + } } } if (!this.isCurrentConnection(epoch)) return this.cleanupSocket() this.status = 'error' - this.errorMessage = LOCAL_HUB_UNREACHABLE_MESSAGE + this.errorMessage = protocolMismatch?.message ?? LOCAL_HUB_UNREACHABLE_MESSAGE this.emitSnapshot() this.scheduleReconnect() } @@ -196,6 +206,11 @@ export class McpHubClient { resolve({ registered, state, ws }) } const handleMessage = (event: Event) => { + const registration = inspectHubRegistration(event) + if (registration && registration.protocolVersion !== TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION) { + fail(createProtocolMismatchError(registration.protocolVersion)) + return + } const message = parseHubMessage(event) if (!message) { fail(new Error('Received malformed MCP server handshake')) @@ -247,6 +262,14 @@ export class McpHubClient { private handleMessage(ws: WebSocket, event: MessageEvent): void { if (this.ws !== ws) return + const registration = inspectHubRegistration(event) + if (registration && registration.protocolVersion !== TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION) { + this.rejectConnectedMessage( + ws, + createProtocolMismatchError(registration.protocolVersion).message + ) + return + } const message = parseHubMessage(event) if (!message) { this.rejectConnectedMessage(ws, 'Received malformed message from MCP server') @@ -366,6 +389,30 @@ function parseHubMessage(event: Event): MessageToExtension | null { return parseMessageToExtension(typeof data === 'string' ? data : '') } +function inspectHubRegistration(event: Event): { protocolVersion: number } | null { + const data = (event as MessageEvent).data + if (typeof data !== 'string') return null + try { + const value: unknown = JSON.parse(data) + if (typeof value !== 'object' || value === null || !('type' in value)) return null + if (value.type !== 'registered') return null + const protocolVersion = 'protocolVersion' in value ? value.protocolVersion : Number.NaN + return { + protocolVersion: typeof protocolVersion === 'number' ? protocolVersion : Number.NaN + } + } catch { + return null + } +} + +function createProtocolMismatchError(protocolVersion: number): McpBridgeProtocolMismatchError { + const received = Number.isFinite(protocolVersion) ? String(protocolVersion) : 'missing or invalid' + return new McpBridgeProtocolMismatchError( + `TemPad Dev protocol mismatch: the extension requires ${TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION}, ` + + `but the MCP server reported ${received}. Update the extension and MCP server together.` + ) +} + function closeWebSocket(ws: WebSocket | null): void { try { ws?.close() diff --git a/packages/extension/mcp/broker/service-worker.ts b/packages/extension/mcp/broker/service-worker.ts index d09b7b1d..8d955cf1 100644 --- a/packages/extension/mcp/broker/service-worker.ts +++ b/packages/extension/mcp/broker/service-worker.ts @@ -7,6 +7,7 @@ import type { } from '@tempad-dev/shared' import { + MCP_MAX_ASSET_BYTES, TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, TEMPAD_MCP_BROWSER_SOURCE, TEMPAD_MCP_ERROR_CODES, @@ -17,6 +18,9 @@ import { import type { McpBrokerPort } from './sessions' +import { readBoundedResponseBytes } from '../bounded-response' +import { base64ToBytes, bytesToBase64, digestMatchesAssetHash, sha256Hex } from '../encoding' +import { coerceToolErrorPayload, createCodedError } from '../errors' import { MCP_LOCAL_HOST_ORIGIN, type McpPermissionMessageType, @@ -27,6 +31,10 @@ import { McpHubClient } from './hub-client' import { McpSessionRegistry } from './sessions' type AssetUploadMessage = Extract +type AssetDownloadMessage = Extract +type AssetDownloadResultPayload = NonNullable< + Extract['payload'] +> export type McpBrokerHubClient = Pick< McpHubClient, @@ -100,6 +108,9 @@ export class McpServiceWorkerBroker { case 'mcp.uploadAsset': void this.uploadAsset(port, message) break + case 'mcp.downloadAsset': + void this.downloadAsset(port, message) + break } } @@ -214,6 +225,28 @@ export class McpServiceWorkerBroker { } } + private async downloadAsset(port: McpBrokerPort, message: AssetDownloadMessage): Promise { + if (this.portSessions.get(port) !== message.sessionId) return + const { assetServerUrl } = this.hubClient.getSnapshot() + if (!assetServerUrl) { + this.sendAssetDownloadResult(port, message, undefined, { + code: TEMPAD_MCP_ERROR_CODES.ASSET_SERVER_NOT_CONFIGURED, + message: 'Asset server URL is not configured.' + }) + return + } + try { + const payload = await downloadAssetFromServer(assetServerUrl, message.payload.hash) + this.sendAssetDownloadResult(port, message, payload) + } catch (error) { + const payload = coerceToolErrorPayload(error) + this.sendAssetDownloadResult(port, message, undefined, { + code: payload.code ?? TEMPAD_MCP_ERROR_CODES.ASSET_BRIDGE_UNAVAILABLE, + message: payload.message + }) + } + } + private handlePortDisconnect(port: McpBrokerPort): void { const sessionId = this.portSessions.get(port) if (!sessionId) return @@ -310,6 +343,32 @@ export class McpServiceWorkerBroker { } } + private sendAssetDownloadResult( + port: McpBrokerPort, + request: AssetDownloadMessage, + payload?: AssetDownloadResultPayload, + error?: { code?: TempadMcpErrorCode; message: string } + ): void { + const message: BridgeToPageMessage = { + ...(error ? { error } : { payload: payload! }), + requestId: request.requestId, + sessionId: request.sessionId, + source: TEMPAD_MCP_BROWSER_SOURCE, + type: 'mcp.assetDownloadResult', + version: TEMPAD_MCP_BROWSER_PROTOCOL_VERSION + } + try { + port.postMessage(message) + } catch { + this.unregisterSession( + request.sessionId, + 'Figma session disconnected before receiving asset download result.' + ) + this.stopHubIfIdle() + this.broadcastState() + } + } + private stopHubIfIdle(): void { if (this.sessions.size > 0) return this.pendingToolCalls.clear() @@ -363,7 +422,7 @@ async function uploadAssetToServer( payload: AssetUploadMessage['payload'] ): Promise { const response = await fetch(`${assetServerUrl}/assets/${payload.hash}`, { - body: new Blob([base64ToArrayBuffer(payload.base64)], { type: payload.mimeType }), + body: new Blob([base64ToBytes(payload.base64)], { type: payload.mimeType }), headers: buildAssetUploadHeaders(payload), method: 'POST' }) @@ -373,6 +432,45 @@ async function uploadAssetToServer( } } +async function downloadAssetFromServer( + assetServerUrl: string, + hash: string +): Promise { + const response = await fetch(`${assetServerUrl}/assets/${hash}`, { method: 'GET' }) + if (response.status === 404) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.ASSET_NOT_FOUND, + `Asset "${hash}" was not found in the local store.` + ) + } + if (!response.ok) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.ASSET_BRIDGE_UNAVAILABLE, + `Asset download failed with status ${response.status} ${response.statusText}.` + ) + } + const tooLarge = () => + createCodedError( + TEMPAD_MCP_ERROR_CODES.ASSET_TOO_LARGE, + `Asset "${hash}" exceeds the ${MCP_MAX_ASSET_BYTES}-byte bridge limit.` + ) + const bytes = await readBoundedResponseBytes(response, MCP_MAX_ASSET_BYTES, tooLarge) + const actual = await sha256Hex(bytes) + if (!digestMatchesAssetHash(actual, hash)) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.ASSET_HASH_MISMATCH, + `Asset "${hash}" did not match its SHA-256 digest.` + ) + } + return { + base64: bytesToBase64(bytes), + mimeType: + response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() || + 'application/octet-stream', + size: bytes.byteLength + } +} + function buildAssetUploadHeaders(payload: AssetUploadMessage['payload']): Record { const headers: Record = { 'Content-Type': payload.mimeType @@ -382,13 +480,3 @@ function buildAssetUploadHeaders(payload: AssetUploadMessage['payload']): Record if (payload.metadata?.themeable) headers['X-Asset-Themeable'] = 'true' return headers } - -function base64ToArrayBuffer(base64: string): ArrayBuffer { - const binary = atob(base64) - const buffer = new ArrayBuffer(binary.length) - const bytes = new Uint8Array(buffer) - for (let index = 0; index < binary.length; index++) { - bytes[index] = binary.charCodeAt(index) - } - return buffer -} diff --git a/packages/extension/mcp/broker/sessions.ts b/packages/extension/mcp/broker/sessions.ts index 629008e1..afd7c727 100644 --- a/packages/extension/mcp/broker/sessions.ts +++ b/packages/extension/mcp/broker/sessions.ts @@ -53,6 +53,6 @@ export class McpSessionRegistry { return } const [sessionId] = this.sessions.keys() - this.activeSessionId = this.sessions.size === 1 ? sessionId : null + this.activeSessionId = this.sessions.size === 1 ? (sessionId ?? null) : null } } diff --git a/packages/extension/mcp/encoding.ts b/packages/extension/mcp/encoding.ts new file mode 100644 index 00000000..471da1d6 --- /dev/null +++ b/packages/extension/mcp/encoding.ts @@ -0,0 +1,36 @@ +import { MCP_HASH_HEX_LENGTH, MCP_LEGACY_HASH_HEX_LENGTH } from '@tempad-dev/shared' + +export function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + return bytes +} + +export function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} + +export async function sha256Hex(bytes: Uint8Array): Promise { + if (typeof crypto?.subtle?.digest !== 'function') { + throw new Error('crypto.subtle.digest is unavailable in this environment.') + } + const input = new Uint8Array(bytes.byteLength) + input.set(bytes) + const digest = await crypto.subtle.digest('SHA-256', input) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function digestMatchesAssetHash(digest: string, hash: string): boolean { + return ( + (hash.length === MCP_HASH_HEX_LENGTH && digest === hash) || + (hash.length === MCP_LEGACY_HASH_HEX_LENGTH && digest.startsWith(hash)) + ) +} diff --git a/packages/extension/mcp/errors.ts b/packages/extension/mcp/errors.ts index 04fbd88f..fe1558b0 100644 --- a/packages/extension/mcp/errors.ts +++ b/packages/extension/mcp/errors.ts @@ -13,12 +13,10 @@ function isTempadMcpErrorCode(value: unknown): value is TempadMcpErrorCode { return typeof value === 'string' && TEMPAD_MCP_ERROR_CODE_SET.has(value) } -function hasCode(value: unknown): value is { code?: unknown } { - return !!value && typeof value === 'object' && 'code' in value -} - -function hasMessage(value: unknown): value is { message?: unknown; code?: unknown } { - return !!value && typeof value === 'object' +function getErrorCode(value: unknown): TempadMcpErrorCode | undefined { + return value && typeof value === 'object' && 'code' in value && isTempadMcpErrorCode(value.code) + ? value.code + : undefined } export function createCodedError( @@ -31,7 +29,7 @@ export function createCodedError( export function coerceToolErrorPayload(error: unknown): ToolErrorPayload { if (error instanceof Error) { const message = error.message || 'Unknown error' - const code = hasCode(error) && isTempadMcpErrorCode(error.code) ? error.code : undefined + const code = getErrorCode(error) return code ? { message, code } : { message } } @@ -39,8 +37,14 @@ export function coerceToolErrorPayload(error: unknown): ToolErrorPayload { return { message: error } } - if (hasMessage(error) && typeof error.message === 'string' && error.message.trim()) { - const code = isTempadMcpErrorCode(error.code) ? error.code : undefined + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof error.message === 'string' && + error.message.trim() + ) { + const code = getErrorCode(error) if (code) { return { message: error.message, code } } diff --git a/packages/extension/mcp/local-styles.ts b/packages/extension/mcp/local-styles.ts new file mode 100644 index 00000000..616ab967 --- /dev/null +++ b/packages/extension/mcp/local-styles.ts @@ -0,0 +1,10 @@ +export async function getLocalStyles(): Promise { + return ( + await Promise.all([ + figma.getLocalPaintStylesAsync(), + figma.getLocalTextStylesAsync(), + figma.getLocalEffectStylesAsync(), + figma.getLocalGridStylesAsync() + ]) + ).flat() +} diff --git a/packages/extension/mcp/media.ts b/packages/extension/mcp/media.ts new file mode 100644 index 00000000..d035b15d --- /dev/null +++ b/packages/extension/mcp/media.ts @@ -0,0 +1,41 @@ +type ImageMimeType = 'image/gif' | 'image/jpeg' | 'image/png' | 'image/webp' + +function hasSignature(bytes: Uint8Array, signature: readonly number[], offset = 0): boolean { + return ( + bytes.length >= offset + signature.length && + signature.every((value, index) => bytes[offset + index] === value) + ) +} + +export function detectImageMime(bytes: Uint8Array): ImageMimeType | null { + if (hasSignature(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return 'image/png' + } + if (hasSignature(bytes, [0xff, 0xd8, 0xff])) { + return 'image/jpeg' + } + if ( + hasSignature(bytes, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || + hasSignature(bytes, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) + ) { + return 'image/gif' + } + if ( + hasSignature(bytes, [0x52, 0x49, 0x46, 0x46]) && + hasSignature(bytes, [0x57, 0x45, 0x42, 0x50], 8) + ) { + return 'image/webp' + } + return null +} + +export function isVisibleMediaPaint( + paint: Paint | null | undefined +): paint is ImagePaint | VideoPaint { + return ( + !!paint && + (paint.type === 'IMAGE' || paint.type === 'VIDEO') && + paint.visible !== false && + (paint.opacity ?? 1) > 0 + ) +} diff --git a/packages/extension/mcp/runtime.ts b/packages/extension/mcp/runtime.ts index dc483846..39f7bf43 100644 --- a/packages/extension/mcp/runtime.ts +++ b/packages/extension/mcp/runtime.ts @@ -39,25 +39,22 @@ function resolveSingleNode(nodeId?: string): SceneNode { return node } - if (selection.value.length !== 1 || !selection.value[0].visible) { + const [selectedNode] = selection.value + if (selection.value.length !== 1 || !selectedNode?.visible) { throw createCodedError( TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION, 'Select exactly one visible node (or provide nodeId) to proceed.' ) } - return selection.value[0] -} - -async function handleGetCode(args?: GetCodeParametersInput): Promise { - return dispatchGetCode(args) + return selectedNode } export type WindowGetCodeParametersInput = GetCodeParametersInput & { _unbounded?: boolean } -async function dispatchGetCode( +async function handleGetCode( args?: GetCodeParametersInput, runtimeOptions?: GetCodeRuntimeOptions ): Promise { @@ -68,7 +65,7 @@ async function dispatchGetCode( async function handleWindowGetCode(args?: WindowGetCodeParametersInput): Promise { const { _unbounded, ...rest } = args ?? {} - return dispatchGetCode(rest, { + return handleGetCode(rest, { unbounded: _unbounded }) } @@ -121,10 +118,8 @@ export const WINDOW_TEMPAD_TOOL_HANDLERS: TempadWindowHandlers = { get_code: handleWindowGetCode } -type McpToolName = keyof MCPHandlers - -function isMcpToolName(name: string): name is McpToolName { - return name in MCP_TOOL_HANDLERS +function isMcpToolName(name: string): name is keyof MCPHandlers { + return Object.hasOwn(MCP_TOOL_HANDLERS, name) } export async function runMcpTool(name: string, args: unknown): Promise { diff --git a/packages/extension/mcp/semantic-tree.ts b/packages/extension/mcp/semantic-tree.ts index f59c7eca..d39ac785 100644 --- a/packages/extension/mcp/semantic-tree.ts +++ b/packages/extension/mcp/semantic-tree.ts @@ -1,5 +1,6 @@ import type { OutlineNode } from '@tempad-dev/shared' +import { isVisibleMediaPaint } from '@/mcp/media' import { toPascalCase } from '@/utils/string' const NODE_CAP = 2048 @@ -88,6 +89,10 @@ const VECTOR_LIKE_TYPES = new Set([ 'POLYGON' ]) +export function isVectorLikeNode(node: SceneNode): boolean { + return VECTOR_LIKE_TYPES.has(node.type) +} + function getBounds(node: SceneNode): Bounds { return { x: node.x, y: node.y, width: node.width, height: node.height } } @@ -110,44 +115,35 @@ function isWrapper(node: SceneNode): boolean { ) } -function resolveTag(node: SceneNode): string { - const { type } = node - if (type === 'TEXT') { +export function resolveSemanticTag(node: SceneNode): string { + if (node.type === 'TEXT') { return node.characters.includes('\n') ? 'p' : 'span' } - if (VECTOR_LIKE_TYPES.has(type)) { + if (isVectorLikeNode(node)) { return 'svg' } - if (type === 'RECTANGLE' && Array.isArray(node.fills)) { - const { fills } = node - const hasImageFill = fills.some((fill) => fill.type === 'IMAGE' && fill.visible !== false) - if (hasImageFill) return 'img' + if (node.type === 'RECTANGLE' && Array.isArray(node.fills)) { + if (node.fills.some(isVisibleMediaPaint)) return 'img' } return 'div' } -function classifyAsset(node: SceneNode): { isAsset: boolean; assetKind?: 'vector' | 'image' } { - const { type } = node - if (VECTOR_LIKE_TYPES.has(type)) { - return { isAsset: true, assetKind: 'vector' } - } +export function classifySemanticAsset(node: SceneNode): 'vector' | 'image' | undefined { + if (isVectorLikeNode(node)) return 'vector' - if (type === 'RECTANGLE' && Array.isArray(node.fills)) { - const { fills } = node - const hasImageFill = fills.some((fill) => fill.type === 'IMAGE' && fill.visible !== false) - if (hasImageFill) { - return { isAsset: true, assetKind: 'image' } - } + if (node.type === 'RECTANGLE' && Array.isArray(node.fills)) { + if (node.fills.some(isVisibleMediaPaint)) return 'image' } - if (type === 'ELLIPSE' || type === 'POLYGON' || type === 'STAR') { - return { isAsset: true, assetKind: 'vector' } - } + return undefined +} - return { isAsset: false } +function describeAsset(node: SceneNode): Pick { + const assetKind = classifySemanticAsset(node) + return assetKind ? { isAsset: true, assetKind } : { isAsset: false } } function hasExplicitOverflow(node: SceneNode): boolean { @@ -309,13 +305,13 @@ function visit( id: node.id, name: node.name, type: node.type, - tag: resolveTag(node), + tag: resolveSemanticTag(node), depth, index, layout: getLayoutKind(node), bounds: getBounds(node), isComponentInstance: node.type === 'INSTANCE', - ...classifyAsset(node), + ...describeAsset(node), autoLayout: extractAutoLayout(node), capped: true, children: [] @@ -343,13 +339,13 @@ function visit( id: node.id, name: node.name, type: node.type, - tag: resolveTag(node), + tag: resolveSemanticTag(node), depth, index, layout: getLayoutKind(node), bounds: getBounds(node), isComponentInstance: node.type === 'INSTANCE', - ...classifyAsset(node), + ...describeAsset(node), autoLayout: extractAutoLayout(node), children } @@ -385,8 +381,8 @@ export function suggestDepthLimit(roots: SceneNode[]): number | undefined { } let cumulative = 0 - for (let i = 0; i < counts.length; i += 1) { - cumulative += counts[i] + for (const [i, count] of counts.entries()) { + cumulative += count if (cumulative > NODE_TARGET) { return i } diff --git a/packages/extension/mcp/tools/canvas.ts b/packages/extension/mcp/tools/canvas.ts deleted file mode 100644 index cd89ecfe..00000000 --- a/packages/extension/mcp/tools/canvas.ts +++ /dev/null @@ -1,761 +0,0 @@ -import type { - ApplyCanvasParameters, - ApplyCanvasParametersInput, - ApplyCanvasResult, - CanvasDesignReference, - CanvasNodeSpec, - CanvasVariableBindings -} from '@tempad-dev/shared' - -import { ApplyCanvasParametersSchema, TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' - -import { canvasWritesOn } from '@/ui/state' - -import { createCodedError } from '../errors' - -const CANVAS_KEY_NAMESPACE = 'tempad_dev' -const CANVAS_KEY_NAME = 'canvas-key' -const SUPPORTED_NODE_TYPES = new Set([ - 'ELLIPSE', - 'FRAME', - 'INSTANCE', - 'LINE', - 'RECTANGLE', - 'TEXT' -]) - -type SupportedCanvasNode = Extract - -type ApplyState = { - claimedNodeIds: Set - componentCache: Map - createdNodeIds: Set - keyedNodes: Map - mutationCount: number - nodeIdsByKey: Record - scope: SupportedCanvasNode | null - updatedNodeIds: Set - variableCache: Map -} - -let applyInProgress = false - -function specError(message: string): never { - throw createCodedError(TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC, message) -} - -function scopeError(message: string): never { - throw createCodedError(TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SCOPE, message) -} - -function isSupportedSceneNode(node: BaseNode | null): node is SupportedCanvasNode { - return !!node && SUPPORTED_NODE_TYPES.has(node.type as CanvasNodeSpec['type']) -} - -function isWithinScope(node: BaseNode, scope: BaseNode): boolean { - let current: BaseNode | null = node - while (current) { - if (current.id === scope.id) return true - current = current.parent - } - return false -} - -function collectKeyedNodes(scope: SupportedCanvasNode): Map { - const keyed = new Map() - const stack: BaseNode[] = [scope] - while (stack.length) { - const node = stack.pop()! - if (isSupportedSceneNode(node)) { - const key = node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_KEY_NAME) - if (key) { - if (keyed.has(key)) { - scopeError(`Canvas key "${key}" is duplicated inside the update scope.`) - } - keyed.set(key, node) - } - } - if ('children' in node) { - stack.push(...node.children) - } - } - return keyed -} - -function markMutation(state: ApplyState, node: SupportedCanvasNode): void { - state.mutationCount += 1 - if (!state.createdNodeIds.has(node.id)) { - state.updatedNodeIds.add(node.id) - } -} - -function setNodeKey(state: ApplyState, node: SupportedCanvasNode, key: string): void { - const currentKey = node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_KEY_NAME) - if (currentKey === key) return - if (currentKey) { - specError(`Node "${node.id}" is already owned by canvas key "${currentKey}".`) - } - node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_KEY_NAME, key) - markMutation(state, node) -} - -function resolveExistingNode( - spec: CanvasNodeSpec, - state: ApplyState, - forcedNode?: SupportedCanvasNode -): SupportedCanvasNode | null { - let node = forcedNode ?? null - if (!node && spec.nodeId) { - const candidate = figma.getNodeById(spec.nodeId) - if (!isSupportedSceneNode(candidate)) { - scopeError(`Node "${spec.nodeId}" does not exist or is not supported by apply_canvas.`) - } - node = candidate - } else if (!node) { - node = state.keyedNodes.get(spec.key) ?? null - } - - if (!node) return null - const keyedNode = state.keyedNodes.get(spec.key) - if (keyedNode && keyedNode.id !== node.id) { - specError( - `Canvas key "${spec.key}" already identifies node "${keyedNode.id}", not "${node.id}".` - ) - } - if (state.scope && !isWithinScope(node, state.scope)) { - scopeError(`Node "${node.id}" is outside the requested update scope.`) - } - if (node.type !== spec.type) { - specError( - `Canvas key "${spec.key}" expects ${spec.type}, but node "${node.id}" is ${node.type}.` - ) - } - if (state.claimedNodeIds.has(node.id)) { - specError(`Node "${node.id}" is referenced more than once in the desired result.`) - } - state.claimedNodeIds.add(node.id) - return node -} - -function referenceCacheKey(reference: CanvasDesignReference): string { - return reference.id !== undefined ? `id:${reference.id}` : `key:${reference.key}` -} - -async function resolveComponent(reference: CanvasDesignReference, state: ApplyState) { - const cacheKey = referenceCacheKey(reference) - const cached = state.componentCache.get(cacheKey) - if (cached) return cached - - let component: ComponentNode | null = null - if (reference.id !== undefined) { - const node = figma.getNodeById(reference.id) - if (node?.type === 'COMPONENT') { - component = node - } else if (node?.type === 'COMPONENT_SET') { - component = node.defaultVariant - } - } else { - component = await figma.importComponentByKeyAsync(reference.key) - } - - if (!component) { - specError('The requested component could not be resolved.') - } - state.componentCache.set(cacheKey, component) - return component -} - -async function resolveVariable( - reference: CanvasDesignReference, - state: ApplyState -): Promise { - const cacheKey = referenceCacheKey(reference) - const cached = state.variableCache.get(cacheKey) - if (cached) return cached - - const variable = - reference.id !== undefined - ? await figma.variables.getVariableByIdAsync(reference.id) - : await figma.variables.importVariableByKeyAsync(reference.key) - if (!variable) { - specError('The requested variable could not be resolved.') - } - state.variableCache.set(cacheKey, variable) - return variable -} - -async function createNode(spec: CanvasNodeSpec, state: ApplyState): Promise { - let node: SupportedCanvasNode - switch (spec.type) { - case 'ELLIPSE': - node = figma.createEllipse() - break - case 'FRAME': - node = figma.createFrame() - break - case 'INSTANCE': { - const component = await resolveComponent(spec.component!, state) - node = component.createInstance() - break - } - case 'LINE': - node = figma.createLine() - break - case 'RECTANGLE': - node = figma.createRectangle() - break - case 'TEXT': - node = figma.createText() - break - } - state.mutationCount += 1 - state.createdNodeIds.add(node.id) - state.claimedNodeIds.add(node.id) - return node -} - -function moveIntoParent( - node: SupportedCanvasNode, - parent: FrameNode, - index: number, - state: ApplyState -): void { - if (node.parent?.id === parent.id && parent.children.indexOf(node) === index) return - parent.insertChild(index, node) - markMutation(state, node) -} - -function setValue( - node: SupportedCanvasNode, - current: T, - desired: T | undefined, - apply: (value: T) => void, - state: ApplyState -): void { - if (desired === undefined || Object.is(current, desired)) return - apply(desired) - markMutation(state, node) -} - -const PADDING_FIELDS = [ - ['top', 'paddingTop'], - ['right', 'paddingRight'], - ['bottom', 'paddingBottom'], - ['left', 'paddingLeft'] -] as const - -function applyLayout(node: FrameNode, spec: CanvasNodeSpec, state: ApplyState): void { - const layout = spec.layout - if (!layout) return - const bindings = spec.variables - - setValue(node, node.layoutMode, layout.mode, (value) => (node.layoutMode = value), state) - const hasAutoLayoutProperty = - layout.gap !== undefined || - layout.padding !== undefined || - layout.primaryAlign !== undefined || - layout.counterAlign !== undefined - if (hasAutoLayoutProperty && node.layoutMode === 'NONE') { - specError( - `FRAME "${spec.key}" must use HORIZONTAL or VERTICAL layout before setting layout details.` - ) - } - setValue( - node, - node.itemSpacing, - bindings?.gap ? undefined : layout.gap, - (value) => (node.itemSpacing = value), - state - ) - setValue( - node, - node.primaryAxisAlignItems, - layout.primaryAlign, - (value) => (node.primaryAxisAlignItems = value), - state - ) - setValue( - node, - node.counterAxisAlignItems, - layout.counterAlign, - (value) => (node.counterAxisAlignItems = value), - state - ) - - const padding = layout.padding - if (padding === undefined) return - for (const [side, field] of PADDING_FIELDS) { - const desired = typeof padding === 'number' ? padding : padding[side] - setValue( - node, - node[field], - bindings?.[field] ? undefined : desired, - (value) => (node[field] = value), - state - ) - } -} - -function applyPosition(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { - const position = spec.position - if (!position) return - setValue(node, node.x, position.x, (value) => (node.x = value), state) - setValue(node, node.y, position.y, (value) => (node.y = value), state) -} - -function applySize(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { - const size = spec.size - if (!size) return - const width = !spec.variables?.width && size.width !== undefined ? size.width : node.width - const height = - node.type !== 'LINE' && !spec.variables?.height && size.height !== undefined - ? size.height - : node.height - if (Math.abs(node.width - width) > 0.01 || Math.abs(node.height - height) > 0.01) { - node.resize(width, height) - markMutation(state, node) - } - setValue( - node, - node.layoutSizingHorizontal, - size.horizontal, - (value) => (node.layoutSizingHorizontal = value), - state - ) - setValue( - node, - node.layoutSizingVertical, - size.vertical, - (value) => (node.layoutSizingVertical = value), - state - ) -} - -function paintsEqual(current: readonly Paint[], desired: readonly SolidPaint[]): boolean { - return ( - current.length === desired.length && - current.every((paint, index) => { - const expected = desired[index]! - return ( - paint.type === 'SOLID' && - paint.color.r === expected.color.r && - paint.color.g === expected.color.g && - paint.color.b === expected.color.b && - (paint.opacity ?? 1) === (expected.opacity ?? 1) && - (paint.visible ?? true) === (expected.visible ?? true) && - (paint.blendMode ?? 'NORMAL') === (expected.blendMode ?? 'NORMAL') - ) - }) - ) -} - -function applyPaint( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - field: 'fill' | 'stroke', - state: ApplyState -): void { - const color = spec.appearance?.[field] - if (color === undefined) return - - const property = field === 'fill' ? 'fills' : 'strokes' - const paints = node[property] - const desired = color === null ? [] : [figma.util.solidPaint(color)] - const hasBinding = !!spec.variables?.[field] - if (hasBinding) { - if (paints !== figma.mixed && paints.length === 1 && paints[0]?.type === 'SOLID') return - if (color === null) { - const label = field === 'fill' ? 'Fill' : 'Stroke' - specError(`${label} variable binding on "${spec.key}" requires a solid fallback paint.`) - } - } else if (paints !== figma.mixed && paintsEqual(paints, desired)) { - return - } - - node[property] = desired - markMutation(state, node) -} - -function applyAppearance(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { - const appearance = spec.appearance - if (!appearance) return - - applyPaint(node, spec, 'fill', state) - applyPaint(node, spec, 'stroke', state) - if ('strokeWeight' in node) { - setValue( - node, - node.strokeWeight, - appearance.strokeWeight, - (value) => (node.strokeWeight = value), - state - ) - } - if ('cornerRadius' in node) { - setValue( - node, - node.cornerRadius, - spec.variables?.cornerRadius ? undefined : appearance.cornerRadius, - (value) => (node.cornerRadius = value), - state - ) - } - setValue( - node, - node.opacity, - spec.variables?.opacity ? undefined : appearance.opacity, - (value) => (node.opacity = value), - state - ) -} - -async function loadTextFonts(node: TextNode, spec: CanvasNodeSpec): Promise { - const text = spec.text - const currentFont = node.fontName - const fontFamily = spec.variables?.fontFamily ? undefined : text?.fontFamily - const fontStyle = spec.variables?.fontStyle ? undefined : text?.fontStyle - const hasExplicitFont = fontFamily !== undefined || fontStyle !== undefined - if (currentFont === figma.mixed && hasExplicitFont && (!fontFamily || !fontStyle)) { - specError( - `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` - ) - } - - const desiredFont: FontName | null = hasExplicitFont - ? { - family: fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family), - style: fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style) - } - : null - const fonts = desiredFont - ? [desiredFont] - : currentFont === figma.mixed - ? node.getRangeAllFontNames(0, node.characters.length) - : [currentFont] - const uniqueFonts = [ - ...new Map(fonts.map((font) => [`${font.family}\0${font.style}`, font])).values() - ] - await Promise.all(uniqueFonts.map((font) => figma.loadFontAsync(font))) - return desiredFont -} - -async function applyText(node: TextNode, spec: CanvasNodeSpec, state: ApplyState): Promise { - const text = spec.text - if (!text) return - const desiredFont = await loadTextFonts(node, spec) - if ( - desiredFont && - (node.fontName === figma.mixed || - node.fontName.family !== desiredFont.family || - node.fontName.style !== desiredFont.style) - ) { - node.fontName = desiredFont - markMutation(state, node) - } - setValue(node, node.characters, text.characters, (value) => (node.characters = value), state) - setValue( - node, - node.fontSize, - spec.variables?.fontSize ? undefined : text.fontSize, - (value) => (node.fontSize = value), - state - ) - setTextPixelValue( - node, - 'lineHeight', - spec.variables?.lineHeight ? undefined : text.lineHeight, - state - ) - setTextPixelValue( - node, - 'letterSpacing', - spec.variables?.letterSpacing ? undefined : text.letterSpacing, - state - ) - setValue( - node, - node.textAlignHorizontal, - text.alignHorizontal, - (value) => (node.textAlignHorizontal = value), - state - ) - setValue( - node, - node.textAlignVertical, - text.alignVertical, - (value) => (node.textAlignVertical = value), - state - ) -} - -function setTextPixelValue( - node: TextNode, - field: 'letterSpacing' | 'lineHeight', - desired: number | undefined, - state: ApplyState -): void { - if (desired === undefined) return - const current = node[field] - if (current !== figma.mixed && current.unit === 'PIXELS' && current.value === desired) return - node[field] = { unit: 'PIXELS', value: desired } - markMutation(state, node) -} - -async function applyComponent( - node: InstanceNode, - spec: CanvasNodeSpec, - state: ApplyState -): Promise { - const component = await resolveComponent(spec.component!, state) - const currentComponent = await node.getMainComponentAsync() - if (currentComponent?.id !== component.id) { - node.swapComponent(component) - markMutation(state, node) - } - - if (!spec.componentProperties) return - const changedProperties = Object.entries(spec.componentProperties).filter( - ([name, value]) => node.componentProperties[name]?.value !== value - ) - if (!changedProperties.length) return - node.setProperties(Object.fromEntries(changedProperties)) - markMutation(state, node) -} - -type DirectVariableField = Exclude - -const DIRECT_VARIABLE_FIELDS: Record< - DirectVariableField, - VariableBindableNodeField | VariableBindableTextField -> = { - width: 'width', - height: 'height', - gap: 'itemSpacing', - paddingTop: 'paddingTop', - paddingRight: 'paddingRight', - paddingBottom: 'paddingBottom', - paddingLeft: 'paddingLeft', - cornerRadius: 'cornerRadius', - opacity: 'opacity', - fontFamily: 'fontFamily', - fontStyle: 'fontStyle', - fontSize: 'fontSize', - lineHeight: 'lineHeight', - letterSpacing: 'letterSpacing' -} - -function currentBoundVariableId( - node: SupportedCanvasNode, - field: VariableBindableNodeField | VariableBindableTextField -): string | undefined { - const value = node.boundVariables?.[field] - const directId = Array.isArray(value) ? value[0]?.id : value?.id - if (directId || field !== 'cornerRadius') return directId - - const aliases = [ - node.boundVariables?.topLeftRadius, - node.boundVariables?.topRightRadius, - node.boundVariables?.bottomLeftRadius, - node.boundVariables?.bottomRightRadius - ] - const radiusId = aliases[0]?.id - return radiusId && aliases.every((alias) => alias?.id === radiusId) ? radiusId : undefined -} - -function applyPaintVariable( - node: SupportedCanvasNode, - field: 'fill' | 'stroke', - variable: Variable, - state: ApplyState -): void { - const property = field === 'fill' ? 'fills' : 'strokes' - const currentPaints = node[property] - if (currentPaints === figma.mixed) { - specError(`${field} variable bindings cannot target mixed paints on node "${node.id}".`) - } - const paints = [...currentPaints] - if (paints.length !== 1 || paints[0]?.type !== 'SOLID') { - specError(`${field} variable bindings require exactly one solid paint on node "${node.id}".`) - } - const currentVariable = node.boundVariables?.[property]?.[0] - if (currentVariable?.id === variable.id) return - paints[0] = figma.variables.setBoundVariableForPaint(paints[0], 'color', variable) - node[property] = paints - markMutation(state, node) -} - -async function applyVariables( - node: SupportedCanvasNode, - bindings: CanvasVariableBindings | undefined, - state: ApplyState -): Promise { - if (!bindings) return - for (const field of Object.keys(bindings) as Array) { - const reference = bindings[field] - if (!reference) continue - const variable = await resolveVariable(reference, state) - if (field === 'fill' || field === 'stroke') { - applyPaintVariable(node, field, variable, state) - continue - } - const figmaField = DIRECT_VARIABLE_FIELDS[field] - if (currentBoundVariableId(node, figmaField) === variable.id) continue - node.setBoundVariable(figmaField, variable) - markMutation(state, node) - } -} - -async function applyNodeProperties( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - state: ApplyState -): Promise { - setValue(node, node.name, spec.name, (value) => (node.name = value), state) - setValue(node, node.visible, spec.visible, (value) => (node.visible = value), state) - if (node.type === 'FRAME') applyLayout(node, spec, state) - applyPosition(node, spec, state) - applySize(node, spec, state) - applyAppearance(node, spec, state) - if (node.type === 'TEXT') await applyText(node, spec, state) - if (node.type === 'INSTANCE') await applyComponent(node, spec, state) - await applyVariables(node, spec.variables, state) -} - -async function reconcileNode( - spec: CanvasNodeSpec, - state: ApplyState, - parent?: FrameNode, - index = 0, - forcedNode?: SupportedCanvasNode -): Promise { - const existing = resolveExistingNode(spec, state, forcedNode) - const node = existing ?? (await createNode(spec, state)) - - if (parent) moveIntoParent(node, parent, index, state) - setNodeKey(state, node, spec.key) - await applyNodeProperties(node, spec, state) - state.nodeIdsByKey[spec.key] = node.id - - if (spec.children?.length) { - if (node.type !== 'FRAME') { - specError(`Only FRAME nodes can contain desired children; "${spec.key}" is ${node.type}.`) - } - for (const [childIndex, child] of spec.children.entries()) { - await reconcileNode(child, state, node, childIndex) - } - } - return node -} - -function placeCreatedRoot( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - state: ApplyState -): void { - if (spec.position?.x !== undefined || spec.position?.y !== undefined) return - const center = figma.viewport.center - const x = center.x - node.width / 2 - const y = center.y - node.height / 2 - if (node.x === x && node.y === y) return - node.x = x - node.y = y - markMutation(state, node) -} - -async function applyParsedCanvas(input: ApplyCanvasParameters): Promise { - let target: SupportedCanvasNode | null = null - if (input.mode === 'update') { - const candidate = figma.getNodeById(input.targetNodeId!) - if (!isSupportedSceneNode(candidate)) { - scopeError('The requested update target does not exist or is not a supported scene node.') - } - target = candidate - } - if (target && target.type !== input.root.type) { - specError( - `The update root expects ${input.root.type}, but target "${target.id}" is ${target.type}.` - ) - } - - const state: ApplyState = { - claimedNodeIds: new Set(), - componentCache: new Map(), - createdNodeIds: new Set(), - keyedNodes: target ? collectKeyedNodes(target) : new Map(), - mutationCount: 0, - nodeIdsByKey: Object.create(null) as Record, - scope: target, - updatedNodeIds: new Set(), - variableCache: new Map() - } - - figma.commitUndo() - try { - const root = await reconcileNode(input.root, state, undefined, 0, target ?? undefined) - if (input.mode === 'create') { - placeCreatedRoot(root, input.root, state) - } - figma.commitUndo() - return { - rootNodeId: root.id, - nodeIdsByKey: state.nodeIdsByKey, - createdNodeIds: [...state.createdNodeIds], - updatedNodeIds: [...state.updatedNodeIds], - mutationCount: state.mutationCount - } - } catch (error) { - try { - figma.triggerUndo() - } catch { - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, - 'Canvas apply failed and automatic rollback was not available. Use Figma Undo.' - ) - } - throw error - } -} - -export async function handleApplyCanvas( - args?: ApplyCanvasParametersInput -): Promise { - if (!canvasWritesOn.value) { - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.CANVAS_WRITE_DISABLED, - 'Canvas writing is disabled. Enable Canvas writes in TemPad Dev → Agent integration.' - ) - } - if (figma.editorType !== 'figma') { - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.CANVAS_UNSUPPORTED_EDITOR, - 'Canvas authoring is supported only in Figma Design files.' - ) - } - if (applyInProgress) { - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.CANVAS_BUSY, - 'Another apply_canvas call is already running in this Figma session.' - ) - } - - const parsed = ApplyCanvasParametersSchema.safeParse(args) - if (!parsed.success) { - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC, - parsed.error.issues.map((issue) => issue.message).join(' ') - ) - } - - applyInProgress = true - try { - return await applyParsedCanvas(parsed.data) - } catch (error) { - if (error instanceof Error && 'code' in error) throw error - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, - error instanceof Error ? error.message : 'Canvas apply failed.' - ) - } finally { - applyInProgress = false - } -} diff --git a/packages/extension/mcp/tools/canvas/assets.ts b/packages/extension/mcp/tools/canvas/assets.ts new file mode 100644 index 00000000..bcc43200 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/assets.ts @@ -0,0 +1,290 @@ +import type { CanvasAssets, TempadMcpErrorCode } from '@tempad-dev/shared' + +import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' +import { parseSync, stringify, type INode } from 'svgson' + +import { downloadAsset } from '@/mcp/assets' +import { sha256Hex } from '@/mcp/encoding' +import { detectImageMime } from '@/mcp/media' + +import { createCodedError } from '../../errors' + +const INLINE_SVG_BYTES = 32 * 1024 +const HUB_SVG_BYTES = 1024 * 1024 +const MAX_SVG_ELEMENTS = 500 +const MAX_SVG_DEPTH = 32 +export const SVG_POLICY_VERSION = '1' + +const BANNED_ELEMENTS = new Set([ + 'audio', + 'foreignobject', + 'iframe', + 'image', + 'script', + 'style', + 'video' +]) + +type ResolvedSvgAsset = { + type: 'SVG' + digest: string + height: number + svg: string + width: number +} + +type ResolvedImageAsset = { + type: 'IMAGE' + bytes: Uint8Array + hash: string + mimeType: 'image/gif' | 'image/jpeg' | 'image/png' +} + +type ResolvedCanvasAsset = ResolvedSvgAsset | ResolvedImageAsset +export type ResolvedCanvasAssets = Map + +export async function resolveCanvasAssets( + assets: CanvasAssets | undefined, + svgColors: ReadonlyMap> +): Promise { + const resolved: ResolvedCanvasAssets = new Map() + for (const [key, declaration] of Object.entries(assets ?? {})) { + if (declaration.type === 'IMAGE') { + const downloaded = await downloadAsset(declaration.assetHash) + resolved.set(imageCacheKey(key), { + type: 'IMAGE', + bytes: downloaded.bytes, + hash: declaration.assetHash, + mimeType: validateImageMime(key, downloaded.bytes, downloaded.mimeType) + }) + continue + } + const colors = svgColors.get(key) + if (!colors) continue + const inline = 'svg' in declaration + const source = inline ? declaration.svg : await downloadSvg(key, declaration.assetHash) + for (const color of colors) { + resolved.set( + svgCacheKey(key, color), + await sanitizeSvg(key, source, color, inline ? INLINE_SVG_BYTES : HUB_SVG_BYTES) + ) + } + } + return resolved +} + +export function resolvedImageAsset( + assets: ResolvedCanvasAssets, + key: string +): ResolvedImageAsset | undefined { + const asset = assets.get(imageCacheKey(key)) + return asset?.type === 'IMAGE' ? asset : undefined +} + +export function resolvedSvgAsset( + assets: ResolvedCanvasAssets, + key: string, + color: string | undefined +): ResolvedSvgAsset | undefined { + const asset = assets.get(svgCacheKey(key, color)) + return asset?.type === 'SVG' ? asset : undefined +} + +async function downloadSvg(key: string, hash: string): Promise { + const asset = await downloadAsset(hash) + if (asset.bytes.byteLength > HUB_SVG_BYTES) { + assetError( + TEMPAD_MCP_ERROR_CODES.ASSET_TOO_LARGE, + key, + `SVG asset exceeds ${HUB_SVG_BYTES} bytes.` + ) + } + if (normalizeMime(asset.mimeType) !== 'image/svg+xml') { + assetError( + TEMPAD_MCP_ERROR_CODES.ASSET_MIME_UNSUPPORTED, + key, + 'SVG asset must use image/svg+xml.' + ) + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(asset.bytes) + } catch { + assetError(TEMPAD_MCP_ERROR_CODES.SVG_INVALID, key, 'SVG asset is not valid UTF-8.') + } +} + +async function sanitizeSvg( + key: string, + source: string, + color: string | undefined, + maxBytes: number +): Promise { + const bytes = new TextEncoder().encode(source) + if (bytes.byteLength > maxBytes) { + assetError( + TEMPAD_MCP_ERROR_CODES.ASSET_TOO_LARGE, + key, + maxBytes === INLINE_SVG_BYTES + ? `Inline SVG exceeds ${INLINE_SVG_BYTES} bytes; store it as a Hub asset.` + : `SVG asset exceeds ${HUB_SVG_BYTES} bytes.` + ) + } + if (/[\uD800-\uDFFF]/u.test(source) || / document root.') + } + + let elements = 0 + const normalizedColor = color?.toUpperCase() + const visit = (node: INode, depth: number): void => { + if (node.type !== 'element') return + elements += 1 + if (elements > MAX_SVG_ELEMENTS || depth > MAX_SVG_DEPTH) { + assetError( + TEMPAD_MCP_ERROR_CODES.SVG_TOO_COMPLEX, + key, + `SVG may contain at most ${MAX_SVG_ELEMENTS} elements and ${MAX_SVG_DEPTH} levels.` + ) + } + const name = node.name.toLowerCase() + if (BANNED_ELEMENTS.has(name)) { + assetError(TEMPAD_MCP_ERROR_CODES.SVG_INVALID, key, `SVG element <${name}> is not supported.`) + } + for (const [attribute, rawValue] of Object.entries(node.attributes)) { + const name = attribute.toLowerCase() + if (name === 'style' || name.startsWith('on') || name === 'src') { + assetError( + TEMPAD_MCP_ERROR_CODES.SVG_INVALID, + key, + `SVG attribute "${attribute}" is not supported.` + ) + } + const value = rawValue.trim() + if (name === 'href' || name === 'xlink:href') { + if (!/^#[A-Za-z_][\w:.-]*$/.test(value)) { + assetError( + TEMPAD_MCP_ERROR_CODES.SVG_EXTERNAL_REFERENCE, + key, + 'SVG links must reference a local #id.' + ) + } + } + if (/@import/i.test(value) || hasExternalUrl(value)) { + assetError( + TEMPAD_MCP_ERROR_CODES.SVG_EXTERNAL_REFERENCE, + key, + 'SVG cannot load external content.' + ) + } + if (/currentcolor/i.test(value)) { + if (!normalizedColor) { + assetError( + TEMPAD_MCP_ERROR_CODES.SVG_INVALID, + key, + 'SVG uses currentColor but its placement has no color.' + ) + } + node.attributes[attribute] = rawValue.replace(/currentcolor/gi, normalizedColor) + } + } + node.attributes = Object.fromEntries( + Object.entries(node.attributes).sort(([left], [right]) => left.localeCompare(right)) + ) + for (const child of node.children) visit(child, depth + 1) + } + visit(root, 1) + + let viewport: { width: number; height: number } + try { + viewport = svgViewport(root) + } catch (error) { + assetError( + TEMPAD_MCP_ERROR_CODES.SVG_INVALID, + key, + error instanceof Error ? error.message : 'SVG viewport is invalid.' + ) + } + const sanitized = stringify(root) + return { + type: 'SVG', + digest: await sha256Hex( + new TextEncoder().encode(`${SVG_POLICY_VERSION}\0${normalizedColor ?? ''}\0${sanitized}`) + ), + height: viewport.height, + svg: sanitized, + width: viewport.width + } +} + +function svgViewport(root: INode): { width: number; height: number } { + const viewBox = root.attributes.viewBox?.trim() + if (viewBox) { + const values = viewBox.split(/[\s,]+/).map(Number) + if (values.length === 4 && values.every(Number.isFinite) && values[2]! > 0 && values[3]! > 0) { + return { width: values[2]!, height: values[3]! } + } + throw new Error('SVG viewBox must contain four finite values with positive width and height.') + } + const width = parseSvgLength(root.attributes.width) + const height = parseSvgLength(root.attributes.height) + if (width && height) return { width, height } + throw new Error('SVG requires a positive viewBox or positive intrinsic width and height.') +} + +function parseSvgLength(value: string | undefined): number | null { + if (!value || !/^(?:\d+(?:\.\d+)?|\.\d+)(?:px)?$/i.test(value.trim())) return null + const parsed = Number.parseFloat(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + +function hasExternalUrl(value: string): boolean { + const withoutLocalRefs = value.replace(/url\(\s*(['"]?)#[A-Za-z_][\w:.-]*\1\s*\)/gi, '') + return /url\s*\(/i.test(withoutLocalRefs) +} + +function validateImageMime( + key: string, + bytes: Uint8Array, + declaredMime: string +): ResolvedImageAsset['mimeType'] { + const actual = detectImageMime(bytes) + const declared = normalizeMime(declaredMime) + if (!actual || actual === 'image/webp' || actual !== declared) { + assetError( + TEMPAD_MCP_ERROR_CODES.ASSET_MIME_UNSUPPORTED, + key, + 'Image asset must be a matching PNG, JPEG, or GIF.' + ) + } + return actual +} + +function normalizeMime(value: string): string { + const mime = value.split(';', 1)[0]!.trim().toLowerCase() + return mime === 'image/jpg' ? 'image/jpeg' : mime +} + +function imageCacheKey(key: string): string { + return `image:${key}` +} + +function svgCacheKey(key: string, color: string | undefined): string { + return `svg:${key}:${color?.toUpperCase() ?? ''}` +} + +function assetError(code: TempadMcpErrorCode, key: string, message: string): never { + throw createCodedError(code, `Asset "${key}": ${message}`) +} diff --git a/packages/extension/mcp/tools/canvas/errors.ts b/packages/extension/mcp/tools/canvas/errors.ts new file mode 100644 index 00000000..a765e3e0 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/errors.ts @@ -0,0 +1,59 @@ +import type { ZodError } from 'zod' + +import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' + +import { createCodedError } from '../../errors' + +const MAX_SCHEMA_ISSUES = 4 +const MAX_SCHEMA_MESSAGE_CHARS = 240 +const READ_ONLY_ERROR_PATTERN = /\b(?:read|view)[ -]?only\b|\bedit access\b|\bpermission to edit\b/i + +export function formatSchemaError(error: ZodError): string { + const issues = error.issues.slice(0, MAX_SCHEMA_ISSUES).map((issue) => { + const message = + issue.message.length <= MAX_SCHEMA_MESSAGE_CHARS + ? issue.message + : `${issue.message.slice(0, MAX_SCHEMA_MESSAGE_CHARS - 3)}...` + return `${formatPath(issue.path)}: ${message}` + }) + const omitted = error.issues.length - issues.length + if (omitted > 0) + issues.push(`${omitted} more validation issue${omitted === 1 ? '' : 's'} omitted.`) + return issues.join('\n') || 'Canvas input is invalid.' +} + +function formatPath(path: PropertyKey[]): string { + if (!path.length) return 'input' + return path.reduce( + (result, segment) => + typeof segment === 'number' + ? `${result}[${segment}]` + : result + ? `${result}.${String(segment)}` + : String(segment), + '' + ) +} + +export function specError(message: string): never { + throw createCodedError(TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC, message) +} + +export function scopeError(message: string): never { + throw createCodedError(TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SCOPE, message) +} + +export function canvasReadOnlyError(error: unknown): Error | null { + // The Plugin API exposes no file-permission flag, so normalize its native mutation error. + if ( + !(error instanceof Error) || + 'code' in error || + !READ_ONLY_ERROR_PATTERN.test(error.message) + ) { + return null + } + return createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_READ_ONLY, + 'Canvas authoring requires edit access to the current Figma Design file.' + ) +} diff --git a/packages/extension/mcp/tools/canvas/html.ts b/packages/extension/mcp/tools/canvas/html.ts new file mode 100644 index 00000000..4b139469 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/html.ts @@ -0,0 +1,174 @@ +import { MAX_CANVAS_DEPTH } from '@tempad-dev/shared' + +export type CanvasMarkupElement = { + attributes: Record + children: CanvasMarkupElement[] + tag: string + text: string +} + +const HTML_ENTITIES: Readonly> = { + amp: '&', + apos: "'", + gt: '>', + lt: '<', + nbsp: '\u00a0', + quot: '"' +} + +function htmlError(message: string): never { + throw new Error(message) +} + +function normalizeTag(tag: string): string { + const normalized = tag.toLowerCase() + return normalized === 'div' || normalized === 'span' ? normalized : tag +} + +function decodeEntities(value: string): string { + let result = '' + let index = 0 + while (index < value.length) { + if (value[index] !== '&') { + result += value[index] + index += 1 + continue + } + const end = value.indexOf(';', index + 1) + if (end < 0) htmlError('HTML entities must end with ";".') + const entity = value.slice(index + 1, end) + if (Object.hasOwn(HTML_ENTITIES, entity)) { + result += HTML_ENTITIES[entity] + } else { + const hex = entity.startsWith('#x') || entity.startsWith('#X') + const digits = hex ? entity.slice(2) : entity.startsWith('#') ? entity.slice(1) : '' + if (!digits || !(hex ? /^[\dA-Fa-f]+$/ : /^\d+$/).test(digits)) { + htmlError(`Unsupported HTML entity "&${entity};".`) + } + const codePoint = Number.parseInt(digits, hex ? 16 : 10) + if ( + !Number.isInteger(codePoint) || + codePoint > 0x10ffff || + (codePoint >= 0xd800 && codePoint <= 0xdfff) + ) { + htmlError(`Invalid HTML character reference "&${entity};".`) + } + result += String.fromCodePoint(codePoint) + } + index = end + 1 + } + return result +} + +class CanvasHtmlParser { + private index = 0 + + constructor(private readonly source: string) {} + + parse(): CanvasMarkupElement { + this.skipWhitespace() + if (this.index >= this.source.length) htmlError('Canvas markup is empty.') + const root = this.parseElement(1) + this.skipWhitespace() + if (this.index !== this.source.length) { + htmlError('Canvas markup must contain exactly one root element.') + } + return root + } + + private parseElement(depth: number): CanvasMarkupElement { + if (depth > MAX_CANVAS_DEPTH) { + htmlError(`Canvas markup may be at most ${MAX_CANVAS_DEPTH} levels deep.`) + } + this.expect('<') + if (this.peek('/') || this.peek('!') || this.peek('?')) { + htmlError('Unexpected closing tag, declaration, or processing instruction.') + } + const rawTag = this.readName() + if (!rawTag) htmlError('Expected an element name.') + const tag = normalizeTag(rawTag) + const attributes: Record = Object.create(null) as Record + let selfClosing = false + while (true) { + this.skipWhitespace() + if (this.peek('>')) { + this.index += 1 + break + } + if (this.peek('/>')) { + this.index += 2 + selfClosing = true + break + } + const name = this.readName() + if (!name) htmlError(`Malformed attribute on <${tag}>.`) + if (name in attributes) htmlError(`Duplicate attribute "${name}" on <${tag}>.`) + this.skipWhitespace() + this.expect('=') + this.skipWhitespace() + const quote = this.source[this.index] + if (quote !== '"' && quote !== "'") { + htmlError(`Attribute "${name}" must use a quoted value.`) + } + this.index += 1 + const end = this.source.indexOf(quote, this.index) + if (end < 0) htmlError(`Attribute "${name}" has an unterminated value.`) + attributes[name] = decodeEntities(this.source.slice(this.index, end)) + this.index = end + 1 + } + if (selfClosing) return { attributes, children: [], tag, text: '' } + + const children: CanvasMarkupElement[] = [] + let text = '' + while (true) { + if (this.index >= this.source.length) htmlError(`Missing closing .`) + if (this.source.startsWith(', found .`) + this.skipWhitespace() + this.expect('>') + break + } + if (this.peek('<')) { + children.push(this.parseElement(depth + 1)) + } else { + const end = this.source.indexOf('<', this.index) + const textEnd = end < 0 ? this.source.length : end + text += decodeEntities(this.source.slice(this.index, textEnd)) + this.index = textEnd + } + } + return { attributes, children, tag, text } + } + + private expect(value: string): void { + if (!this.source.startsWith(value, this.index)) { + htmlError(`Expected "${value}" at character ${this.index}.`) + } + this.index += value.length + } + + private peek(value: string): boolean { + return this.source.startsWith(value, this.index) + } + + private readName(): string { + const start = this.index + while (this.index < this.source.length && /[A-Za-z0-9:-]/.test(this.source[this.index]!)) { + this.index += 1 + } + return this.source.slice(start, this.index) + } + + private skipWhitespace(): void { + while (this.index < this.source.length && /\s/.test(this.source[this.index]!)) { + this.index += 1 + } + } +} + +export function parseCanvasHtml(source: string): CanvasMarkupElement { + return new CanvasHtmlParser(source).parse() +} diff --git a/packages/extension/mcp/tools/canvas/identity.ts b/packages/extension/mcp/tools/canvas/identity.ts new file mode 100644 index 00000000..e1df5ae3 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/identity.ts @@ -0,0 +1,72 @@ +import type { CanvasDesignReference } from '@tempad-dev/shared' + +import { specError } from './errors' + +export const CANVAS_KEY_NAMESPACE = 'tempad_dev' +export const CANVAS_NODE_KEY_NAME = 'canvas-key' +export const CANVAS_PAGE_KEY_NAME = 'page-key' +export const CANVAS_STYLE_KEY_NAME = 'style-key' +export const CANVAS_VARIABLE_COLLECTION_KEY_NAME = 'variable-collection-key' +export const CANVAS_VARIABLE_KEY_NAME = 'variable-key' +export const CANVAS_VARIABLE_MODE_KEYS_NAME = 'variable-mode-keys' + +export type MutationCounter = { count: number } + +export function designReferenceCacheKey(reference: CanvasDesignReference): string { + return reference.id !== undefined ? `id:${reference.id}` : `key:${reference.key}` +} + +export function readAuthoringKey( + resource: { + getSharedPluginData?: (namespace: string, key: string) => string + }, + name: string +): string | undefined { + const key = resource.getSharedPluginData?.(CANVAS_KEY_NAMESPACE, name) + return key || undefined +} + +export function claimAuthoringKey( + resource: { + id: string + getSharedPluginData: (namespace: string, key: string) => string + setSharedPluginData: (namespace: string, key: string, value: string) => void + }, + key: string, + name: string, + label: string, + mutations: MutationCounter +): void { + const current = readAuthoringKey(resource, name) + if (current === key) return + if (current) { + specError(`${label} "${resource.id}" is already owned by authoring key "${current}".`) + } + resource.setSharedPluginData(CANVAS_KEY_NAMESPACE, name, key) + mutations.count += 1 +} + +export function parseVariableModeKeys( + raw: string, + modes: readonly { modeId: string }[] +): Map | null { + if (!raw) return new Map() + try { + const parsed: unknown = JSON.parse(raw) + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + Object.entries(parsed).some(([key, value]) => !key || typeof value !== 'string' || !value) + ) { + return null + } + const liveIds = new Set(modes.map((mode) => mode.modeId)) + const keys = new Map( + Object.entries(parsed as Record).filter(([, id]) => liveIds.has(id)) + ) + return new Set(keys.values()).size === keys.size ? keys : null + } catch { + return null + } +} diff --git a/packages/extension/mcp/tools/canvas/index.ts b/packages/extension/mcp/tools/canvas/index.ts new file mode 100644 index 00000000..2b175452 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/index.ts @@ -0,0 +1,73 @@ +import type { + ApplyCanvasParametersInput, + ApplyCanvasResult, + CanvasResolvedApplyParameters +} from '@tempad-dev/shared' + +import { ApplyCanvasParametersSchema, TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' + +import type { DesignSystemCatalog } from '../design-system-catalog' + +import { createCodedError } from '../../errors' +import { formatSchemaError, specError } from './errors' +import { parseCanvasMarkup } from './markup' +import { reconcileCanvas } from './reconcile' +import { resolveCanvasInput } from './resolve' + +let applyInProgress = false + +function parseSpec(parse: () => Result, fallback = 'Canvas input is invalid.'): Result { + try { + return parse() + } catch (error) { + specError(typeof error === 'string' ? error : error instanceof Error ? error.message : fallback) + } +} + +function assertCanvasAvailable(): void { + if (figma.editorType !== 'figma') { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_UNSUPPORTED_EDITOR, + 'Canvas authoring is supported only in Figma Design files.' + ) + } + if (applyInProgress) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_BUSY, + 'Another apply_canvas call is already running in this Figma session.' + ) + } +} + +export async function applyResolvedCanvas( + input: CanvasResolvedApplyParameters, + catalog?: DesignSystemCatalog +): Promise { + const parsedInput = parseSpec( + () => parseCanvasMarkup(input, catalog), + 'Canvas markup is invalid.' + ) + + applyInProgress = true + try { + return await reconcileCanvas(parsedInput) + } catch (error) { + if (error instanceof Error && 'code' in error) throw error + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + error instanceof Error ? error.message : 'Canvas apply failed.' + ) + } finally { + applyInProgress = false + } +} + +export async function handleApplyCanvas( + args?: ApplyCanvasParametersInput +): Promise { + assertCanvasAvailable() + const parsed = ApplyCanvasParametersSchema.safeParse(args) + if (!parsed.success) specError(formatSchemaError(parsed.error)) + const resolved = parseSpec(() => resolveCanvasInput(parsed.data)) + return applyResolvedCanvas(resolved.input, resolved.catalog) +} diff --git a/packages/extension/mcp/tools/canvas/markup.ts b/packages/extension/mcp/tools/canvas/markup.ts new file mode 100644 index 00000000..a583594c --- /dev/null +++ b/packages/extension/mcp/tools/canvas/markup.ts @@ -0,0 +1,1646 @@ +import type { + CanvasAssets, + CanvasBinding, + CanvasFigmaPaint, + CanvasResolvedApplyParameters, + CanvasStyleReference, + CanvasStyleBindings, + CanvasVariableReference, + CanvasVariableBindings +} from '@tempad-dev/shared' + +import { CanvasStableKeySchema, MAX_CANVAS_DEPTH, MAX_CANVAS_NODES } from '@tempad-dev/shared' + +import type { CatalogComponent, DesignSystemCatalog } from '../design-system-catalog' +import type { CanvasMarkupElement } from './html' +import type { + CanvasNodeSpec, + CanvasShapeNodeType, + CanvasSizingMode, + ParsedCanvasInput +} from './model' +import type { CanvasClasses } from './tailwind' + +import { parseCanvasHtml } from './html' +import { MAX_GRID_TRACKS, parseCanvasClasses } from './tailwind' + +const ALLOWED_ATTRIBUTES = new Set(['class', 'data-key', 'data-node-id']) +const SIZE_VARIABLE_FIELDS = [ + 'width', + 'height', + 'minWidth', + 'maxWidth', + 'minHeight', + 'maxHeight' +] as const +const SIZE_BOUND_FIELDS = ['minWidth', 'maxWidth', 'minHeight', 'maxHeight'] as const +const STROKE_SIDE_VARIABLE_FIELDS = [ + 'strokeTopWeight', + 'strokeRightWeight', + 'strokeBottomWeight', + 'strokeLeftWeight' +] as const +const CORNER_SIDE_VARIABLE_FIELDS = [ + 'topLeftRadius', + 'topRightRadius', + 'bottomRightRadius', + 'bottomLeftRadius' +] as const +const FRAME_VARIABLE_FIELDS = new Set([ + 'fill', + 'stroke', + 'visible', + ...SIZE_VARIABLE_FIELDS, + 'gap', + 'counterAxisSpacing', + 'gridRowGap', + 'gridColumnGap', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'cornerRadius', + ...CORNER_SIDE_VARIABLE_FIELDS, + 'strokeWeight', + ...STROKE_SIDE_VARIABLE_FIELDS, + 'opacity' +]) +const TEXT_VARIABLE_FIELDS = new Set([ + 'fill', + 'characters', + 'visible', + ...SIZE_VARIABLE_FIELDS, + 'strokeWeight', + 'opacity', + 'fontFamily', + 'fontStyle', + 'fontWeight', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'paragraphIndent', + 'paragraphSpacing' +]) +const INSTANCE_VARIABLE_FIELDS = new Set([ + 'visible', + ...SIZE_VARIABLE_FIELDS, + 'cornerRadius', + ...CORNER_SIDE_VARIABLE_FIELDS, + 'strokeWeight', + ...STROKE_SIDE_VARIABLE_FIELDS, + 'opacity' +]) +const SECTION_VARIABLE_FIELDS = new Set([ + 'fill', + 'stroke', + 'visible', + 'width', + 'height', + 'cornerRadius', + ...CORNER_SIDE_VARIABLE_FIELDS, + 'strokeWeight' +]) +const GROUP_VARIABLE_FIELDS = new Set(['visible', 'opacity']) +const BOOLEAN_OPERATION_VARIABLE_FIELDS = new Set([ + 'fill', + 'stroke', + 'visible', + 'cornerRadius', + 'strokeWeight', + 'opacity' +]) +const BASE_SHAPE_VARIABLE_FIELDS = [ + 'fill', + 'stroke', + 'visible', + ...SIZE_VARIABLE_FIELDS, + 'opacity' +] as const satisfies ReadonlyArray +const RECTANGLE_VARIABLE_FIELDS = new Set([ + ...BASE_SHAPE_VARIABLE_FIELDS, + 'cornerRadius', + ...CORNER_SIDE_VARIABLE_FIELDS, + 'strokeWeight', + ...STROKE_SIDE_VARIABLE_FIELDS +]) +const LINE_VARIABLE_FIELDS = new Set([ + ...BASE_SHAPE_VARIABLE_FIELDS, + 'strokeWeight' +]) +const ROUND_SHAPE_VARIABLE_FIELDS = new Set([ + ...BASE_SHAPE_VARIABLE_FIELDS, + 'cornerRadius', + 'strokeWeight' +]) +const VARIABLE_FIELDS = { + BOOLEAN_OPERATION: BOOLEAN_OPERATION_VARIABLE_FIELDS, + COMPONENT: FRAME_VARIABLE_FIELDS, + COMPONENT_SET: FRAME_VARIABLE_FIELDS, + FRAME: FRAME_VARIABLE_FIELDS, + GROUP: GROUP_VARIABLE_FIELDS, + TEXT: TEXT_VARIABLE_FIELDS, + INSTANCE: INSTANCE_VARIABLE_FIELDS, + SECTION: SECTION_VARIABLE_FIELDS, + SLOT: FRAME_VARIABLE_FIELDS, + RECTANGLE: RECTANGLE_VARIABLE_FIELDS, + LINE: LINE_VARIABLE_FIELDS, + ELLIPSE: ROUND_SHAPE_VARIABLE_FIELDS, + POLYGON: ROUND_SHAPE_VARIABLE_FIELDS, + STAR: ROUND_SHAPE_VARIABLE_FIELDS, + VECTOR: ROUND_SHAPE_VARIABLE_FIELDS +} satisfies Record> +const SHAPE_STYLE_FIELDS = new Set(['fill', 'stroke', 'effect']) +const FRAME_STYLE_FIELDS = new Set(['fill', 'stroke', 'effect', 'grid']) +const STYLE_FIELDS = { + BOOLEAN_OPERATION: SHAPE_STYLE_FIELDS, + COMPONENT: FRAME_STYLE_FIELDS, + COMPONENT_SET: FRAME_STYLE_FIELDS, + FRAME: FRAME_STYLE_FIELDS, + GROUP: new Set(['effect']), + TEXT: new Set(['fill', 'stroke', 'text', 'effect']), + INSTANCE: FRAME_STYLE_FIELDS, + SECTION: new Set(['fill', 'stroke']), + SLOT: FRAME_STYLE_FIELDS, + RECTANGLE: SHAPE_STYLE_FIELDS, + LINE: SHAPE_STYLE_FIELDS, + ELLIPSE: SHAPE_STYLE_FIELDS, + POLYGON: SHAPE_STYLE_FIELDS, + STAR: SHAPE_STYLE_FIELDS, + VECTOR: SHAPE_STYLE_FIELDS +} satisfies Record> +const VARIABLE_ATTRIBUTES = new Map( + [...new Set(Object.values(VARIABLE_FIELDS).flatMap((fields) => [...fields]))].map((field) => [ + `data-var-${field.replaceAll(/[A-Z]/g, (character) => `-${character.toLowerCase()}`)}`, + field + ]) +) +const STYLE_ATTRIBUTES = new Map( + [...new Set(Object.values(STYLE_FIELDS).flatMap((fields) => [...fields]))].map((field) => [ + `data-style-${field}`, + field + ]) +) + +function isInlineBindingAttribute(name: string): boolean { + return VARIABLE_ATTRIBUTES.has(name) || STYLE_ATTRIBUTES.has(name) +} + +function markupError(message: string): never { + throw new Error(message) +} + +function componentPropertyEntry( + component: CatalogComponent, + name: string, + value: string, + catalog: DesignSystemCatalog +): [string, NonNullable[string]] { + const property = Object.hasOwn(component.properties, name) + ? component.properties[name] + : undefined + if (!property) markupError(`Unsupported property "${name}" on <${component.tag}>.`) + if (property.type === 'boolean') { + if (value !== 'true' && value !== 'false') { + markupError(`Boolean property "${name}" on <${component.tag}> must be true or false.`) + } + return [property.name, value === 'true'] + } + if (property.type === 'instance') { + const replacement = catalog.entries.get(value) + const reference = + replacement?.kind === 'component' + ? replacement.reference + : catalog.componentReferences.get(value) + if (!reference) { + markupError(`Instance property "${name}" on <${component.tag}> requires a component ref.`) + } + if (!reference.id) { + markupError(`Component ref "${value}" is not materialized in the current file.`) + } + return [property.name, reference.id] + } + if (property.type === 'variant' && property.options && !property.options.includes(value)) { + markupError(`Property "${name}" on <${component.tag}> has no variant "${value}".`) + } + return [property.name, value] +} + +function normalizeCatalogElement( + element: CanvasMarkupElement, + bindings: Record, + catalog: DesignSystemCatalog | undefined +): CanvasMarkupElement { + if (element.tag === 'div' || element.tag === 'span') { + if (element.attributes['data-ref']) { + markupError(`data-ref is only valid on a catalog component tag.`) + } + return { + ...element, + children: element.children.map((child) => normalizeCatalogElement(child, bindings, catalog)) + } + } + if (!catalog) markupError(`Catalog component <${element.tag}> requires catalogId.`) + const component = catalog.tags.get(element.tag) + if (!component) { + markupError(`Unknown component tag <${element.tag}> in catalog "${catalog.id}".`) + } + if (element.children.length || hasText(element.text)) { + markupError(`Catalog component <${element.tag}> must be childless.`) + } + if (element.attributes['data-ref'] !== component.ref) { + markupError(`<${element.tag}> requires data-ref="${component.ref}".`) + } + const keyResult = CanvasStableKeySchema.safeParse(element.attributes['data-key']) + if (!keyResult.success) { + markupError(`Catalog component <${element.tag}> requires a valid, stable data-key.`) + } + const key = keyResult.data + const properties = Object.fromEntries( + Object.entries(element.attributes) + .filter( + ([name]) => + !['class', 'data-key', 'data-node-id', 'data-ref'].includes(name) && + !isInlineBindingAttribute(name) + ) + .map(([name, value]) => componentPropertyEntry(component, name, value, catalog)) + ) + const existing = bindings[key] + bindings[key] = { + ...(existing ?? {}), + component: component.reference, + ...(Object.keys(properties).length ? { componentProperties: properties } : {}) + } + const classes = parseCanvasClasses(element.attributes.class ?? '') + const className = [ + element.attributes.class, + classes.width ? undefined : `w-[${component.nativeSize.width}px]`, + classes.height ? undefined : `h-[${component.nativeSize.height}px]` + ] + .filter(Boolean) + .join(' ') + return { + tag: 'div', + text: '', + children: [], + attributes: { + 'data-key': key, + ...(element.attributes['data-node-id'] + ? { 'data-node-id': element.attributes['data-node-id'] } + : {}), + ...Object.fromEntries( + Object.entries(element.attributes).filter(([name]) => isInlineBindingAttribute(name)) + ), + class: className + } + } +} + +function hasText(value: string): boolean { + return /[^\t\n\f\r ]/.test(value) +} + +function textContent(value: string, preserve: boolean): string { + return preserve ? value : value.replace(/[\t\n\f\r ]+/g, ' ').trim() +} + +function textAutoResize( + horizontal: CanvasSizingMode, + vertical: CanvasSizingMode +): NonNullable['autoResize'] { + return horizontal === 'HUG' ? 'WIDTH_AND_HEIGHT' : vertical === 'HUG' ? 'HEIGHT' : 'NONE' +} + +const SHAPE_TYPES = new Set([ + 'RECTANGLE', + 'LINE', + 'ELLIPSE', + 'POLYGON', + 'STAR', + 'VECTOR' +]) + +function isShapeType(type: CanvasNodeSpec['type']): type is CanvasShapeNodeType { + return SHAPE_TYPES.has(type as CanvasShapeNodeType) +} + +function isFrameContainerType( + type: CanvasNodeSpec['type'] +): type is 'COMPONENT' | 'COMPONENT_SET' | 'FRAME' | 'SLOT' { + return type === 'COMPONENT' || type === 'COMPONENT_SET' || type === 'FRAME' || type === 'SLOT' +} + +function hasShapeAppearance(type: CanvasNodeSpec['type']): boolean { + return type === 'BOOLEAN_OPERATION' || isShapeType(type) +} + +function isIntrinsicContainer(type: CanvasNodeSpec['type']): boolean { + return type === 'BOOLEAN_OPERATION' || type === 'GROUP' +} + +function hasFields(value: object): boolean { + return Object.keys(value).length > 0 +} + +function nodeType( + element: CanvasMarkupElement, + binding: CanvasBinding | undefined +): CanvasNodeSpec['type'] { + if (element.tag === 'span') return 'TEXT' + if (binding?.component) return 'INSTANCE' + if (binding?.figma?.component) return binding.figma.component.type + if (binding?.figma?.slot) return 'SLOT' + if (binding?.figma?.section) return 'SECTION' + if (binding?.figma?.group) return 'GROUP' + if (binding?.figma?.booleanOperation) return 'BOOLEAN_OPERATION' + return binding?.figma?.shape?.type ?? 'FRAME' +} + +function hasVariable( + variables: CanvasVariableBindings | undefined, + fields: ReadonlyArray +): boolean { + return fields.some((field) => variables?.[field] != null) +} + +function hasStrokeWeight(binding: CanvasBinding | undefined, classes: CanvasClasses): boolean { + return ( + classes.strokeWeight !== undefined || + hasFields(classes.strokeWeights) || + binding?.figma?.stroke?.weight !== undefined || + binding?.figma?.stroke?.weights !== undefined || + binding?.variables?.strokeWeight != null || + hasVariable(binding?.variables, STROKE_SIDE_VARIABLE_FIELDS) + ) +} + +function validateAttributes(element: CanvasMarkupElement): { + className: string + key: string + nodeId?: string +} { + for (const name of Object.keys(element.attributes)) { + if (!ALLOWED_ATTRIBUTES.has(name) && !isInlineBindingAttribute(name)) { + markupError(`Unsupported attribute "${name}" on <${element.tag}>.`) + } + } + const key = element.attributes['data-key'] + const parsedKey = CanvasStableKeySchema.safeParse(key) + if (!parsedKey.success) { + markupError('Every element requires a valid, stable data-key.') + } + const nodeId = element.attributes['data-node-id']?.trim() + if (nodeId !== undefined && (!nodeId || nodeId.length > 200)) { + markupError(`data-node-id on "${key}" must be a non-empty Figma node ID.`) + } + return { + className: element.attributes.class ?? '', + key: parsedKey.data, + ...(nodeId === undefined ? {} : { nodeId }) + } +} + +function validateVariables( + key: string, + type: CanvasNodeSpec['type'], + binding: CanvasBinding | undefined, + classes: CanvasClasses +): void { + const variables = binding?.variables + if (!variables) return + const allowed = VARIABLE_FIELDS[type] + for (const field of Object.keys(variables) as Array) { + if (!allowed.has(field)) { + markupError(`Variable field "${field}" is not supported on ${type} node "${key}".`) + } + if (variables[field] === null) continue + if (field === 'width' && classes.width?.mode !== 'FIXED') { + markupError(`Width variable on "${key}" requires a fixed width fallback.`) + } + if (field === 'height' && classes.height?.mode !== 'FIXED') { + markupError(`Height variable on "${key}" requires a fixed height fallback.`) + } + if (field === 'gap' && !classes.direction) { + markupError(`Variable field "${field}" requires flex layout on "${key}".`) + } + if (field.startsWith('padding') && !classes.direction && !classes.grid) { + markupError(`Variable field "${field}" requires auto layout on "${key}".`) + } + if (field === 'counterAxisSpacing' && classes.wrap !== 'WRAP') { + markupError(`Variable field "${field}" requires flex-wrap on "${key}".`) + } + if ((field === 'gridRowGap' || field === 'gridColumnGap') && !classes.grid) { + markupError(`Variable field "${field}" requires grid layout on "${key}".`) + } + } + if ( + variables.fill && + (isFrameContainerType(type) || type === 'SECTION' || hasShapeAppearance(type)) && + !classes.fill + ) { + markupError(`Fill variable on "${key}" requires a solid bg-[#RRGGBB] fallback.`) + } + if ( + variables.stroke && + (isFrameContainerType(type) || type === 'SECTION' || hasShapeAppearance(type)) && + (!classes.stroke || !hasStrokeWeight(binding, classes)) + ) { + markupError(`Stroke variable on "${key}" requires border width and color fallbacks.`) + } +} + +function validateStyles( + key: string, + type: CanvasNodeSpec['type'], + binding: CanvasBinding | undefined, + classes: CanvasClasses +): void { + const styles = binding?.styles + if (!styles) return + const variables = binding.variables + for (const field of Object.keys(styles) as Array) { + if (!STYLE_FIELDS[type].has(field)) { + markupError(`Style field "${field}" is not supported on ${type} node "${key}".`) + } + } + if (styles.fill && variables?.fill !== undefined) { + markupError(`Fill style and variable bindings cannot be combined on "${key}".`) + } + if (styles.stroke && variables?.stroke !== undefined) { + markupError(`Stroke style and variable bindings cannot be combined on "${key}".`) + } + if ( + styles.stroke && + (isFrameContainerType(type) || type === 'SECTION' || hasShapeAppearance(type)) && + !hasStrokeWeight(binding, classes) + ) { + markupError(`Stroke style on "${key}" requires border weight fallback.`) + } +} + +function validateEffects( + key: string, + type: CanvasNodeSpec['type'], + binding: CanvasBinding | undefined +): void { + const effects = binding?.figma?.effects + if (effects === undefined) return + if (binding?.styles?.effect) { + markupError(`Direct effects and an effect style cannot be combined on "${key}".`) + } + if (type === 'SECTION') { + markupError(`Direct effects are not supported on SECTION node "${key}".`) + } + if ( + !isFrameContainerType(type) && + type !== 'INSTANCE' && + type !== 'RECTANGLE' && + type !== 'ELLIPSE' && + effects.some( + (effect) => + (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') && + (effect.spread !== undefined || effect.variables?.spread !== undefined) + ) + ) { + markupError(`Shadow spread is not supported on ${type} node "${key}".`) + } +} + +function validatePaints( + key: string, + type: CanvasNodeSpec['type'], + binding: CanvasBinding | undefined, + classes: CanvasClasses +): void { + for (const [field, paints] of [ + ['fill', binding?.figma?.fills], + ['stroke', binding?.figma?.strokes] + ] as const) { + if (paints === undefined) continue + if (type === 'GROUP') { + markupError(`Direct ${field} paints are not supported on GROUP node "${key}".`) + } + if (binding?.styles?.[field]) { + markupError(`Direct ${field} paints and a ${field} style cannot be combined on "${key}".`) + } + if (binding?.variables?.[field] !== undefined) { + markupError(`Direct ${field} paints and a ${field} variable cannot be combined on "${key}".`) + } + if (classes[field] !== undefined) { + markupError(`Direct ${field} paints and a literal ${field} cannot be combined on "${key}".`) + } + } +} + +function validateFigmaLayout( + key: string, + type: CanvasNodeSpec['type'], + binding: CanvasBinding | undefined, + classes: CanvasClasses +): void { + const properties = binding?.figma + const autoLayout = properties?.autoLayout + if (autoLayout) { + if (!isFrameContainerType(type) || !classes.flex) { + markupError(`Figma Auto Layout properties on "${key}" require a flex frame container.`) + } + const mainGap = classes.direction === 'HORIZONTAL' ? classes.columnGap : classes.rowGap + if ( + autoLayout.itemSpacing !== undefined && + (classes.gap !== undefined || mainGap !== undefined) + ) { + markupError(`Main-axis spacing on "${key}" cannot use both classes and Figma properties.`) + } + if (autoLayout.counterAxisSpacing !== undefined) { + if (classes.wrap !== 'WRAP') { + markupError(`Figma counter-axis spacing on "${key}" requires flex-wrap.`) + } + const counterGap = classes.direction === 'HORIZONTAL' ? classes.rowGap : classes.columnGap + if (classes.gap !== undefined || counterGap !== undefined) { + markupError( + `Counter-axis spacing on "${key}" cannot use both classes and Figma properties.` + ) + } + if (autoLayout.counterAxisSpacing === null && binding?.variables?.counterAxisSpacing) { + markupError( + `Synchronized counter-axis spacing and a counter-axis variable cannot be combined on "${key}".` + ) + } + } + } + + if (properties?.layoutGrids !== undefined || properties?.guides !== undefined) { + if (!isFrameContainerType(type) && type !== 'INSTANCE') { + markupError(`Layout grids and guides are not supported on ${type} node "${key}".`) + } + } + if (properties?.layoutGrids !== undefined && binding?.styles?.grid) { + markupError(`Direct layout grids and a grid style cannot be combined on "${key}".`) + } +} + +function validateTextRanges(key: string, characters: string, binding: CanvasBinding | undefined) { + for (const [index, range] of (binding?.figma?.text?.ranges ?? []).entries()) { + if (range.end > characters.length) { + markupError( + `Text range ${index} on "${key}" ends at ${range.end}, beyond its ${characters.length} UTF-16 code units.` + ) + } + } +} + +function validateTextFont( + key: string, + binding: CanvasBinding | undefined, + classes: CanvasClasses +): void { + if (!binding?.figma?.text?.fontName) return + if (classes.fontFamily !== undefined || classes.fontStyle !== undefined) { + markupError(`Font on "${key}" cannot use both classes and an exact Figma font name.`) + } + if (binding.variables?.fontFamily || binding.variables?.fontStyle) { + markupError(`Font on "${key}" cannot use both variables and an exact Figma font name.`) + } + if (binding.styles?.text) { + markupError(`Font on "${key}" cannot use both a Text style and an exact Figma font name.`) + } +} + +type CompileState = { + bindings: Record + catalog?: DesignSystemCatalog + count: number + keys: Set + mode: CanvasResolvedApplyParameters['mode'] + nodeIds: Set +} + +function applyInlineBindings(element: CanvasMarkupElement, key: string, state: CompileState): void { + let binding = state.bindings[key] + for (const [attribute, ref] of Object.entries(element.attributes)) { + const variableField = VARIABLE_ATTRIBUTES.get(attribute) + const styleField = STYLE_ATTRIBUTES.get(attribute) + if (!variableField && !styleField) continue + const field = variableField ?? styleField! + const values = variableField ? binding?.variables : binding?.styles + if (values && field in values) { + markupError(`Binding "${field}" on "${key}" is declared more than once.`) + } + let reference: CanvasStyleReference | CanvasVariableReference | null + if (ref === 'none') { + reference = null + } else { + if (!state.catalog) markupError(`Design-system ref "${ref}" requires catalogId.`) + const entry = state.catalog.entries.get(ref) + const kind = variableField ? 'variable' : 'style' + if (!entry) { + markupError(`Unknown design-system ref "${ref}" in catalog "${state.catalog.id}".`) + } + if (entry.kind !== kind) { + markupError(`Design-system ref "${ref}" is ${entry.kind}, not ${kind}.`) + } + if (!('reference' in entry)) { + markupError(`Design-system ref "${ref}" cannot be applied as a binding.`) + } + reference = entry.reference + } + binding = variableField + ? { + ...(binding ?? {}), + variables: { + ...binding?.variables, + [variableField]: reference as CanvasVariableReference | null + } + } + : { + ...(binding ?? {}), + styles: { + ...binding?.styles, + [styleField!]: reference as CanvasStyleReference | null + } + } + } + if (binding) state.bindings[key] = binding +} + +function validateSizeBounds( + key: string, + axis: 'height' | 'width', + size: NonNullable, + min: number | null | undefined, + max: number | null | undefined +): void { + if (min !== undefined && min !== null && max !== undefined && max !== null && min > max) { + markupError(`min-${axis} on "${key}" cannot exceed max-${axis}.`) + } + const value = size.value + if (size.mode !== 'FIXED' || value === undefined) return + if (min !== undefined && min !== null && value < min) { + markupError(`${axis} on "${key}" cannot be smaller than its minimum.`) + } + if (max !== undefined && max !== null && value > max) { + markupError(`${axis} on "${key}" cannot exceed its maximum.`) + } +} + +type GridPlacement = { + columns: number + rows: number + manual: boolean + occupied: Set +} + +function gridAreaFits( + placement: GridPlacement, + row: number, + column: number, + rowSpan: number, + columnSpan: number +): boolean { + if (column + columnSpan > placement.columns || row + rowSpan > placement.rows) { + return false + } + for (let currentRow = row; currentRow < row + rowSpan; currentRow += 1) { + for (let currentColumn = column; currentColumn < column + columnSpan; currentColumn += 1) { + if (placement.occupied.has(`${currentRow}:${currentColumn}`)) return false + } + } + return true +} + +function occupyGridArea( + placement: GridPlacement, + row: number, + column: number, + rowSpan: number, + columnSpan: number +): void { + for (let currentRow = row; currentRow < row + rowSpan; currentRow += 1) { + for (let currentColumn = column; currentColumn < column + columnSpan; currentColumn += 1) { + placement.occupied.add(`${currentRow}:${currentColumn}`) + } + } +} + +function placeGridChild( + key: string, + classes: CanvasClasses, + placement: GridPlacement +): NonNullable { + const rowSpan = classes.gridRowSpan ?? 1 + const columnSpan = classes.gridColumnSpan ?? 1 + const hasRow = classes.gridRow !== undefined + const hasColumn = classes.gridColumn !== undefined + if (hasRow !== hasColumn) { + markupError(`Grid child "${key}" must provide both row-start and col-start.`) + } + if (!placement.manual && hasRow) { + markupError(`Grid child "${key}" cannot use explicit placement with grid-flow-row.`) + } + + let row = classes.gridRow + let column = classes.gridColumn + if (row === undefined || column === undefined) { + for (let candidateRow = 0; candidateRow < placement.rows; candidateRow += 1) { + for (let candidateColumn = 0; candidateColumn < placement.columns; candidateColumn += 1) { + if (gridAreaFits(placement, candidateRow, candidateColumn, rowSpan, columnSpan)) { + row = candidateRow + column = candidateColumn + break + } + } + if (row !== undefined) break + } + } + if ( + row === undefined || + column === undefined || + !gridAreaFits(placement, row, column, rowSpan, columnSpan) + ) { + markupError(`Grid child "${key}" does not fit in an unoccupied grid area.`) + } + occupyGridArea(placement, row, column, rowSpan, columnSpan) + + return { + ...(placement.manual ? { row, column } : {}), + rowSpan, + columnSpan, + horizontalAlign: classes.gridHorizontalAlign ?? 'AUTO', + verticalAlign: classes.gridVerticalAlign ?? 'AUTO' + } +} + +function strokeAppearance( + binding: CanvasBinding | undefined, + classes: CanvasClasses, + includeDefault: boolean +): Partial> { + const stroke = binding?.figma?.stroke + const variables = binding?.variables + const individual = + stroke?.weights !== undefined || + hasFields(classes.strokeWeights) || + hasVariable(variables, STROKE_SIDE_VARIABLE_FIELDS) + const uniform = stroke?.weight ?? classes.strokeWeight ?? 0 + if (individual) { + return { + strokeTopWeight: stroke?.weights?.top ?? classes.strokeWeights.top ?? uniform, + strokeRightWeight: stroke?.weights?.right ?? classes.strokeWeights.right ?? uniform, + strokeBottomWeight: stroke?.weights?.bottom ?? classes.strokeWeights.bottom ?? uniform, + strokeLeftWeight: stroke?.weights?.left ?? classes.strokeWeights.left ?? uniform + } + } + const weight = stroke?.weight ?? classes.strokeWeight + return weight === undefined && !includeDefault ? {} : { strokeWeight: weight ?? 0 } +} + +function cornerAppearance( + binding: CanvasBinding | undefined, + classes: CanvasClasses, + includeDefault: boolean +): Partial> { + const corners = binding?.figma?.corners + const variables = binding?.variables + const individual = + corners?.radii !== undefined || + hasFields(classes.cornerRadii) || + hasVariable(variables, CORNER_SIDE_VARIABLE_FIELDS) + const uniform = corners?.radius ?? classes.cornerRadius ?? 0 + if (individual) { + return { + topLeftRadius: corners?.radii?.topLeft ?? classes.cornerRadii.topLeft ?? uniform, + topRightRadius: corners?.radii?.topRight ?? classes.cornerRadii.topRight ?? uniform, + bottomRightRadius: corners?.radii?.bottomRight ?? classes.cornerRadii.bottomRight ?? uniform, + bottomLeftRadius: corners?.radii?.bottomLeft ?? classes.cornerRadii.bottomLeft ?? uniform + } + } + const radius = corners?.radius ?? classes.cornerRadius + return radius === undefined && !includeDefault ? {} : { cornerRadius: radius ?? 0 } +} + +function fillStrokeAppearance( + binding: CanvasBinding | undefined, + classes: CanvasClasses +): Partial> { + return { + ...(binding?.figma?.fills !== undefined || classes.fill === undefined + ? {} + : { fill: classes.fill }), + ...(binding?.figma?.strokes !== undefined || classes.stroke === undefined + ? {} + : { stroke: classes.stroke }), + ...strokeAppearance(binding, classes, false), + ...cornerAppearance(binding, classes, false) + } +} + +function compileElement( + element: CanvasMarkupElement, + state: CompileState, + depth: number, + parent?: CanvasNodeSpec, + gridPlacement?: GridPlacement, + insideComponent = false +): CanvasNodeSpec { + state.count += 1 + if (state.count > MAX_CANVAS_NODES) { + markupError(`Canvas markup may contain at most ${MAX_CANVAS_NODES} elements.`) + } + if (depth > MAX_CANVAS_DEPTH) { + markupError(`Canvas markup may be at most ${MAX_CANVAS_DEPTH} levels deep.`) + } + + const { className, key, nodeId } = validateAttributes(element) + if (state.keys.has(key)) markupError(`Duplicate data-key "${key}".`) + state.keys.add(key) + if (nodeId) { + if (state.mode === 'create') { + markupError(`Create mode cannot use data-node-id on "${key}".`) + } + if (state.nodeIds.has(nodeId)) markupError(`Duplicate data-node-id "${nodeId}".`) + state.nodeIds.add(nodeId) + } + + applyInlineBindings(element, key, state) + const classes = parseCanvasClasses(className) + if (!classes.width || !classes.height) { + markupError(`Element "${key}" requires exactly one width and one height class.`) + } + if ( + classes.gap !== undefined && + (classes.columnGap !== undefined || classes.rowGap !== undefined) + ) { + markupError(`Element "${key}" cannot combine gap-[Npx] with gap-x/y-[Npx].`) + } + + const binding = state.bindings[key] + const shapeType = binding?.figma?.shape?.type + const type = nodeType(element, binding) + const nativeStroke = binding?.figma?.stroke + const nativeCorners = binding?.figma?.corners + const hasStrokeClasses = classes.strokeWeight !== undefined || hasFields(classes.strokeWeights) + const hasCornerClasses = classes.cornerRadius !== undefined || hasFields(classes.cornerRadii) + const characters = + element.tag === 'span' ? textContent(element.text, !!classes.preserveWhitespace) : '' + + if ((nativeStroke?.weight !== undefined || nativeStroke?.weights) && hasStrokeClasses) { + markupError(`Stroke weights on "${key}" cannot use both classes and Figma properties.`) + } + if ((nativeCorners?.radius !== undefined || nativeCorners?.radii) && hasCornerClasses) { + markupError(`Corner radii on "${key}" cannot use both classes and Figma properties.`) + } + if ( + (hasFields(classes.strokeWeights) || nativeStroke?.weights) && + !isFrameContainerType(type) && + type !== 'INSTANCE' && + type !== 'RECTANGLE' + ) { + markupError(`Individual stroke weights are not supported on ${type} node "${key}".`) + } + if ( + hasFields(classes.cornerRadii) && + !isFrameContainerType(type) && + type !== 'SECTION' && + type !== 'RECTANGLE' + ) { + markupError(`Individual corner classes are not supported on ${type} node "${key}".`) + } + if (nativeCorners && (type === 'TEXT' || type === 'LINE')) { + markupError(`Figma corner properties are not supported on ${type} node "${key}".`) + } + if ( + nativeCorners?.radii && + !isFrameContainerType(type) && + type !== 'INSTANCE' && + type !== 'SECTION' && + type !== 'RECTANGLE' + ) { + markupError(`Individual corner radii are not supported on ${type} node "${key}".`) + } + + if (element.tag === 'span') { + if (element.children.length) markupError(`span "${key}" cannot contain elements.`) + if (classes.frameClass || classes.layoutClass) { + markupError( + `Class "${classes.frameClass ?? classes.layoutClass}" is not supported on span "${key}".` + ) + } + if (binding?.component) markupError(`Component binding "${key}" requires a childless div.`) + if (shapeType) markupError(`Native shape binding "${key}" requires a childless div.`) + if (binding?.figma?.section) markupError(`Native section binding "${key}" requires a div.`) + if (binding?.figma?.group) markupError(`Native group binding "${key}" requires a div.`) + if (binding?.figma?.booleanOperation) { + markupError(`Native boolean-operation binding "${key}" requires a div.`) + } + if (binding?.figma?.component) { + markupError(`Native authored-component binding "${key}" requires a div.`) + } + if (binding?.figma?.slot) markupError(`Native slot binding "${key}" requires a div.`) + if (binding?.figma?.svg) markupError(`SVG binding "${key}" requires a childless div.`) + validateTextFont(key, binding, classes) + validateTextRanges(key, characters, binding) + } else { + if (hasText(element.text)) markupError(`div "${key}" cannot contain direct text.`) + if (classes.textClass) { + markupError(`Class "${classes.textClass}" is not supported on div "${key}".`) + } + } + + if (type === 'INSTANCE') { + if (element.children.length) markupError(`Component placeholder "${key}" must be childless.`) + if (classes.frameClass || classes.layoutClass) { + markupError( + `Class "${classes.frameClass ?? classes.layoutClass}" is not supported on component "${key}".` + ) + } + } + if (binding?.figma?.svg && element.children.length) { + markupError(`SVG binding "${key}" requires a childless div.`) + } + if (binding?.figma?.svg && classes.layoutClass) { + markupError(`SVG wrapper "${key}" cannot define an internal layout.`) + } + if ( + (isFrameContainerType(type) || type === 'SECTION' || hasShapeAppearance(type)) && + !binding?.styles?.stroke && + binding?.figma?.strokes === undefined && + (classes.stroke !== undefined) !== hasStrokeWeight(binding, classes) + ) { + markupError(`Border on "${key}" requires both stroke weight and paint sources.`) + } + if (isShapeType(type)) { + if (element.children.length) markupError(`Native shape "${key}" must be childless.`) + const vector = binding?.figma?.shape + if ( + vector?.type === 'VECTOR' && + state.mode === 'create' && + !vector.paths?.length && + !vector.network?.vertices.length + ) { + markupError(`New vector "${key}" requires at least one path or network vertex.`) + } + if (classes.layoutClass) { + markupError(`Layout class "${classes.layoutClass}" is not supported on shape "${key}".`) + } + if (classes.clipsContent !== undefined) { + markupError(`Overflow classes are not supported on shape "${key}".`) + } + if (classes.width.mode === 'HUG' || classes.height.mode === 'HUG') { + markupError(`Native shape "${key}" cannot use hug sizing.`) + } + if (type === 'LINE') { + if (classes.height.mode !== 'FIXED' || classes.height.value !== 0) { + markupError(`Line "${key}" requires h-[0px]; its length is represented by width.`) + } + if ( + classes.minHeight !== undefined || + classes.maxHeight !== undefined || + binding?.variables?.height || + binding?.variables?.minHeight || + binding?.variables?.maxHeight + ) { + markupError(`Line "${key}" cannot bind or constrain its zero height.`) + } + if ( + hasCornerClasses || + binding?.variables?.cornerRadius || + hasVariable(binding?.variables, CORNER_SIDE_VARIABLE_FIELDS) + ) { + markupError(`Line "${key}" does not support corner radius.`) + } + if (binding?.figma?.aspectRatioLocked !== undefined) { + markupError(`Line "${key}" does not support aspect-ratio locking.`) + } + } + } + if (parent?.type === 'BOOLEAN_OPERATION' && type !== 'TEXT' && !hasShapeAppearance(type)) { + markupError( + `Boolean operation "${parent.key}" can contain only text, basic shapes, or nested boolean operations.` + ) + } + if (parent?.type === 'COMPONENT_SET' && type !== 'COMPONENT') { + markupError(`Component set "${parent.key}" can contain only component nodes.`) + } + if (type === 'SLOT' && !insideComponent && !(state.mode === 'update' && parent === undefined)) { + markupError(`Slot "${key}" must be nested inside an authored component.`) + } + if ( + insideComponent && + (type === 'COMPONENT' || type === 'COMPONENT_SET') && + parent?.type !== 'COMPONENT_SET' + ) { + markupError(`Authored component "${key}" cannot be nested inside another component.`) + } + if (isIntrinsicContainer(type)) { + if (classes.width.mode !== 'HUG' || classes.height.mode !== 'HUG') { + markupError(`${type} node "${key}" requires intrinsic w-fit and h-fit sizing.`) + } + if (classes.layoutClass) { + markupError( + `Layout class "${classes.layoutClass}" is not supported on ${type} node "${key}".` + ) + } + if (classes.grow) markupError(`${type} node "${key}" cannot grow.`) + if ( + classes.minWidth !== undefined || + classes.maxWidth !== undefined || + classes.minHeight !== undefined || + classes.maxHeight !== undefined + ) { + markupError(`Min/max sizing is not supported on intrinsic ${type} node "${key}".`) + } + if ( + binding?.variables && + ['width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight'].some( + (field) => binding.variables?.[field as keyof CanvasVariableBindings] !== undefined + ) + ) { + markupError(`Size variables are not supported on intrinsic ${type} node "${key}".`) + } + } + if (type === 'GROUP') { + if (classes.frameClass) { + markupError(`Appearance class "${classes.frameClass}" is not supported on group "${key}".`) + } + if ( + binding?.figma?.stroke || + binding?.figma?.corners || + binding?.figma?.fills !== undefined || + binding?.figma?.strokes !== undefined + ) { + markupError(`Fill, stroke, and corner properties are not supported on group "${key}".`) + } + } + if (type === 'BOOLEAN_OPERATION' && classes.clipsContent !== undefined) { + markupError(`Overflow classes are not supported on boolean operation "${key}".`) + } + if (type === 'SECTION') { + if (parent && parent.type !== 'SECTION') { + markupError(`Section "${key}" can only be a canvas root or a direct child of a section.`) + } + if (classes.layoutClass) { + markupError(`Layout class "${classes.layoutClass}" is not supported on section "${key}".`) + } + if (classes.width.mode !== 'FIXED' || classes.height.mode !== 'FIXED') { + markupError(`Section "${key}" requires fixed width and height.`) + } + if (classes.grow) markupError(`Section "${key}" cannot grow.`) + if (classes.clipsContent !== undefined) { + markupError(`Overflow classes are not supported on section "${key}".`) + } + if (classes.opacity !== undefined || classes.blendMode !== undefined) { + markupError(`Opacity and blend modes are not supported on section "${key}".`) + } + if (classes.rotation !== undefined) { + markupError(`Rotation classes are not supported on section "${key}".`) + } + if (binding?.figma?.mask !== undefined) { + markupError(`Masks are not supported on section "${key}".`) + } + if (nativeStroke?.cap !== undefined || nativeStroke?.miterLimit !== undefined) { + markupError(`Stroke caps and miter limits are not supported on section "${key}".`) + } + } + if (binding?.figma?.text && type !== 'TEXT') { + markupError(`Figma text properties on "${key}" require a span.`) + } + const propertyReferences = binding?.figma?.componentPropertyReferences + if (propertyReferences?.characters !== undefined && type !== 'TEXT') { + markupError(`A characters property reference on "${key}" requires a span.`) + } + if (propertyReferences?.mainComponent !== undefined && type !== 'INSTANCE') { + markupError(`A mainComponent property reference on "${key}" requires an instance.`) + } + if ( + propertyReferences?.characters && + (binding?.variables?.characters || binding?.figma?.text?.ranges) + ) { + markupError( + `A characters property reference on "${key}" cannot be combined with a characters variable or rich-text ranges.` + ) + } + if (propertyReferences?.visible && binding?.variables?.visible) { + markupError( + `A visible property reference on "${key}" cannot be combined with a visibility variable.` + ) + } + if (classes.textCase && binding?.figma?.text?.case) { + markupError(`Text case on "${key}" cannot use both a class and a Figma property.`) + } + + if (isFrameContainerType(type)) { + if (classes.flex && classes.grid) { + markupError(`Container "${key}" cannot combine flex and grid layout.`) + } + if (classes.flex !== (classes.direction !== undefined)) { + markupError(`Flex container "${key}" must declare exactly one flex direction.`) + } + if (!classes.flex && !classes.grid && classes.layoutClass) { + markupError(`Layout class "${classes.layoutClass}" requires flex or grid on "${key}".`) + } + if (classes.grid) { + if (!classes.gridColumns) { + markupError(`Grid container "${key}" requires grid-cols-*.`) + } + if ( + classes.primaryAlign || + classes.counterAlign || + classes.counterAlignContent || + classes.wrap + ) { + markupError(`Flex alignment and wrapping classes are not supported on grid "${key}".`) + } + if ( + classes.width.mode === 'HUG' && + classes.gridColumns.some((track) => track.type === 'FLEX') + ) { + markupError(`Hug-width grid "${key}" cannot contain flexible column tracks.`) + } + if ( + classes.height.mode === 'HUG' && + (!classes.gridRows || classes.gridRows.some((track) => track.type === 'FLEX')) + ) { + markupError(`Hug-height grid "${key}" cannot contain flexible or automatic row tracks.`) + } + } else { + if (classes.counterAlign === 'BASELINE' && classes.direction !== 'HORIZONTAL') { + markupError(`items-baseline requires flex-row on "${key}".`) + } + if (classes.counterAlignContent === 'SPACE_BETWEEN' && classes.wrap !== 'WRAP') { + markupError(`content-between requires flex-wrap on "${key}".`) + } + const counterGap = classes.direction === 'HORIZONTAL' ? classes.rowGap : classes.columnGap + if (counterGap !== undefined && classes.wrap !== 'WRAP') { + markupError(`Cross-axis gap on "${key}" requires flex-wrap.`) + } + } + if ( + (classes.width.mode === 'HUG' || classes.height.mode === 'HUG') && + !classes.flex && + !classes.grid + ) { + markupError(`Hug-sized frame "${key}" must use auto layout.`) + } + } + + if (type === 'LINE') { + if (classes.width.mode === 'FIXED' && classes.width.value! < 0.01) { + markupError(`Line "${key}" requires width of at least 0.01px.`) + } + } else { + for (const [axis, size] of [ + ['width', classes.width], + ['height', classes.height] + ] as const) { + if (size.mode === 'FIXED' && size.value! < 0.01) { + markupError(`${axis} on "${key}" must be at least 0.01px.`) + } + } + } + + if (type === 'TEXT' && classes.width.mode === 'HUG' && classes.height.mode !== 'HUG') { + markupError(`Text "${key}" may use w-fit only together with h-fit.`) + } + + const parentMode = parent?.layout?.mode ?? 'NONE' + const relativeTransform = binding?.figma?.relativeTransform + if (type === 'LINE' && classes.grow && parentMode === 'VERTICAL') { + markupError(`Line "${key}" cannot grow on a vertical axis; its height is always zero.`) + } + if (classes.gridChildClass && (parentMode !== 'GRID' || classes.absolute)) { + markupError(`Grid child class "${classes.gridChildClass}" requires an in-flow grid child.`) + } + if (!parent) { + const validCreateRoot = + type === 'SECTION' || + isIntrinsicContainer(type) || + (isFrameContainerType(type) && type !== 'SLOT') + if (state.mode === 'create' && !validCreateRoot) { + markupError( + 'Create mode requires a frame, section, group, boolean-operation, component, or component-set canvas root.' + ) + } + if ( + !isIntrinsicContainer(type) && + (classes.width.mode !== 'FIXED' || classes.height.mode !== 'FIXED') + ) { + markupError('Canvas markup root requires fixed w-[Npx] and h-[Npx] classes.') + } + if (classes.grow) markupError('Canvas markup root cannot grow.') + if (classes.absolute) markupError('Canvas markup root cannot use absolute positioning.') + } else if (!classes.absolute) { + if (classes.width.mode === 'FILL' && parentMode !== 'VERTICAL' && parentMode !== 'GRID') { + markupError(`w-full on "${key}" requires a flex-col parent; use grow on a row main axis.`) + } + if (classes.height.mode === 'FILL' && parentMode !== 'HORIZONTAL' && parentMode !== 'GRID') { + markupError(`h-full on "${key}" requires a flex-row parent; use grow on a column main axis.`) + } + if (classes.grow && parentMode === 'GRID') { + markupError(`grow on "${key}" is not supported in grid; use w-full or h-full.`) + } + if (classes.grow && parentMode === 'NONE') { + markupError(`grow on "${key}" requires a flex parent.`) + } + } + if (relativeTransform && classes.rotation !== undefined) { + markupError(`Relative transform on "${key}" cannot be combined with a rotation class.`) + } + if ( + relativeTransform && + parentMode === 'NONE' && + (classes.absolute || classes.left !== undefined || classes.top !== undefined) + ) { + markupError(`Relative transform on "${key}" cannot be combined with position classes.`) + } + if ( + parent && + relativeTransform && + parentMode !== 'NONE' && + (relativeTransform[0][2] !== 0 || relativeTransform[1][2] !== 0) + ) { + markupError( + `Relative transform on "${key}" must use zero translation in Auto Layout because Figma computes its position.` + ) + } + if (parent && parentMode === 'NONE' && !classes.absolute && !relativeTransform) { + markupError( + `Child "${key}" in a freeform container requires absolute offsets or a relative transform.` + ) + } + if (classes.absolute) { + if (classes.left === undefined || classes.top === undefined) { + markupError(`Absolute node "${key}" requires left-* and top-* supported classes.`) + } + if (classes.grow || classes.width.mode === 'FILL' || classes.height.mode === 'FILL') { + markupError(`Absolute node "${key}" cannot use grow, w-full, or h-full.`) + } + } else if (classes.left !== undefined || classes.top !== undefined) { + markupError(`Position classes on "${key}" require absolute.`) + } + + const hasBounds = SIZE_BOUND_FIELDS.some( + (field) => classes[field] !== undefined || binding?.variables?.[field] != null + ) + if (hasBounds && type !== 'TEXT' && !classes.flex && !classes.grid && parentMode === 'NONE') { + markupError(`Min/max sizing on "${key}" requires text or auto layout.`) + } + validateSizeBounds(key, 'width', classes.width, classes.minWidth, classes.maxWidth) + validateSizeBounds(key, 'height', classes.height, classes.minHeight, classes.maxHeight) + + validatePaints(key, type, binding, classes) + validateVariables(key, type, binding, classes) + validateStyles(key, type, binding, classes) + validateEffects(key, type, binding) + validateFigmaLayout(key, type, binding, classes) + + const horizontalMode = classes.grow && parentMode === 'HORIZONTAL' ? 'FILL' : classes.width.mode + const verticalMode = classes.grow && parentMode === 'VERTICAL' ? 'FILL' : classes.height.mode + const autoResize = textAutoResize(horizontalMode, verticalMode) + if (binding?.figma?.aspectRatioLocked === true && type === 'TEXT' && autoResize !== 'NONE') { + markupError(`Aspect-ratio lock on auto-resizing text "${key}" is not supported by Figma.`) + } + const gridChild = + parentMode === 'GRID' && !classes.absolute + ? placeGridChild(key, classes, gridPlacement!) + : undefined + const includeDefaults = state.mode === 'create' + const size = { + ...(classes.width.value === undefined ? {} : { width: classes.width.value }), + ...(classes.height.value === undefined ? {} : { height: classes.height.value }), + ...(classes.minWidth !== undefined + ? { minWidth: classes.minWidth } + : includeDefaults + ? { minWidth: null } + : {}), + ...(classes.maxWidth !== undefined + ? { maxWidth: classes.maxWidth } + : includeDefaults + ? { maxWidth: null } + : {}), + ...(classes.minHeight !== undefined + ? { minHeight: classes.minHeight } + : includeDefaults + ? { minHeight: null } + : {}), + ...(classes.maxHeight !== undefined + ? { maxHeight: classes.maxHeight } + : includeDefaults + ? { maxHeight: null } + : {}), + horizontal: horizontalMode, + vertical: verticalMode + } + const common = { + key, + ...(nodeId === undefined ? {} : { nodeId }), + type, + ...(binding?.figma?.name !== undefined || includeDefaults + ? { displayName: binding?.figma?.name ?? key } + : {}), + size, + ...(classes.grow !== undefined || includeDefaults ? { grow: classes.grow ?? false } : {}), + ...(classes.visible === undefined ? {} : { visible: classes.visible }), + ...(classes.blendMode === undefined ? {} : { blendMode: classes.blendMode }), + ...(classes.rotation === undefined ? {} : { rotation: classes.rotation }), + ...(gridChild ? { gridChild } : {}), + ...(parent && (classes.absolute !== undefined || includeDefaults) + ? { positioning: classes.absolute ? ('ABSOLUTE' as const) : ('AUTO' as const) } + : {}), + ...(classes.absolute ? { position: { x: classes.left!, y: classes.top! } } : {}), + ...(binding?.variables ? { variables: binding.variables } : {}), + ...(binding?.variableModes ? { variableModes: binding.variableModes } : {}), + ...(binding?.styles ? { styles: binding.styles } : {}), + ...(binding?.figma ? { figma: binding.figma } : {}) + } + + let node: CanvasNodeSpec + if (type === 'TEXT') { + const fontName = binding?.figma?.text?.fontName + const fontFamily = fontName?.family ?? classes.fontFamily + const fontStyle = fontName?.style ?? classes.fontStyle + node = { + ...common, + type, + appearance: { + ...(binding?.figma?.fills === undefined && (classes.fill !== undefined || includeDefaults) + ? { fill: classes.fill ?? '#000000' } + : {}), + ...(binding?.figma?.strokes === undefined && includeDefaults ? { stroke: null } : {}), + ...strokeAppearance(binding, classes, false), + ...(classes.opacity !== undefined || includeDefaults + ? { opacity: classes.opacity ?? 1 } + : {}) + }, + text: { + characters, + ...(fontFamily !== undefined || includeDefaults + ? { fontFamily: fontFamily ?? 'Inter' } + : {}), + ...(fontStyle !== undefined || includeDefaults + ? { fontStyle: fontStyle ?? 'Regular' } + : {}), + ...(classes.fontSize !== undefined || includeDefaults + ? { fontSize: classes.fontSize ?? 16 } + : {}), + ...(classes.lineHeight !== undefined || includeDefaults + ? { lineHeight: classes.lineHeight ?? { unit: 'PIXELS' as const, value: 24 } } + : {}), + ...(classes.letterSpacing !== undefined || includeDefaults + ? { letterSpacing: classes.letterSpacing ?? { unit: 'PIXELS' as const, value: 0 } } + : {}), + ...(classes.textAlign !== undefined || includeDefaults + ? { alignHorizontal: classes.textAlign ?? ('LEFT' as const) } + : {}), + ...(binding?.figma?.text?.verticalAlign !== undefined || includeDefaults + ? { alignVertical: binding?.figma?.text?.verticalAlign ?? ('TOP' as const) } + : {}), + autoResize, + ...(classes.textCase ? { textCase: classes.textCase } : {}), + ...(classes.textDecoration ? { textDecoration: classes.textDecoration } : {}), + ...(classes.textTruncation ? { textTruncation: classes.textTruncation } : {}), + ...(classes.maxLines === undefined ? {} : { maxLines: classes.maxLines }) + } + } + } else if (type === 'INSTANCE') { + node = { + ...common, + type, + appearance: { + ...strokeAppearance(binding, classes, false), + ...cornerAppearance(binding, classes, false), + ...(classes.opacity !== undefined || includeDefaults + ? { opacity: classes.opacity ?? 1 } + : {}) + }, + component: binding!.component, + ...(binding?.componentProperties ? { componentProperties: binding.componentProperties } : {}) + } + } else if (type === 'GROUP') { + node = { + ...common, + type, + layout: { mode: 'NONE' }, + appearance: { + ...(classes.opacity !== undefined || includeDefaults + ? { opacity: classes.opacity ?? 1 } + : {}) + } + } + } else if (type === 'SECTION') { + node = { + ...common, + type, + appearance: fillStrokeAppearance(binding, classes) + } + } else if (hasShapeAppearance(type)) { + node = { + ...common, + type, + ...(type === 'BOOLEAN_OPERATION' ? { layout: { mode: 'NONE' as const } } : {}), + appearance: { + ...fillStrokeAppearance(binding, classes), + ...(classes.opacity !== undefined || includeDefaults + ? { opacity: classes.opacity ?? 1 } + : {}) + } + } + } else { + const rowGap = classes.rowGap ?? classes.gap + const columnGap = classes.columnGap ?? classes.gap + const padding = includeDefaults + ? { + top: classes.padding.top ?? 0, + right: classes.padding.right ?? 0, + bottom: classes.padding.bottom ?? 0, + left: classes.padding.left ?? 0 + } + : classes.padding + const hasPadding = includeDefaults || Object.keys(padding).length > 0 + const layout = classes.grid + ? { + mode: 'GRID' as const, + columns: classes.gridColumns!, + ...(classes.gridRows ? { rows: classes.gridRows } : {}), + ...(classes.gridRows !== undefined || + classes.gridFlow === 'ROW_AUTO_FLOW' || + includeDefaults + ? { autoRows: classes.gridRows === undefined } + : {}), + ...(rowGap !== undefined || includeDefaults ? { rowGap: rowGap ?? 0 } : {}), + ...(columnGap !== undefined || includeDefaults ? { columnGap: columnGap ?? 0 } : {}), + ...(hasPadding ? { padding } : {}), + ...(classes.gridFlow !== undefined || includeDefaults + ? { itemsPositioning: classes.gridFlow ?? ('MANUAL' as const) } + : {}), + ...(classes.strokesIncluded !== undefined || includeDefaults + ? { strokesIncluded: classes.strokesIncluded ?? false } + : {}) + } + : classes.flex + ? { + mode: classes.direction!, + ...((classes.direction === 'HORIZONTAL' ? columnGap : rowGap) !== undefined || + includeDefaults + ? { gap: (classes.direction === 'HORIZONTAL' ? columnGap : rowGap) ?? 0 } + : {}), + ...(classes.wrap === 'WRAP' && + ((classes.direction === 'HORIZONTAL' ? rowGap : columnGap) !== undefined || + includeDefaults) + ? { counterGap: (classes.direction === 'HORIZONTAL' ? rowGap : columnGap) ?? 0 } + : {}), + ...(hasPadding ? { padding } : {}), + ...(classes.primaryAlign !== undefined || includeDefaults + ? { primaryAlign: classes.primaryAlign ?? ('MIN' as const) } + : {}), + ...(classes.counterAlign !== undefined || includeDefaults + ? { counterAlign: classes.counterAlign ?? ('MIN' as const) } + : {}), + ...(classes.counterAlignContent !== undefined || includeDefaults + ? { counterAlignContent: classes.counterAlignContent ?? ('AUTO' as const) } + : {}), + ...(classes.wrap !== undefined || includeDefaults + ? { wrap: classes.wrap ?? ('NO_WRAP' as const) } + : {}), + ...(classes.strokesIncluded !== undefined || includeDefaults + ? { strokesIncluded: classes.strokesIncluded ?? false } + : {}) + } + : ({ mode: 'NONE' } as const) + node = { + ...common, + type, + ...(includeDefaults || classes.grid || classes.flex ? { layout } : {}), + appearance: { + ...(binding?.figma?.fills === undefined && (classes.fill !== undefined || includeDefaults) + ? { fill: classes.fill ?? null } + : {}), + ...(binding?.figma?.strokes === undefined && + (classes.stroke !== undefined || includeDefaults) + ? { stroke: classes.stroke ?? null } + : {}), + ...strokeAppearance(binding, classes, includeDefaults), + ...cornerAppearance(binding, classes, includeDefaults), + ...(classes.clipsContent !== undefined || includeDefaults + ? { clipsContent: classes.clipsContent ?? false } + : {}), + ...(classes.opacity !== undefined || includeDefaults + ? { opacity: classes.opacity ?? 1 } + : {}) + } + } + } + + if (element.children.length) { + const placement = + node.layout?.mode === 'GRID' + ? { + columns: node.layout.columns.length, + rows: node.layout.rows?.length ?? MAX_GRID_TRACKS, + manual: node.layout.itemsPositioning !== 'ROW_AUTO_FLOW', + occupied: new Set() + } + : undefined + const childInsideComponent = + insideComponent || type === 'COMPONENT' || type === 'COMPONENT_SET' || type === 'SLOT' + node.children = element.children.map((child) => + compileElement(child, state, depth + 1, node, placement, childInsideComponent) + ) + } + if (state.mode === 'create' && type === 'GROUP' && !node.children?.length) { + markupError(`New group "${key}" requires at least one child.`) + } + if (state.mode === 'create' && type === 'BOOLEAN_OPERATION' && (node.children?.length ?? 0) < 2) { + markupError(`New boolean operation "${key}" requires at least two children.`) + } + if (state.mode === 'create' && type === 'COMPONENT_SET' && !node.children?.length) { + markupError(`New component set "${key}" requires at least one component child.`) + } + return node +} + +export function parseCanvasMarkup( + input: CanvasResolvedApplyParameters, + catalog?: DesignSystemCatalog +): ParsedCanvasInput { + if (input.markup === null) { + return { + mode: 'update', + targetNodeId: input.targetNodeId!, + root: null + } + } + const state: CompileState = { + bindings: Object.assign(Object.create(null) as Record, input.bindings), + ...(catalog ? { catalog } : {}), + count: 0, + keys: new Set(), + mode: input.mode, + nodeIds: new Set() + } + const rootElement = normalizeCatalogElement( + parseCanvasHtml(input.markup), + state.bindings, + catalog + ) + const root = compileElement(rootElement, state, 1) + validateAssetReferences(root, input.assets) + for (const key of Object.keys(state.bindings)) { + if (!state.keys.has(key)) markupError(`Binding "${key}" has no matching data-key.`) + } + for (const key of input.removeKeys ?? []) { + if (state.keys.has(key)) { + markupError(`Canvas key "${key}" cannot be both present and removed.`) + } + } + if (input.mode === 'update' && root.nodeId !== undefined && root.nodeId !== input.targetNodeId) { + markupError('The root data-node-id must match targetNodeId in update mode.') + } + return { + mode: input.mode, + ...(input.targetNodeId === undefined ? {} : { targetNodeId: input.targetNodeId }), + removeKeys: input.removeKeys ?? [], + ...(input.page === undefined ? {} : { page: input.page }), + ...(input.variableCollections === undefined + ? {} + : { variableCollections: input.variableCollections }), + ...(input.styles === undefined ? {} : { styles: input.styles }), + ...(input.assets === undefined ? {} : { assets: input.assets }), + root + } +} + +function validateAssetReferences(root: CanvasNodeSpec, assets: CanvasAssets | undefined): void { + const referenced = new Set() + const requireAsset = (key: string, type: 'IMAGE' | 'SVG', owner: string): void => { + const asset = assets?.[key] + if (!asset) markupError(`${type} asset "${key}" referenced by "${owner}" is not declared.`) + if (asset.type !== type) { + markupError(`Asset "${key}" referenced by "${owner}" is ${asset.type}, expected ${type}.`) + } + referenced.add(key) + } + const visitPaints = (paints: CanvasFigmaPaint[] | undefined, owner: string): void => { + for (const paint of paints ?? []) { + if (paint.type === 'IMAGE' && paint.assetKey) { + requireAsset(paint.assetKey, 'IMAGE', owner) + } + } + } + const visit = (spec: CanvasNodeSpec): void => { + if (spec.figma?.svg) requireAsset(spec.figma.svg.assetKey, 'SVG', spec.key) + visitPaints(spec.figma?.fills, spec.key) + visitPaints(spec.figma?.strokes, spec.key) + for (const range of spec.figma?.text?.ranges ?? []) { + visitPaints(range.fills, `${spec.key} text range`) + } + if (spec.figma?.shape?.type === 'VECTOR') { + for (const region of spec.figma.shape.network?.regions ?? []) { + visitPaints(region.fills, `${spec.key} vector region`) + } + } + for (const child of spec.children ?? []) visit(child) + } + visit(root) + for (const key of Object.keys(assets ?? {})) { + if (!referenced.has(key)) markupError(`Declared asset "${key}" is not referenced.`) + } +} diff --git a/packages/extension/mcp/tools/canvas/model.ts b/packages/extension/mcp/tools/canvas/model.ts new file mode 100644 index 00000000..76becefd --- /dev/null +++ b/packages/extension/mcp/tools/canvas/model.ts @@ -0,0 +1,154 @@ +import type { + CanvasAssets, + CanvasComponentPropertyValue, + CanvasDesignReference, + CanvasFigmaProperties, + CanvasFigmaShape, + CanvasPageProperties, + CanvasStyleBindings, + CanvasStyles, + CanvasVariableBindings, + CanvasVariableCollections, + CanvasVariableModes +} from '@tempad-dev/shared' + +export type CanvasShapeNodeType = CanvasFigmaShape['type'] +type CanvasNodeType = + | 'BOOLEAN_OPERATION' + | 'COMPONENT' + | 'COMPONENT_SET' + | 'FRAME' + | 'GROUP' + | 'INSTANCE' + | 'SECTION' + | 'SLOT' + | 'TEXT' + | CanvasShapeNodeType +export type CanvasSizingMode = 'FILL' | 'FIXED' | 'HUG' +export type CanvasGridTrack = { type: 'FIXED' | 'FLEX'; value: number } | { type: 'HUG' } + +type CanvasPadding = number | Partial> + +export type CanvasGridLayout = { + autoRows?: boolean + mode: 'GRID' + columns: CanvasGridTrack[] + rows?: CanvasGridTrack[] + rowGap?: number + columnGap?: number + padding?: CanvasPadding + itemsPositioning?: 'MANUAL' | 'ROW_AUTO_FLOW' + strokesIncluded?: boolean +} + +type CanvasLayout = + | { + mode: 'NONE' + } + | { + mode: 'HORIZONTAL' | 'VERTICAL' + gap?: number + counterGap?: number + padding?: CanvasPadding + primaryAlign?: 'CENTER' | 'MAX' | 'MIN' | 'SPACE_BETWEEN' + counterAlign?: 'BASELINE' | 'CENTER' | 'MAX' | 'MIN' + counterAlignContent?: 'AUTO' | 'SPACE_BETWEEN' + wrap?: 'NO_WRAP' | 'WRAP' + strokesIncluded?: boolean + } + | CanvasGridLayout + +export type CanvasNodeSpec = { + key: string + nodeId?: string + type: CanvasNodeType + displayName?: string + size: { + width?: number + height?: number + minWidth?: number | null + maxWidth?: number | null + minHeight?: number | null + maxHeight?: number | null + horizontal: CanvasSizingMode + vertical: CanvasSizingMode + } + grow?: boolean + visible?: boolean + blendMode?: BlendMode + rotation?: number + position?: { + x: number + y: number + } + positioning?: 'ABSOLUTE' | 'AUTO' + layout?: CanvasLayout + gridChild?: { + row?: number + column?: number + rowSpan: number + columnSpan: number + horizontalAlign: 'AUTO' | 'CENTER' | 'MAX' | 'MIN' + verticalAlign: 'AUTO' | 'CENTER' | 'MAX' | 'MIN' + } + appearance?: { + fill?: `#${string}` | null + stroke?: `#${string}` | null + strokeWeight?: number + strokeTopWeight?: number + strokeRightWeight?: number + strokeBottomWeight?: number + strokeLeftWeight?: number + cornerRadius?: number + topLeftRadius?: number + topRightRadius?: number + bottomRightRadius?: number + bottomLeftRadius?: number + clipsContent?: boolean + opacity?: number + } + text?: { + characters: string + fontFamily?: string + fontStyle?: string + fontSize?: number + lineHeight?: LineHeight + letterSpacing?: LetterSpacing + alignHorizontal?: 'CENTER' | 'JUSTIFIED' | 'LEFT' | 'RIGHT' + alignVertical?: 'BOTTOM' | 'CENTER' | 'TOP' + autoResize: 'HEIGHT' | 'NONE' | 'WIDTH_AND_HEIGHT' + textCase?: TextCase + textDecoration?: TextDecoration + textTruncation?: 'DISABLED' | 'ENDING' + maxLines?: number | null + } + component?: CanvasDesignReference + componentProperties?: Record + variables?: CanvasVariableBindings + variableModes?: CanvasVariableModes + styles?: CanvasStyleBindings + figma?: CanvasFigmaProperties + children?: CanvasNodeSpec[] +} + +type ParsedCanvasCommon = { + mode: 'create' | 'update' + targetNodeId?: string + removeKeys: string[] + page?: CanvasPageProperties + assets?: CanvasAssets + styles?: CanvasStyles + variableCollections?: CanvasVariableCollections +} + +export type ParsedCanvasTreeInput = ParsedCanvasCommon & { + root: CanvasNodeSpec +} + +type ParsedCanvasRootRemovalInput = { + mode: 'update' + targetNodeId: string + root: null +} + +export type ParsedCanvasInput = ParsedCanvasTreeInput | ParsedCanvasRootRemovalInput diff --git a/packages/extension/mcp/tools/canvas/reconcile.ts b/packages/extension/mcp/tools/canvas/reconcile.ts new file mode 100644 index 00000000..9033a01b --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile.ts @@ -0,0 +1,5315 @@ +import { + type ApplyCanvasResult, + type CanvasDesignReference, + type CanvasFigmaComponentPropertyDefinition, + type CanvasFigmaEffect, + type CanvasFigmaLayoutGrid, + type CanvasFigmaPaint, + type CanvasFigmaShaderPropertyValue, + type CanvasFigmaSlotProperty, + type CanvasFigmaTextRange, + type CanvasFigmaVectorNetwork, + type CanvasHyperlink, + type CanvasPageProperties, + type CanvasStyleBindings, + type CanvasStyleReference, + type CanvasStyleResource, + type CanvasVariableBindings, + type CanvasVariableReference, + TEMPAD_MCP_ERROR_CODES +} from '@tempad-dev/shared' + +import type { + CanvasGridLayout, + CanvasGridTrack, + CanvasNodeSpec, + ParsedCanvasInput, + ParsedCanvasTreeInput +} from './model' + +import { readBoundedResponseBytes } from '../../bounded-response' +import { createCodedError } from '../../errors' +import { + type ResolvedCanvasAssets, + resolveCanvasAssets, + resolvedImageAsset, + resolvedSvgAsset, + SVG_POLICY_VERSION +} from './assets' +import { canvasReadOnlyError, scopeError, specError } from './errors' +import { + CANVAS_KEY_NAMESPACE, + CANVAS_NODE_KEY_NAME, + CANVAS_PAGE_KEY_NAME, + type MutationCounter, + designReferenceCacheKey +} from './identity' +import { + type CanvasStyleState, + createStyleState, + prepareStyleResources, + removeStyleResources, + resolveStyle +} from './styles' +import { + type CanvasVariableState, + createVariableState, + reconcileVariableCollections, + removeVariableResources, + resolveCollection, + resolveModeId, + resolvedCollection, + resolvedModeId, + resolvedVariable, + resolveVariable, + variableReferenceCacheKey +} from './variables' +import { canonicalVectorPaths, vectorPathsEqual } from './vector' + +const CANVAS_COUNTER_AXIS_SYNC_NAME = 'counter-axis-spacing-sync' +const CANVAS_COMPONENT_PROPERTY_KEYS_NAME = 'component-property-keys' +const CANVAS_SVG_CHILD_NAME = 'svg-child' +const CANVAS_SVG_COLOR_NAME = 'svg-color' +const CANVAS_SVG_DIGEST_NAME = 'svg-digest' +const CANVAS_SVG_POLICY_NAME = 'svg-policy' +const MAX_VIDEO_BYTES = 100 * 1024 * 1024 +const ROOT_PLACEMENT_GAP = 80 +const GEOMETRY_TOLERANCE = 0.01 +const MAX_IMPORTED_IMAGE_HASHES = 256 +const importedImageHashes = new Map() +const SUPPORTED_NODE_TYPES = new Set([ + 'BOOLEAN_OPERATION', + 'COMPONENT', + 'COMPONENT_SET', + 'FRAME', + 'GROUP', + 'INSTANCE', + 'SECTION', + 'SLOT', + 'TEXT', + 'RECTANGLE', + 'LINE', + 'ELLIPSE', + 'POLYGON', + 'STAR', + 'VECTOR' +]) + +type SupportedCanvasNode = Extract +type CanvasFrameContainerNode = ComponentNode | ComponentSetNode | FrameNode | SlotNode +type CanvasParentNode = + | BooleanOperationNode + | ComponentNode + | ComponentSetNode + | FrameNode + | GroupNode + | PageNode + | SectionNode + | SlotNode +type IntrinsicContainerNode = BooleanOperationNode | GroupNode +type WrappedContainerNode = ComponentSetNode | IntrinsicContainerNode +type WrappedContainerSpec = CanvasNodeSpec & { type: WrappedContainerNode['type'] } +type ComponentPropertyOwner = ComponentNode | ComponentSetNode +type ComponentPropertyReferenceField = 'characters' | 'mainComponent' | 'visible' +type ComponentPropertyContext = { + existing?: ComponentPropertyOwner + spec?: CanvasNodeSpec +} + +type ApplyState = { + assets: ResolvedCanvasAssets + claimedNodeIds: Set + componentCache: Map + componentPropertyKeys: Map> + createdNodeIds: Set + desiredKeys: Set + fontLoads: Map> + imageHashes: Map + imageAssetKeys: Set + imageUrls: Set + keyedNodes: Map + mutations: MutationCounter + nodeIdsByKey: Record + removalNodeIds: Set + referencedNodeIds: Set + scope: SupportedCanvasNode | null + shaderCache: Map + styles: CanvasStyleState + updatedNodeIds: Set + variables: CanvasVariableState + videoHashes: Map + videoUrls: Set +} + +function isSupportedSceneNode(node: BaseNode | null): node is SupportedCanvasNode { + return !!node && SUPPORTED_NODE_TYPES.has(node.type as CanvasNodeSpec['type']) +} + +function isSceneNode(node: BaseNode | null): node is SceneNode { + return !!node && 'x' in node && 'y' in node +} + +function isMaskNode(node: SceneNode): boolean { + return 'isMask' in node && node.isMask +} + +function isWrappedSpec(spec: CanvasNodeSpec): spec is WrappedContainerSpec { + return spec.type === 'BOOLEAN_OPERATION' || spec.type === 'COMPONENT_SET' || spec.type === 'GROUP' +} + +function isIntrinsicNode(node: SupportedCanvasNode): node is IntrinsicContainerNode { + return node.type === 'BOOLEAN_OPERATION' || node.type === 'GROUP' +} + +function isFrameContainer( + node: SupportedCanvasNode | CanvasParentNode +): node is CanvasFrameContainerNode { + return ( + node.type === 'COMPONENT' || + node.type === 'COMPONENT_SET' || + node.type === 'FRAME' || + node.type === 'SLOT' + ) +} + +function isWithinScope(node: BaseNode, scope: BaseNode): boolean { + let current: BaseNode | null = node + while (current) { + if (current.id === scope.id) return true + current = current.parent + } + return false +} + +function containingPage(node: BaseNode): PageNode { + let current: BaseNode | null = node + while (current) { + if (current.type === 'PAGE') return current + current = current.parent + } + scopeError(`Node "${node.id}" is not attached to a page.`) +} + +function pageById(id: string): PageNode | undefined { + return figma.root.children.find((page) => page.id === id) +} + +function pageByKey(key: string): PageNode | undefined { + let match: PageNode | undefined + for (const page of figma.root.children) { + if (page.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME) !== key) { + continue + } + if (match) specError(`Page key "${key}" identifies more than one local page.`) + match = page + } + return match +} + +async function resolveResultPage( + properties: CanvasPageProperties | undefined, + target: SupportedCanvasNode | null, + state: ApplyState +): Promise { + const containing = target ? containingPage(target) : figma.currentPage + const id = properties?.id + const key = properties?.pageKey + const explicit = id ? pageById(id) : undefined + if (id && !explicit) specError(`Page "${id}" does not exist.`) + const keyed = key ? pageByKey(key) : undefined + if (explicit && keyed && explicit.id !== keyed.id) { + specError(`Page key "${key}" does not identify "${explicit.id}".`) + } + + let page = explicit ?? keyed + if (target) { + if (page && page.id !== containing.id) { + scopeError(`The update target belongs to page "${containing.id}", not "${page.id}".`) + } + page = containing + } + const createsPage = !page && key !== undefined + if (createsPage && properties?.name === undefined) { + specError(`New page "${key}" requires a name.`) + } + const index = properties?.index + const maxIndex = figma.root.children.length - (createsPage ? 0 : 1) + if (index !== undefined && index > maxIndex) { + specError(`Page index ${index} exceeds the maximum index ${maxIndex}.`) + } + if (createsPage) { + page = figma.createPage() + state.mutations.count += 1 + } + page ??= containing + + const currentKey = key ? page.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME) : '' + if (key && currentKey && currentKey !== key) { + specError(`Page "${page.id}" is already owned by authoring key "${currentKey}".`) + } + if (page.id !== figma.currentPage.id) await page.loadAsync() + if (key && !currentKey) { + page.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME, key) + markMutation(state, page) + } + return page +} + +function collectKeyedNodes(scope: SupportedCanvasNode): Map { + const keyed = new Map() + for (const node of walkNodes([scope])) { + if (isSupportedSceneNode(node)) { + const key = node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_KEY_NAME) + if (key) { + if (keyed.has(key)) { + scopeError(`Canvas key "${key}" is duplicated inside the update scope.`) + } + keyed.set(key, node) + } + } + } + return keyed +} + +function* walkNodes(roots: Iterable, descendIntoInstances = true): Generator { + const stack = [...roots] + while (stack.length) { + const node = stack.pop()! + yield node + if ('children' in node && (descendIntoInstances || node.type !== 'INSTANCE')) { + stack.push(...node.children) + } + } +} + +function collectDesiredKeys(root: CanvasNodeSpec): Set { + const keys = new Set() + const stack = [root] + while (stack.length) { + const spec = stack.pop()! + keys.add(spec.key) + stack.push(...(spec.children ?? [])) + } + return keys +} + +type CanvasNodeReference = { nodeId: string } | { canvasKey: string } + +async function preflightNodeReference( + reference: CanvasNodeReference, + context: string, + state: ApplyState, + sceneOnly = false +): Promise { + if ('canvasKey' in reference) { + if (!state.desiredKeys.has(reference.canvasKey) && !state.keyedNodes.has(reference.canvasKey)) { + specError( + `${context} canvas key "${reference.canvasKey}" does not exist in the desired result or update scope.` + ) + } + return + } + const node = await figma.getNodeByIdAsync(reference.nodeId) + if (!node || (sceneOnly && !isSceneNode(node))) { + specError( + `${context} "${reference.nodeId}" does not exist${sceneOnly ? ' or is not a scene node' : ''}.` + ) + } + state.referencedNodeIds.add(node.id) +} + +function resolveCanvasKey(key: string, state: ApplyState): SupportedCanvasNode { + const nodeId = state.nodeIdsByKey[key] + const node = (nodeId ? figma.getNodeById(nodeId) : state.keyedNodes.get(key)) ?? null + if (!isSupportedSceneNode(node)) { + specError(`Canvas key "${key}" did not resolve to a reconciled scene node.`) + } + state.referencedNodeIds.add(node.id) + return node +} + +function outermostNodes(nodes: SupportedCanvasNode[]): SupportedCanvasNode[] { + const ids = new Set(nodes.map((node) => node.id)) + return nodes.filter((node) => { + let parent = node.parent + while (parent) { + if (ids.has(parent.id)) return false + parent = parent.parent + } + return true + }) +} + +function validateRemovalOwnership(root: SupportedCanvasNode, state: ApplyState): void { + for (const node of walkNodes([root], false)) { + if (!isSupportedSceneNode(node)) { + scopeError(`Removing "${root.id}" would also remove an unsupported canvas node.`) + } + const key = node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_KEY_NAME) + if (!key || state.keyedNodes.get(key)?.id !== node.id) { + scopeError(`Removing "${root.id}" would also remove a node not owned by apply_canvas.`) + } + } +} + +function validateRemovalAncestors(node: SupportedCanvasNode): void { + let ancestor = node.parent + while (ancestor) { + if ((ancestor.type === 'COMPONENT' || ancestor.type === 'COMPONENT_SET') && ancestor.remote) { + scopeError(`Remote ${ancestor.type.toLowerCase()} "${ancestor.id}" is read-only.`) + } + ancestor = ancestor.parent + } +} + +function resolveRemovalNodes( + input: ParsedCanvasTreeInput, + state: ApplyState +): SupportedCanvasNode[] { + const nodes: SupportedCanvasNode[] = [] + for (const key of input.removeKeys) { + const node = state.keyedNodes.get(key) + if (!node) continue + if (node.id === state.scope?.id) { + scopeError('The update root cannot be removed.') + } + validateRemovalAncestors(node) + validateRemovalOwnership(node, state) + state.removalNodeIds.add(node.id) + nodes.push(node) + } + return nodes +} + +function collectRemovalComponents(roots: SupportedCanvasNode[]): ComponentNode[] { + const components: ComponentNode[] = [] + for (const node of walkNodes(roots, false)) { + if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') { + if (node.remote) { + scopeError(`Remote ${node.type.toLowerCase()} "${node.id}" cannot be removed.`) + } + if (node.type === 'COMPONENT') components.push(node) + } + } + return components +} + +async function validateRemovalComponents(roots: SupportedCanvasNode[]): Promise { + for (const component of collectRemovalComponents(roots)) { + const instances = await component.getInstancesAsync() + if ( + instances.some( + (instance) => !instance.removed && !roots.some((root) => isWithinScope(instance, root)) + ) + ) { + scopeError(`Component "${component.id}" has instances outside the removal scope.`) + } + } +} + +type RemovalReferences = { + componentKeys: Set + nodeIds: Set + shaders: Array +} + +function collectReferences(value: unknown, references: RemovalReferences): void { + if (Array.isArray(value)) { + value.forEach((item) => collectReferences(item, references)) + return + } + if (!value || typeof value !== 'object') return + const record = value as Record + if (record.type === 'PATTERN' && typeof record.sourceNodeId === 'string') { + references.nodeIds.add(record.sourceNodeId) + } else if (record.type === 'NODE' && typeof record.value === 'string') { + references.nodeIds.add(record.value) + } else if (record.type === 'SHADER' && typeof record.id === 'string') { + references.shaders.push(value as ShaderEffect | ShaderPaint) + } + Object.values(record).forEach((item) => collectReferences(item, references)) +} + +function collectComponentReferences( + properties: ComponentProperties | ComponentPropertyDefinitions, + references: RemovalReferences +): void { + for (const property of Object.values(properties)) { + if (property.type === 'INSTANCE_SWAP') { + const value = 'defaultValue' in property ? property.defaultValue : property.value + if (typeof value === 'string') references.nodeIds.add(value) + } + for (const preferred of property.preferredValues ?? []) { + references.componentKeys.add(preferred.key) + } + } +} + +function collectSceneReferences(node: SceneNode, references: RemovalReferences): void { + const record = node as unknown as Record + collectReferences(record.fills, references) + collectReferences(record.strokes, references) + collectReferences(record.effects, references) + if (node.type === 'VECTOR') { + collectReferences(node.vectorNetwork.regions, references) + } + if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') { + collectComponentReferences(node.componentPropertyDefinitions, references) + } else if (node.type === 'INSTANCE') { + collectComponentReferences(node.componentProperties, references) + } + if (node.type !== 'TEXT') return + collectReferences(node.hyperlink, references) + try { + collectReferences(node.getStyledTextSegments(['fills', 'hyperlink']), references) + } catch { + scopeError(`Rich text on node "${node.id}" could not be inspected before node removal.`) + } +} + +function collectRemovedIdentities(roots: SupportedCanvasNode[]): { + componentKeys: Set + nodeIds: Set +} { + const componentKeys = new Set() + const nodeIds = new Set() + for (const node of walkNodes(roots, false)) { + nodeIds.add(node.id) + if ((node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') && node.key) { + componentKeys.add(node.key) + } + } + return { componentKeys, nodeIds } +} + +async function validateRemovalReferences( + roots: SupportedCanvasNode[], + state: ApplyState +): Promise { + if (!roots.length) return + const removed = collectRemovedIdentities(roots) + const references: RemovalReferences = { + componentKeys: new Set(), + nodeIds: new Set(), + shaders: [] + } + for (const page of figma.root.children) { + try { + await page.loadAsync() + } catch { + scopeError(`Page "${page.id}" could not be inspected before node removal.`) + } + collectReferences(page.backgrounds, references) + const pending = [...page.children] + while (pending.length) { + const node = pending.pop()! + if (removed.nodeIds.has(node.id)) continue + collectSceneReferences(node, references) + if ('children' in node) pending.push(...node.children) + } + } + const removedStyleIds = new Set(state.styles.removals.map(({ style }) => style.id)) + const [paintStyles, effectStyles] = await Promise.all([ + figma.getLocalPaintStylesAsync(), + figma.getLocalEffectStylesAsync() + ]) + for (const style of [...paintStyles, ...effectStyles]) { + if (removedStyleIds.has(style.id)) continue + collectReferences(style.type === 'PAINT' ? style.paints : style.effects, references) + } + for (const usage of references.shaders) { + const definitions = (await resolveShader(usage.id, state)).propertyDefinitions ?? {} + for (const [propertyId, value] of Object.entries(usage.properties ?? {})) { + const type = definitions[propertyId]?.type + if ((type === 'INSTANCE_SWAP' || type === 'SLOT') && typeof value === 'string') { + references.nodeIds.add(value) + references.componentKeys.add(value) + } + } + } + const nodeId = [...references.nodeIds].find((id) => removed.nodeIds.has(id)) + if (nodeId) scopeError(`Node "${nodeId}" is still referenced outside the removal scope.`) + const componentKey = [...references.componentKeys].find((key) => removed.componentKeys.has(key)) + if (componentKey) { + scopeError(`Component key "${componentKey}" is still referenced outside the removal scope.`) + } +} + +function validateRemovalResult(roots: SupportedCanvasNode[], state: ApplyState): void { + for (const root of roots) { + for (const node of walkNodes([root])) { + if (state.claimedNodeIds.has(node.id)) { + specError(`Desired node "${node.id}" would remain inside a removed subtree.`) + } + if (state.referencedNodeIds.has(node.id)) { + specError(`Referenced node "${node.id}" would be removed by this result.`) + } + } + } + + const rootsByParent = new Map>() + for (const root of roots) { + const parent = root.parent + if (!parent || !('children' in parent)) continue + const ids = rootsByParent.get(parent) ?? new Set() + ids.add(root.id) + rootsByParent.set(parent, ids) + } + for (const [parent, removedIds] of rootsByParent) { + const remaining = parent.children.filter((child) => !removedIds.has(child.id)) + if ( + parent.children.some(isMaskNode) && + remaining.some((child) => !state.claimedNodeIds.has(child.id)) + ) { + specError( + `Removing a sibling in mask container "${parent.id}" requires every remaining sibling in the desired result.` + ) + } + const last = remaining.at(-1) + if (last && isMaskNode(last)) { + specError(`Mask "${last.id}" must precede at least one remaining sibling.`) + } + if (parent.type === 'GROUP' && remaining.length < 1) { + specError(`Removing these nodes would implicitly remove group "${parent.id}".`) + } + if (parent.type === 'BOOLEAN_OPERATION' && remaining.length < 2) { + specError(`Boolean operation "${parent.id}" requires at least two remaining operands.`) + } + if (parent.type === 'COMPONENT_SET' && remaining.length < 1) { + specError(`Component set "${parent.id}" requires at least one remaining variant.`) + } + } +} + +async function applyRemovals( + removalNodes: SupportedCanvasNode[], + state: ApplyState +): Promise { + const roots = outermostNodes(removalNodes.filter((node) => !node.removed)) + validateRemovalResult(roots, state) + await validateRemovalComponents(roots) + await validateRemovalReferences(roots, state) + for (const root of roots) { + root.remove() + state.mutations.count += 1 + } + return removalNodes.map((node) => node.id) +} + +function markMutation(state: ApplyState, node: BaseNode): void { + state.mutations.count += 1 + if (!state.createdNodeIds.has(node.id)) { + state.updatedNodeIds.add(node.id) + } +} + +function setNodeKey(state: ApplyState, node: SupportedCanvasNode, key: string): void { + const currentKey = node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_KEY_NAME) + if (currentKey === key) return + if (currentKey) { + specError(`Node "${node.id}" is already owned by canvas key "${currentKey}".`) + } + node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_KEY_NAME, key) + markMutation(state, node) +} + +function componentPropertyOwner(node: BaseNode): ComponentPropertyOwner | null { + let current = node.parent + while (current) { + if (current.type === 'COMPONENT_SET') return current + if (current.type === 'COMPONENT') { + return current.parent?.type === 'COMPONENT_SET' ? current.parent : current + } + current = current.parent + } + return null +} + +function componentPropertyKeys( + owner: ComponentPropertyOwner, + state: ApplyState +): Record { + const cached = state.componentPropertyKeys.get(owner.id) + if (cached) return cached + const raw = owner.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COMPONENT_PROPERTY_KEYS_NAME) + let keys: Record = Object.create(null) as Record + if (raw) { + try { + const parsed: unknown = JSON.parse(raw) + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + Object.values(parsed).some((value) => typeof value !== 'string') + ) { + throw new Error() + } + keys = { ...(parsed as Record) } + } catch { + scopeError(`Component property identity data on "${owner.id}" is invalid.`) + } + } + state.componentPropertyKeys.set(owner.id, keys) + return keys +} + +function setComponentPropertyKey( + owner: ComponentPropertyOwner, + key: string, + propertyName: string, + state: ApplyState +): void { + const keys = componentPropertyKeys(owner, state) + if (keys[key] === propertyName) return + keys[key] = propertyName + owner.setSharedPluginData( + CANVAS_KEY_NAMESPACE, + CANVAS_COMPONENT_PROPERTY_KEYS_NAME, + JSON.stringify(keys) + ) + markMutation(state, owner) +} + +function componentPropertyName( + owner: ComponentPropertyOwner, + key: string, + state: ApplyState +): string | undefined { + const mapped = componentPropertyKeys(owner, state)[key] + if (mapped) return mapped + return owner.componentPropertyDefinitions[key] ? key : undefined +} + +function findExistingNode( + spec: CanvasNodeSpec, + state: ApplyState, + forcedNode?: SupportedCanvasNode +): SupportedCanvasNode | null { + if (forcedNode) return forcedNode + if (spec.nodeId) { + const node = figma.getNodeById(spec.nodeId) + return isSupportedSceneNode(node) ? node : null + } + return state.keyedNodes.get(spec.key) ?? null +} + +function resolveExistingNode( + spec: CanvasNodeSpec, + state: ApplyState, + forcedNode?: SupportedCanvasNode +): SupportedCanvasNode | null { + const node = findExistingNode(spec, state, forcedNode) + if (!node) { + if (spec.nodeId) { + scopeError(`Node "${spec.nodeId}" does not exist or is not supported by apply_canvas.`) + } + return null + } + + const keyedNode = state.keyedNodes.get(spec.key) + if (keyedNode && keyedNode.id !== node.id) { + specError( + `Canvas key "${spec.key}" already identifies node "${keyedNode.id}", not "${node.id}".` + ) + } + if (state.scope && !isWithinScope(node, state.scope)) { + scopeError(`Node "${node.id}" is outside the requested update scope.`) + } + if (node.type !== spec.type) { + specError( + `Canvas key "${spec.key}" expects ${spec.type}, but node "${node.id}" is ${node.type}.` + ) + } + if (state.claimedNodeIds.has(node.id)) { + specError(`Node "${node.id}" is referenced more than once in the desired result.`) + } + state.claimedNodeIds.add(node.id) + return node +} + +async function resolveComponent(reference: CanvasDesignReference, state: ApplyState) { + const cacheKey = designReferenceCacheKey(reference) + const cached = state.componentCache.get(cacheKey) + if (cached) return cached + + let component: ComponentNode | null = null + if (reference.id !== undefined) { + const node = figma.getNodeById(reference.id) + if (node?.type === 'COMPONENT') { + component = node + } else if (node?.type === 'COMPONENT_SET') { + component = node.defaultVariant + } + } else { + component = await figma.importComponentByKeyAsync(reference.key) + } + + if (!component) { + specError('The requested component could not be resolved.') + } + state.componentCache.set(cacheKey, component) + return component +} + +async function resolveShader(id: string, state: ApplyState): Promise { + const cached = state.shaderCache.get(id) + if (cached) return cached + let shader: Shader + try { + shader = await figma.importShaderById(id) + } catch { + specError(`Shader "${id}" could not be imported.`) + } + state.shaderCache.set(id, shader) + return shader +} + +const STYLE_TYPES = { + fill: 'PAINT', + stroke: 'PAINT', + text: 'TEXT', + effect: 'EFFECT', + grid: 'GRID' +} satisfies Record + +function validateStyleType(field: keyof CanvasStyleBindings, style: BaseStyle, key: string): void { + const expected = STYLE_TYPES[field] + if (style.type !== expected) { + specError( + `Style "${style.id}" for ${field} on "${key}" is ${style.type}, expected ${expected}.` + ) + } +} + +function loadFont(font: FontName, state: ApplyState): Promise { + const key = `${font.family}\0${font.style}` + const pending = state.fontLoads.get(key) + if (pending) return pending + const load = figma + .loadFontAsync(font) + .catch(() => + specError(`Font "${font.family} ${font.style}" is unavailable in the current Figma context.`) + ) + state.fontLoads.set(key, load) + return load +} + +async function loadFonts(fonts: Iterable, state: ApplyState): Promise { + const unique = new Map([...fonts].map((font) => [`${font.family}\0${font.style}`, font] as const)) + await Promise.all([...unique.values()].map((font) => loadFont(font, state))) +} + +function currentTextFonts(node: TextNode, range?: { start: number; end: number }): FontName[] { + if (range) return node.getRangeAllFontNames(range.start, range.end) + return node.fontName === figma.mixed + ? node.getRangeAllFontNames(0, node.characters.length) + : [node.fontName] +} + +function expectedVariableType(field: keyof CanvasVariableBindings): VariableResolvedDataType { + if (field === 'fill' || field === 'stroke') return 'COLOR' + if (field === 'characters' || field === 'fontFamily' || field === 'fontStyle') return 'STRING' + if (field === 'visible') return 'BOOLEAN' + return 'FLOAT' +} + +function validateVariableType( + field: keyof CanvasVariableBindings, + variable: Variable, + key: string +): void { + const expected = expectedVariableType(field) + if (variable.resolvedType !== expected) { + specError( + `Variable "${variable.id}" for ${field} on "${key}" is ${variable.resolvedType}, expected ${expected}.` + ) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isComponentPropertyVariable( + value: unknown +): value is { variable: CanvasVariableReference } { + return isRecord(value) && 'variable' in value +} + +function isShaderVariable( + value: CanvasFigmaShaderPropertyValue +): value is { variable: CanvasVariableReference } { + return isRecord(value) && 'variable' in value +} + +function collectShaderVariableReferences( + value: CanvasFigmaShaderPropertyValue, + references: CanvasVariableReference[] +): void { + if (!isRecord(value)) return + if (isShaderVariable(value)) { + references.push(value.variable) + return + } + if ('color' in value) { + collectShaderVariableReferences(value.color as CanvasFigmaShaderPropertyValue, references) + } else if ('stops' in value) { + for (const stop of value.stops as Array<{ color: CanvasFigmaShaderPropertyValue }>) { + collectShaderVariableReferences(stop.color, references) + } + } +} + +function shaderPropertyMatches( + type: ShaderPropertyDefinition['type'], + value: CanvasFigmaShaderPropertyValue +): boolean { + if (isShaderVariable(value)) return true + switch (type) { + case 'BOOLEAN': + return typeof value === 'boolean' + case 'TEXT': + case 'IMAGE': + case 'INSTANCE_SWAP': + case 'SLOT': + return typeof value === 'string' + case 'NUMBER': + return typeof value === 'number' + case 'COLOR': + return isRecord(value) && 'r' in value + case 'POINT': + return isRecord(value) && 'x' in value && Object.keys(value).length === 2 + case 'LINE': + return isRecord(value) && 'x2' in value + case 'CIRCLE': + return isRecord(value) && 'radius' in value && !('angle' in value) + case 'CIRCLE_POINT': + return isRecord(value) && 'angle' in value + case 'COLOR_POINT': + return isRecord(value) && 'color' in value + case 'GRADIENT': + return isRecord(value) && 'stops' in value + } +} + +async function preflightEffects( + effects: CanvasFigmaEffect[] | undefined, + key: string, + state: ApplyState +): Promise { + for (const [index, effect] of (effects ?? []).entries()) { + if ('variables' in effect && effect.variables) { + for (const [field, reference] of Object.entries(effect.variables)) { + const variable = await resolveVariable(reference, state.variables) + const expected = field === 'color' ? 'COLOR' : 'FLOAT' + if (variable.resolvedType !== expected) { + specError( + `Variable "${variable.id}" for effect ${index} ${field} on "${key}" is ${variable.resolvedType}, expected ${expected}.` + ) + } + } + } + if (effect.type !== 'SHADER') continue + await preflightShader(effect.id, effect.properties, 'effect', key, state) + } +} + +async function preflightShader( + id: string, + properties: Record | undefined, + type: Shader['type'], + key: string, + state: ApplyState +): Promise { + const shader = await resolveShader(id, state) + if (shader.type !== type) { + specError(`Shader "${id}" on "${key}" is a ${shader.type} shader, not a ${type} shader.`) + } + const definitions = shader.propertyDefinitions ?? {} + for (const [propertyId, value] of Object.entries(properties ?? {})) { + const definition = definitions[propertyId] + if (!definition) { + specError(`Shader "${id}" has no property "${propertyId}" on "${key}".`) + } + if (!shaderPropertyMatches(definition.type, value)) { + specError(`Shader property "${propertyId}" on "${key}" expects ${definition.type}.`) + } + const references: CanvasVariableReference[] = [] + collectShaderVariableReferences(value, references) + await Promise.all(references.map((reference) => resolveVariable(reference, state.variables))) + } +} + +async function preflightPaintVariable( + reference: CanvasVariableReference, + field: string, + index: number, + key: string, + state: ApplyState +): Promise { + const variable = await resolveVariable(reference, state.variables) + if (variable.resolvedType !== 'COLOR') { + specError( + `Variable "${variable.id}" for ${field} paint ${index} on "${key}" is ${variable.resolvedType}, expected COLOR.` + ) + } +} + +function hasCanvasKeyPattern(paints: CanvasFigmaPaint[] | undefined): boolean { + return ( + paints?.some((paint) => paint.type === 'PATTERN' && paint.sourceCanvasKey !== undefined) ?? + false + ) +} + +function hasCanvasKeyPaints(spec: CanvasNodeSpec): boolean { + return hasCanvasKeyPattern(spec.figma?.fills) || hasCanvasKeyPattern(spec.figma?.strokes) +} + +function hasCanvasKeyVectorPattern(spec: CanvasNodeSpec): boolean { + return ( + spec.figma?.shape?.type === 'VECTOR' && + (spec.figma.shape.network?.regions?.some((region) => hasCanvasKeyPattern(region.fills)) ?? + false) + ) +} + +function isCanvasKeyHyperlink( + hyperlink: CanvasHyperlink | undefined +): hyperlink is { type: 'NODE'; value: { canvasKey: string } } { + return hyperlink?.type === 'NODE' && typeof hyperlink.value !== 'string' +} + +function hasDeferredTextRanges(spec: CanvasNodeSpec): boolean { + const ranges = spec.figma?.text?.ranges + return ( + ranges !== undefined && + (hasCanvasKeyPaints(spec) || + ranges.some( + (range) => hasCanvasKeyPattern(range.fills) || isCanvasKeyHyperlink(range.hyperlink) + )) + ) +} + +async function preflightPaintStack( + paints: CanvasFigmaPaint[] | undefined, + field: string, + key: string, + state: ApplyState +): Promise { + for (const [index, paint] of (paints ?? []).entries()) { + if (paint.type === 'SOLID' && paint.variables) { + await preflightPaintVariable(paint.variables.color, field, index, key, state) + } else if ('gradientStops' in paint) { + for (const stop of paint.gradientStops) { + if (stop.variables) { + await preflightPaintVariable(stop.variables.color, field, index, key, state) + } + } + } + if (paint.type === 'IMAGE') { + if (paint.imageUrl !== undefined) { + state.imageUrls.add(paint.imageUrl) + } else if (paint.assetKey !== undefined) { + state.imageAssetKeys.add(paint.assetKey) + } else if (paint.imageHash && !figma.getImageByHash(paint.imageHash)) { + specError( + `Image "${paint.imageHash}" for ${field} paint ${index} on "${key}" does not exist.` + ) + } + } + if (paint.type === 'VIDEO' && paint.videoUrl !== undefined) { + state.videoUrls.add(paint.videoUrl) + } + if (paint.type === 'PATTERN') { + await preflightNodeReference( + paint.sourceCanvasKey + ? { canvasKey: paint.sourceCanvasKey } + : { nodeId: paint.sourceNodeId! }, + `Pattern source for ${field} paint ${index} on "${key}"`, + state, + true + ) + } + if (paint.type === 'SHADER') { + await preflightShader(paint.id, paint.properties, 'fill', key, state) + } + } +} + +async function preflightPaints(spec: CanvasNodeSpec, state: ApplyState): Promise { + await preflightPaintStack(spec.figma?.fills, 'fill', spec.key, state) + await preflightPaintStack(spec.figma?.strokes, 'stroke', spec.key, state) +} + +async function preflightVector(spec: CanvasNodeSpec, state: ApplyState): Promise { + const shape = spec.figma?.shape + if (shape?.type !== 'VECTOR') return + if (shape.paths) { + try { + canonicalVectorPaths(shape.paths) + } catch (error) { + specError( + `Vector path on "${spec.key}" is invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + for (const [index, region] of (shape.network?.regions ?? []).entries()) { + await preflightPaintStack(region.fills, `vector region ${index} fill`, spec.key, state) + if (!region.fillStyle) continue + const style = await resolveStyle(region.fillStyle, state.styles) + validateStyleType('fill', style, spec.key) + } +} + +async function preflightTextRanges(spec: CanvasNodeSpec, state: ApplyState): Promise { + for (const [index, range] of (spec.figma?.text?.ranges ?? []).entries()) { + const key = `${spec.key} text range ${index}` + if (range.fontName) await loadFont(range.fontName, state) + for (const [field, reference] of [ + ['text', range.textStyle], + ['fill', range.fillStyle] + ] as const) { + if (!reference) continue + const style = await resolveStyle(reference, state.styles) + validateStyleType(field, style, key) + if (style.type === 'TEXT') await loadFont(style.fontName, state) + } + for (const [field, reference] of Object.entries(range.variables ?? {}) as Array< + [keyof CanvasVariableBindings, CanvasVariableReference | null] + >) { + if (!reference) continue + validateVariableType(field, await resolveVariable(reference, state.variables), key) + } + if (range.hyperlink?.type === 'NODE') { + await preflightNodeReference( + typeof range.hyperlink.value === 'string' + ? { nodeId: range.hyperlink.value } + : { canvasKey: range.hyperlink.value.canvasKey }, + `Hyperlink target on "${key}"`, + state + ) + } + await preflightPaintStack(range.fills, `text range ${index} fill`, spec.key, state) + const decorationColor = range.textDecorationColor + if (decorationColor && decorationColor.value !== 'AUTO') { + await preflightPaintStack( + [decorationColor.value], + `text range ${index} decoration`, + spec.key, + state + ) + } + } +} + +async function preflightLayoutGrids( + grids: CanvasFigmaLayoutGrid[] | undefined, + key: string, + state: ApplyState +): Promise { + for (const [index, grid] of (grids ?? []).entries()) { + for (const [field, reference] of Object.entries(grid.variables ?? {})) { + const variable = await resolveVariable(reference, state.variables) + if (variable.resolvedType !== 'FLOAT') { + specError( + `Variable "${variable.id}" for layout grid ${index} ${field} on "${key}" is ${variable.resolvedType}, expected FLOAT.` + ) + } + } + } +} + +const TEXT_STYLE_VALUE_FIELDS = [ + 'fontName', + 'fontSize', + 'textDecoration', + 'letterSpacing', + 'lineHeight', + 'leadingTrim', + 'paragraphIndent', + 'paragraphSpacing', + 'listSpacing', + 'hangingPunctuation', + 'hangingList', + 'textCase' +] as const satisfies ReadonlyArray> + +const TEXT_STYLE_VARIABLES_BY_VALUE = { + fontName: ['fontFamily', 'fontStyle', 'fontWeight'], + fontSize: ['fontSize'], + textDecoration: [], + letterSpacing: ['letterSpacing'], + lineHeight: ['lineHeight'], + leadingTrim: [], + paragraphIndent: ['paragraphIndent'], + paragraphSpacing: ['paragraphSpacing'], + listSpacing: [], + hangingPunctuation: [], + hangingList: [], + textCase: [] +} satisfies Record<(typeof TEXT_STYLE_VALUE_FIELDS)[number], readonly VariableBindableTextField[]> + +type TextStyleResource = Extract + +function textStyleVariableEntries( + spec: TextStyleResource +): Array<[VariableBindableTextField, CanvasVariableReference | null]> { + return Object.entries(spec.variables ?? {}) as Array< + [VariableBindableTextField, CanvasVariableReference | null] + > +} + +async function preflightStyleResources(state: ApplyState): Promise { + for (const { key, spec } of state.styles.resources) { + switch (spec.type) { + case 'PAINT': + for (const paint of spec.paints ?? []) { + if ( + paint.type === 'PATTERN' && + paint.sourceCanvasKey !== undefined && + !state.keyedNodes.has(paint.sourceCanvasKey) + ) { + specError( + `Pattern source "${paint.sourceCanvasKey}" on Paint style "${key}" must already exist in the update scope; use sourceNodeId when creating the source separately.` + ) + } + } + await preflightPaintStack(spec.paints, 'style', key, state) + break + case 'TEXT': + if (spec.fontName) await loadFont(spec.fontName, state) + for (const [field, reference] of textStyleVariableEntries(spec)) { + if (!reference) continue + validateVariableType( + field as keyof CanvasVariableBindings, + await resolveVariable(reference, state.variables), + key + ) + } + break + case 'EFFECT': + await preflightEffects(spec.effects, key, state) + break + case 'GRID': + await preflightLayoutGrids(spec.layoutGrids, key, state) + break + } + } +} + +function componentPropertyDisplayName(propertyName: string): string { + const suffix = propertyName.lastIndexOf('#') + return suffix < 0 ? propertyName : propertyName.slice(0, suffix) +} + +function nextComponentPropertyContext( + spec: CanvasNodeSpec, + existing: SupportedCanvasNode | undefined, + inherited: ComponentPropertyContext | undefined +): ComponentPropertyContext | undefined { + if (spec.type === 'COMPONENT_SET') { + return { + spec, + ...(existing?.type === 'COMPONENT_SET' ? { existing } : {}) + } + } + if (spec.type === 'COMPONENT') { + if (inherited?.spec?.type === 'COMPONENT_SET') return inherited + if (existing?.type === 'COMPONENT' && existing.parent?.type === 'COMPONENT_SET') { + return { existing: existing.parent } + } + return { + spec, + ...(existing?.type === 'COMPONENT' ? { existing } : {}) + } + } + if (inherited) return inherited + const owner = existing ? componentPropertyOwner(existing) : null + return owner ? { existing: owner } : undefined +} + +function contextPropertyType( + context: ComponentPropertyContext, + key: string, + state: ApplyState +): ComponentPropertyType | undefined { + const desired = context.spec?.figma?.component?.properties?.[key] + if (desired !== undefined) return desired?.type + const owner = context.existing + if (!owner) return undefined + const name = componentPropertyName(owner, key, state) + return name ? owner.componentPropertyDefinitions[name]?.type : undefined +} + +function expectedComponentPropertyVariableType( + type: CanvasFigmaComponentPropertyDefinition['type'] +): VariableResolvedDataType { + return type === 'BOOLEAN' ? 'BOOLEAN' : 'STRING' +} + +async function preflightComponentPropertyDefinition( + key: string, + definition: CanvasFigmaComponentPropertyDefinition, + state: ApplyState +): Promise { + if (isComponentPropertyVariable(definition.defaultValue)) { + const variable = await resolveVariable(definition.defaultValue.variable, state.variables) + const expected = expectedComponentPropertyVariableType(definition.type) + if (variable.resolvedType !== expected) { + specError( + `Variable "${variable.id}" for component property "${key}" is ${variable.resolvedType}, expected ${expected}.` + ) + } + } else if (definition.type === 'INSTANCE_SWAP') { + await resolveComponent(definition.defaultValue, state) + } +} + +async function preflightAuthoredComponentProperties( + spec: CanvasNodeSpec, + context: ComponentPropertyContext | undefined, + state: ApplyState +): Promise { + const properties = spec.figma?.component?.properties + if (!properties) return + if (!context || context.spec !== spec) { + specError( + `Component property definitions on variant "${spec.key}" belong on its component set.` + ) + } + const owner = context.existing + const keys = owner ? componentPropertyKeys(owner, state) : undefined + for (const [key, desired] of Object.entries(properties)) { + const propertyName = owner ? (keys?.[key] ?? key) : undefined + const current = propertyName ? owner?.componentPropertyDefinitions[propertyName] : undefined + if (desired === null) { + if (!owner || (!keys?.[key] && !current)) { + specError(`Component property "${key}" on "${spec.key}" does not exist.`) + } + if (current?.type === 'VARIANT' || current?.type === 'SLOT') { + specError( + `${current.type} property "${key}" on "${spec.key}" cannot be deleted through component properties.` + ) + } + continue + } + if (current && current.type !== desired.type) { + specError( + `Component property "${key}" on "${spec.key}" is ${current.type}, expected ${desired.type}.` + ) + } + await preflightComponentPropertyDefinition(key, desired, state) + } +} + +function expectedComponentPropertyReferenceType( + field: ComponentPropertyReferenceField +): ComponentPropertyType { + if (field === 'characters') return 'TEXT' + if (field === 'mainComponent') return 'INSTANCE_SWAP' + return 'BOOLEAN' +} + +function preflightComponentPropertyReferences( + spec: CanvasNodeSpec, + existing: SupportedCanvasNode | undefined, + context: ComponentPropertyContext | undefined, + state: ApplyState +): void { + const references = spec.figma?.componentPropertyReferences + if (references) { + if (!context) { + specError(`Component property references on "${spec.key}" require a component sublayer.`) + } + for (const [field, key] of Object.entries(references) as Array< + [ComponentPropertyReferenceField, string | null] + >) { + if (key === null) continue + const actual = contextPropertyType(context, key, state) + const expected = expectedComponentPropertyReferenceType(field) + if (actual !== expected) { + specError( + `Component property reference "${key}" for ${field} on "${spec.key}" is ${actual ?? 'missing'}, expected ${expected}.` + ) + } + } + } + const effective = (field: ComponentPropertyReferenceField) => + references?.[field] === undefined + ? existing?.componentPropertyReferences?.[field] + : references[field] + if (effective('characters') && (spec.variables?.characters || spec.figma?.text?.ranges)) { + specError( + `A characters property reference on "${spec.key}" cannot be combined with a characters variable or rich-text ranges.` + ) + } + if (effective('visible') && spec.variables?.visible) { + specError( + `A visible property reference on "${spec.key}" cannot be combined with a visibility variable.` + ) + } + if (effective('mainComponent') && spec.figma?.instance?.preserveOverrides !== undefined) { + specError( + `Instance override preservation on "${spec.key}" cannot be combined with a mainComponent property reference.` + ) + } +} + +function slotPropertyName( + owner: ComponentPropertyOwner, + spec: CanvasNodeSpec, + state: ApplyState +): string | undefined { + const direct = componentPropertyName(owner, spec.key, state) + if (direct && owner.componentPropertyDefinitions[direct]?.type === 'SLOT') return direct + const desiredName = spec.figma?.slot?.property?.name + if (!desiredName) return undefined + const matches = Object.entries(owner.componentPropertyDefinitions) + .filter( + ([name, definition]) => + definition.type === 'SLOT' && componentPropertyDisplayName(name) === desiredName + ) + .map(([name]) => name) + if (matches.length > 1) { + specError(`Slot property "${desiredName}" on "${spec.key}" is ambiguous.`) + } + return matches[0] +} + +function preflightSlot( + spec: CanvasNodeSpec, + existing: SupportedCanvasNode | undefined, + context: ComponentPropertyContext | undefined, + state: ApplyState +): void { + if (spec.type !== 'SLOT') return + if (!existing) { + if (!spec.figma?.slot?.property) { + specError(`New slot "${spec.key}" requires property metadata.`) + } + if (!context) { + specError(`New slot "${spec.key}" must be nested inside an authored component.`) + } + return + } + if (existing.type !== 'SLOT' || !spec.figma?.slot?.property) return + const owner = componentPropertyOwner(existing) + const propertyName = owner ? slotPropertyName(owner, spec, state) : undefined + if (!owner || !propertyName) { + specError(`Slot property for "${spec.key}" could not be resolved.`) + } + const current = owner.componentPropertyDefinitions[propertyName] + const settings = spec.figma.slot.property.settings + if (settings) { + const merged = { ...current?.slotSettings, ...settings } + if ( + merged.minChildren != null && + merged.maxChildren != null && + merged.minChildren > merged.maxChildren + ) { + specError(`Slot minChildren on "${spec.key}" cannot exceed maxChildren.`) + } + } +} + +async function preflightComponentProperties( + spec: CanvasNodeSpec, + component: ComponentNode, + state: ApplyState +): Promise { + const definitions = + component.parent?.type === 'COMPONENT_SET' + ? component.parent.componentPropertyDefinitions + : component.componentPropertyDefinitions + for (const [name, value] of Object.entries(spec.componentProperties ?? {})) { + const definition = definitions[name] + if (!definition) { + specError(`Component "${component.id}" has no property "${name}" for "${spec.key}".`) + } + if (definition.type === 'SLOT') { + specError(`Slot property "${name}" on "${spec.key}" cannot be set with componentProperties.`) + } + if (isComponentPropertyVariable(value)) { + await resolveVariable(value.variable, state.variables) + continue + } + const expected = definition.type === 'BOOLEAN' ? 'boolean' : 'string' + if (typeof value !== expected) { + specError(`Component property "${name}" on "${spec.key}" expects ${expected}.`) + } + if ( + definition.type === 'VARIANT' && + definition.variantOptions && + !definition.variantOptions.includes(value as string) + ) { + specError(`Component property "${name}" on "${spec.key}" has no variant "${value}".`) + } + if (definition.type !== 'INSTANCE_SWAP') continue + const replacement = await figma.getNodeByIdAsync(value as string) + if (replacement?.type !== 'COMPONENT' && replacement?.type !== 'COMPONENT_SET') { + specError( + `Instance-swap property "${name}" on "${spec.key}" must reference a component node.` + ) + } + } +} + +function isPrimaryNestedInstance(node: InstanceNode): boolean { + let parent = node.parent + while (parent) { + if (parent.type === 'INSTANCE') return false + if (parent.type === 'COMPONENT' || parent.type === 'COMPONENT_SET') return true + parent = parent.parent + } + return false +} + +async function preflightVariableModes( + modes: CanvasNodeSpec['variableModes'], + state: ApplyState +): Promise { + for (const [collectionId, modeId] of Object.entries(modes ?? {})) { + const collection = await resolveCollection(collectionId, state.variables) + if (modeId !== null) await resolveModeId(collection, modeId, state.variables) + } +} + +function findOmittedChild( + specs: CanvasNodeSpec[], + parent: ChildrenMixin, + state: ApplyState +): SceneNode | undefined { + const describedIds = new Set( + specs + .map((spec) => findExistingNode(spec, state)) + .filter((node): node is SupportedCanvasNode => node !== null) + .map((node) => node.id) + ) + return parent.children.find( + (child) => !describedIds.has(child.id) && !state.removalNodeIds.has(child.id) + ) +} + +function preflightMasks( + spec: CanvasNodeSpec, + state: ApplyState, + existing: SupportedCanvasNode | null = null, + isRoot = true +): void { + if (isRoot && spec.figma?.mask != null) { + specError('The canvas root cannot be a mask because its scope would escape the desired tree.') + } + + const children = spec.children ?? [] + const hasMask = children.some((child) => child.figma?.mask != null) + const lastChild = children.at(-1) + if (lastChild?.figma?.mask != null) { + specError(`Mask "${lastChild.key}" must precede at least one sibling to mask.`) + } + + if (hasMask && existing && 'children' in existing) { + const omitted = findOmittedChild(children, existing, state) + if (omitted) { + specError( + `Mask container "${spec.key}" has omitted live child "${omitted.id}"; describe every direct child so the mask scope is deterministic.` + ) + } + } + + for (const child of children) { + preflightMasks(child, state, findExistingNode(child, state), false) + } +} + +function preflightContainers( + spec: CanvasNodeSpec, + state: ApplyState, + existing: SupportedCanvasNode | null = null +): void { + const childCount = spec.children?.length ?? 0 + if (!existing && spec.type === 'GROUP' && childCount === 0) { + specError(`New group "${spec.key}" requires at least one child.`) + } + if (!existing && spec.type === 'BOOLEAN_OPERATION' && childCount < 2) { + specError(`New boolean operation "${spec.key}" requires at least two children.`) + } + if (!existing && spec.type === 'COMPONENT_SET' && childCount === 0) { + specError(`New component set "${spec.key}" requires at least one component child.`) + } + if (spec.type === 'COMPONENT_SET' && spec.children?.some((child) => child.type !== 'COMPONENT')) { + specError(`Component set "${spec.key}" can contain only component nodes.`) + } + if ( + existing && + (existing.type === 'COMPONENT' || existing.type === 'COMPONENT_SET') && + existing.remote + ) { + specError(`Remote ${existing.type} node "${spec.key}" is read-only.`) + } + if (existing && isIntrinsicNode(existing) && spec.children?.length) { + const omitted = findOmittedChild(spec.children, existing, state) + if (omitted) { + specError( + `Intrinsic container "${spec.key}" has omitted live child "${omitted.id}"; describe every direct child when reconciling its contents.` + ) + } + } + for (const child of spec.children ?? []) { + preflightContainers(child, state, findExistingNode(child, state)) + } +} + +async function preflightResources( + spec: CanvasNodeSpec, + state: ApplyState, + existing?: SupportedCanvasNode, + inheritedComponent?: ComponentPropertyContext +): Promise { + const component = nextComponentPropertyContext(spec, existing, inheritedComponent) + if (component?.existing?.remote) { + specError(`Remote ${component.existing.type} containing "${spec.key}" is read-only.`) + } + await preflightAuthoredComponentProperties(spec, component, state) + preflightComponentPropertyReferences(spec, existing, component, state) + preflightSlot(spec, existing, component, state) + if (spec.component) { + const instanceComponent = await resolveComponent(spec.component, state) + await preflightComponentProperties(spec, instanceComponent, state) + } + if (spec.figma?.instance?.exposed !== undefined) { + const instance = existing ?? findExistingNode(spec, state) + if (instance?.type !== 'INSTANCE' || !isPrimaryNestedInstance(instance)) { + specError( + `Instance exposure on "${spec.key}" requires an existing primary instance inside a component.` + ) + } + } + if (spec.variables) { + for (const field of Object.keys(spec.variables) as Array) { + const reference = spec.variables[field] + if (!reference) continue + const variable = await resolveVariable(reference, state.variables) + validateVariableType(field, variable, spec.key) + } + } + await preflightVariableModes(spec.variableModes, state) + if (spec.styles) { + for (const field of Object.keys(spec.styles) as Array) { + const reference = spec.styles[field] + if (!reference) continue + const style = await resolveStyle(reference, state.styles) + validateStyleType(field, style, spec.key) + if (style.type === 'TEXT') await loadFont(style.fontName, state) + } + } + const hyperlink = spec.figma?.text?.hyperlink + if (hyperlink?.type === 'NODE') { + await preflightNodeReference( + typeof hyperlink.value === 'string' + ? { nodeId: hyperlink.value } + : { canvasKey: hyperlink.value.canvasKey }, + `Hyperlink target on "${spec.key}"`, + state + ) + } + await preflightPaints(spec, state) + await preflightVector(spec, state) + await preflightTextRanges(spec, state) + await preflightEffects(spec.figma?.effects, spec.key, state) + await preflightLayoutGrids(spec.figma?.layoutGrids, spec.key, state) + for (const child of spec.children ?? []) { + await preflightResources(child, state, findExistingNode(child, state) ?? undefined, component) + } +} + +async function resolveImageUrls(state: ApplyState): Promise { + for (const url of state.imageUrls) { + try { + state.imageHashes.set(url, (await figma.createImageAsync(url)).hash) + } catch { + specError('An image URL could not be loaded as a PNG, JPEG, or GIF up to 4096 by 4096 px.') + } + } +} + +function resolveImageAssets(state: ApplyState): void { + for (const key of state.imageAssetKeys) { + const asset = resolvedImageAsset(state.assets, key) + if (!asset) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.ASSET_NOT_FOUND, + `Image asset "${key}" was not resolved.` + ) + } + try { + const cachedHash = importedImageHashes.get(asset.hash) + if (cachedHash) importedImageHashes.delete(asset.hash) + const imageHash = + cachedHash && figma.getImageByHash(cachedHash) + ? cachedHash + : figma.createImage(asset.bytes).hash + importedImageHashes.set(asset.hash, imageHash) + while (importedImageHashes.size > MAX_IMPORTED_IMAGE_HASHES) { + importedImageHashes.delete(importedImageHashes.keys().next().value!) + } + state.imageHashes.set(`asset:${key}`, imageHash) + } catch { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.IMAGE_IMPORT_FAILED, + `Image asset "${key}" could not be imported as a PNG, JPEG, or GIF up to 4096 by 4096 px.` + ) + } + } +} + +async function readVideoBytes(response: Response): Promise { + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return readBoundedResponseBytes( + response, + MAX_VIDEO_BYTES, + () => new Error('Video exceeds 100MB.') + ) +} + +async function resolveVideoUrls(state: ApplyState): Promise { + for (const url of state.videoUrls) { + try { + const response = await fetch(url, { + credentials: 'omit', + signal: AbortSignal.timeout(60_000) + }) + const video = await figma.createVideoAsync(await readVideoBytes(response)) + state.videoHashes.set(url, video.hash) + } catch { + specError( + 'A video URL could not be imported as an MP4, MOV, or WebM up to 100MB. Figma video uploads require a paid team file.' + ) + } + } +} + +function recordCreatedNode(node: SupportedCanvasNode, state: ApplyState, claimed = true): void { + state.mutations.count += 1 + state.createdNodeIds.add(node.id) + if (claimed) state.claimedNodeIds.add(node.id) +} + +function containingComponentNode(parent: CanvasParentNode | undefined): ComponentNode | null { + let current: BaseNode | null = parent ?? null + while (current) { + if (current.type === 'COMPONENT') return current + current = current.parent + } + return null +} + +function createSlotNode( + spec: CanvasNodeSpec, + parent: CanvasParentNode | undefined, + state: ApplyState +): SlotNode { + const component = containingComponentNode(parent) + if (!component || component.remote) { + specError(`New slot "${spec.key}" must be nested inside a local authored component.`) + } + const owner: ComponentPropertyOwner = + component.parent?.type === 'COMPONENT_SET' ? component.parent : component + componentPropertyKeys(owner, state) + const previousNames = new Set(Object.keys(owner.componentPropertyDefinitions)) + const slot = component.createSlot() + recordCreatedNode(slot, state) + const propertyNames = Object.entries(owner.componentPropertyDefinitions) + .filter(([name, definition]) => !previousNames.has(name) && definition.type === 'SLOT') + .map(([name]) => name) + if (propertyNames.length !== 1) { + specError(`Figma did not create exactly one slot property for "${spec.key}".`) + } + setComponentPropertyKey(owner, spec.key, propertyNames[0]!, state) + return slot +} + +async function createNode(spec: CanvasNodeSpec, state: ApplyState): Promise { + let node: SupportedCanvasNode + switch (spec.type) { + case 'BOOLEAN_OPERATION': + case 'COMPONENT_SET': + case 'GROUP': + return specError(`${spec.type} nodes must be created from their children.`) + case 'SLOT': + return specError('SLOT nodes must be created by their containing component.') + case 'COMPONENT': + node = figma.createComponent() + break + case 'FRAME': + node = figma.createFrame() + break + case 'INSTANCE': { + const component = await resolveComponent(spec.component!, state) + node = component.createInstance() + break + } + case 'SECTION': + node = figma.createSection() + break + case 'TEXT': + node = figma.createText() + break + case 'RECTANGLE': + node = figma.createRectangle() + break + case 'LINE': + node = figma.createLine() + break + case 'ELLIPSE': + node = figma.createEllipse() + break + case 'POLYGON': + node = figma.createPolygon() + break + case 'STAR': + node = figma.createStar() + break + case 'VECTOR': + node = figma.createVector() + break + } + recordCreatedNode(node, state) + return node +} + +function moveIntoParent( + node: SupportedCanvasNode, + parent: CanvasParentNode, + index: number, + state: ApplyState +): void { + if (node.parent?.id === parent.id && parent.children.indexOf(node) === index) return + parent.insertChild(index, node) + markMutation(state, node) +} + +function setValue( + node: BaseNode, + current: unknown, + desired: T | undefined, + apply: (value: T) => void, + state: ApplyState +): void { + if (desired === undefined || Object.is(current, desired)) return + apply(desired) + markMutation(state, node) +} + +const PADDING_FIELDS = [ + ['top', 'paddingTop'], + ['right', 'paddingRight'], + ['bottom', 'paddingBottom'], + ['left', 'paddingLeft'] +] as const + +function applyCounterAxisSpacing( + node: CanvasFrameContainerNode, + desired: number | null | undefined, + state: ApplyState +): void { + if (desired === undefined) return + const synced = + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COUNTER_AXIS_SYNC_NAME) === 'true' + if (desired !== null) { + setValue( + node, + node.counterAxisSpacing, + desired, + (value) => (node.counterAxisSpacing = value), + state + ) + if (synced) { + node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COUNTER_AXIS_SYNC_NAME, '') + markMutation(state, node) + } + return + } + + if (!synced || !Object.is(node.counterAxisSpacing, node.itemSpacing)) { + node.counterAxisSpacing = null + markMutation(state, node) + } + if (!synced) { + node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COUNTER_AXIS_SYNC_NAME, 'true') + markMutation(state, node) + } +} + +function applyLayout( + node: CanvasFrameContainerNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const layout = spec.layout + if (!layout) return + const bindings = spec.variables + + setValue(node, node.layoutMode, layout.mode, (value) => (node.layoutMode = value), state) + if (layout.mode === 'NONE') return + + if (layout.padding !== undefined) { + for (const [side, field] of PADDING_FIELDS) { + const desired = typeof layout.padding === 'number' ? layout.padding : layout.padding[side] + setValue( + node, + node[field], + bindings?.[field] || currentBoundVariableId(node, field) ? undefined : desired, + (value) => (node[field] = value), + state + ) + } + } + setValue( + node, + node.strokesIncludedInLayout, + layout.strokesIncluded, + (value) => (node.strokesIncludedInLayout = value), + state + ) + + if (layout.mode === 'GRID') { + setValue( + node, + node.gridRowGap, + bindings?.gridRowGap || currentBoundVariableId(node, 'gridRowGap') + ? undefined + : layout.rowGap, + (value) => (node.gridRowGap = value), + state + ) + setValue( + node, + node.gridColumnGap, + bindings?.gridColumnGap || currentBoundVariableId(node, 'gridColumnGap') + ? undefined + : layout.columnGap, + (value) => (node.gridColumnGap = value), + state + ) + + const rowCount = layout.rows?.length + if (layout.autoRows !== undefined) { + setValue( + node, + node.gridAutoTracks, + layout.autoRows ? ('ROWS' as const) : ('NONE' as const), + (value) => (node.gridAutoTracks = value), + state + ) + } + if (node.gridColumnCount < layout.columns.length) { + setValue( + node, + node.gridColumnCount, + layout.columns.length, + (value) => (node.gridColumnCount = value), + state + ) + } + if (rowCount !== undefined && node.gridRowCount < rowCount) { + setValue(node, node.gridRowCount, rowCount, (value) => (node.gridRowCount = value), state) + } + return + } + + const autoLayout = spec.figma?.autoLayout + setValue( + node, + node.itemSpacing, + bindings?.gap || currentBoundVariableId(node, 'itemSpacing') + ? undefined + : (autoLayout?.itemSpacing ?? layout.gap), + (value) => (node.itemSpacing = value), + state + ) + setValue(node, node.layoutWrap, layout.wrap, (value) => (node.layoutWrap = value), state) + const counterAxisSpacing = + autoLayout?.counterAxisSpacing !== undefined ? autoLayout.counterAxisSpacing : layout.counterGap + if (!bindings?.counterAxisSpacing && !currentBoundVariableId(node, 'counterAxisSpacing')) { + applyCounterAxisSpacing(node, counterAxisSpacing, state) + } + setValue( + node, + node.itemReverseZIndex, + autoLayout?.itemReverseZIndex, + (value) => (node.itemReverseZIndex = value), + state + ) + setValue( + node, + node.primaryAxisAlignItems, + layout.primaryAlign, + (value) => (node.primaryAxisAlignItems = value), + state + ) + setValue( + node, + node.counterAxisAlignItems, + layout.counterAlign, + (value) => (node.counterAxisAlignItems = value), + state + ) + setValue( + node, + node.counterAxisAlignContent, + layout.counterAlignContent, + (value) => (node.counterAxisAlignContent = value), + state + ) +} + +const SIZE_BOUND_FIELDS = ['minWidth', 'maxWidth', 'minHeight', 'maxHeight'] as const + +function applySizingModes( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + if (isIntrinsicNode(node) || !('layoutSizingHorizontal' in node)) return + const size = spec.size + setValue( + node, + node.layoutSizingHorizontal, + size.horizontal, + (value) => (node.layoutSizingHorizontal = value), + state + ) + setValue( + node, + node.layoutSizingVertical, + size.vertical, + (value) => (node.layoutSizingVertical = value), + state + ) + setValue( + node, + node.layoutGrow, + spec.grow === undefined ? undefined : spec.grow ? 1 : 0, + (value) => (node.layoutGrow = value), + state + ) + + if (!isFrameContainer(node) || node.layoutMode === 'NONE' || node.layoutMode === 'GRID') return + const horizontalMode: 'AUTO' | 'FIXED' = size.horizontal === 'HUG' ? 'AUTO' : 'FIXED' + const verticalMode: 'AUTO' | 'FIXED' = size.vertical === 'HUG' ? 'AUTO' : 'FIXED' + if (node.layoutMode === 'HORIZONTAL') { + setValue( + node, + node.primaryAxisSizingMode, + horizontalMode, + (value) => (node.primaryAxisSizingMode = value), + state + ) + setValue( + node, + node.counterAxisSizingMode, + verticalMode, + (value) => (node.counterAxisSizingMode = value), + state + ) + } else { + setValue( + node, + node.primaryAxisSizingMode, + verticalMode, + (value) => (node.primaryAxisSizingMode = value), + state + ) + setValue( + node, + node.counterAxisSizingMode, + horizontalMode, + (value) => (node.counterAxisSizingMode = value), + state + ) + } +} + +type CrossAxisFill = { + axis: 'horizontal' | 'vertical' + size: number +} + +function clampSize(value: number, min: number | null, max: number | null): number { + return Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? 0, value)) +} + +function expectedCrossAxisFill( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent?: SupportedCanvasNode | CanvasParentNode +): CrossAxisFill | null { + if ( + isIntrinsicNode(node) || + !parent || + !isFrameContainer(parent) || + !('layoutPositioning' in node) || + parent.counterAxisSizingMode !== 'FIXED' || + parent.layoutWrap !== 'NO_WRAP' || + node.layoutPositioning !== 'AUTO' + ) { + return null + } + if (parent.layoutMode === 'VERTICAL' && spec.size.horizontal === 'FILL') { + return { + axis: 'horizontal', + size: clampSize( + Math.max(0, parent.width - parent.paddingLeft - parent.paddingRight), + node.minWidth, + node.maxWidth + ) + } + } + if (parent.layoutMode === 'HORIZONTAL' && spec.size.vertical === 'FILL') { + return { + axis: 'vertical', + size: clampSize( + Math.max(0, parent.height - parent.paddingTop - parent.paddingBottom), + node.minHeight, + node.maxHeight + ) + } + } + return null +} + +function stabilizeCrossAxisFill( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent: CanvasParentNode | undefined, + state: ApplyState +): void { + if (!('layoutSizingHorizontal' in node)) return + const expected = expectedCrossAxisFill(node, spec, parent) + if (!expected) return + const current = expected.axis === 'horizontal' ? node.width : node.height + if (Math.abs(current - expected.size) <= GEOMETRY_TOLERANCE) return + + if (expected.axis === 'horizontal') { + node.layoutSizingHorizontal = 'FIXED' + node.resize(expected.size, node.height) + node.layoutSizingHorizontal = 'FILL' + } else { + node.layoutSizingVertical = 'FIXED' + node.resize(node.width, expected.size) + node.layoutSizingVertical = 'FILL' + } + markMutation(state, node) +} + +function applySize(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { + if (isIntrinsicNode(node)) return + const size = spec.size + for (const field of SIZE_BOUND_FIELDS) { + setValue( + node, + node[field], + spec.variables?.[field] || currentBoundVariableId(node, field) ? undefined : size[field], + (value) => (node[field] = value), + state + ) + } + const width = + !spec.variables?.width && !currentBoundVariableId(node, 'width') && size.width !== undefined + ? size.width + : node.width + const height = + !spec.variables?.height && !currentBoundVariableId(node, 'height') && size.height !== undefined + ? size.height + : node.height + if (Math.abs(node.width - width) > 0.01 || Math.abs(node.height - height) > 0.01) { + node.resize(width, height) + markMutation(state, node) + } + applySizingModes(node, spec, state) +} + +function applyPosition( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent: CanvasParentNode, + state: ApplyState +): void { + if (spec.positioning !== undefined && isFrameContainer(parent) && parent.layoutMode !== 'NONE') { + if (node.type === 'SECTION') { + specError(`Section "${spec.key}" cannot be a child of an Auto Layout frame.`) + } + setValue( + node, + node.layoutPositioning, + spec.positioning, + (value) => (node.layoutPositioning = value), + state + ) + } + if (!spec.position) return + setValue(node, node.x, spec.position.x, (value) => (node.x = value), state) + setValue(node, node.y, spec.position.y, (value) => (node.y = value), state) +} + +function transformsMatch(current: Transform, desired: Transform): boolean { + return current.every((row, rowIndex) => + row.every((value, columnIndex) => Math.abs(value - desired[rowIndex]![columnIndex]!) <= 1e-6) + ) +} + +function applyRelativeTransform( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent: CanvasParentNode | undefined, + state: ApplyState +): void { + const transform = spec.figma?.relativeTransform + if (!transform) return + const current = node.relativeTransform + const autoLayoutChild = !!parent && isFrameContainer(parent) && parent.layoutMode !== 'NONE' + const desired: Transform = autoLayoutChild + ? [ + [transform[0][0], transform[0][1], current[0][2]], + [transform[1][0], transform[1][1], current[1][2]] + ] + : transform + if (transformsMatch(current, desired)) return + node.relativeTransform = desired + markMutation(state, node) +} + +function applyPaint( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + field: 'fill' | 'stroke', + state: ApplyState +): void { + if (!('fills' in node)) { + specError(`${field} paints are not supported on ${node.type} node "${spec.key}".`) + } + const color = spec.appearance?.[field] + if (color === undefined) return + + const property = field === 'fill' ? 'fills' : 'strokes' + const styleProperty = field === 'fill' ? 'fillStyleId' : 'strokeStyleId' + if (spec.styles?.[field] || (node[styleProperty] && !spec.variables?.[field])) return + const paints = node[property] + const desired = color === null ? [] : [figma.util.solidPaint(color)] + const binding = spec.variables?.[field] + const currentVariable = node.boundVariables?.[property]?.[0] + if (!binding && currentVariable) return + if (binding) { + if (color === null) { + const label = field === 'fill' ? 'Fill' : 'Stroke' + specError(`${label} variable binding on "${spec.key}" requires a solid fallback paint.`) + } + const variable = state.variables.variableCache.get(variableReferenceCacheKey(binding)) + if (variable && currentVariable?.id === variable.id) return + } + if (paints !== figma.mixed && paintStacksEqual(paints, desired)) { + return + } + + node[property] = desired + markMutation(state, node) +} + +const STROKE_WEIGHT_FIELDS = [ + 'strokeTopWeight', + 'strokeRightWeight', + 'strokeBottomWeight', + 'strokeLeftWeight' +] as const satisfies ReadonlyArray +const CORNER_RADIUS_FIELDS = [ + 'topLeftRadius', + 'topRightRadius', + 'bottomRightRadius', + 'bottomLeftRadius' +] as const satisfies ReadonlyArray + +function hasDesiredVariable( + spec: CanvasNodeSpec, + fields: ReadonlyArray +): boolean { + return fields.some((field) => spec.variables?.[field] !== undefined) +} + +function hasCurrentVariable( + node: SupportedCanvasNode, + fields: ReadonlyArray +): boolean { + return fields.some((field) => currentBoundVariableId(node, field) !== undefined) +} + +function applyIndividualValue( + node: SupportedCanvasNode, + current: number, + desired: number | undefined, + field: VariableBindableNodeField, + uniformField: VariableBindableNodeField, + spec: CanvasNodeSpec, + apply: (value: number) => void, + state: ApplyState +): void { + if (spec.variables?.[uniformField as keyof CanvasVariableBindings]) return + if (currentBoundVariableId(node, uniformField)) return + if (spec.variables?.[field as keyof CanvasVariableBindings]) return + if (currentBoundVariableId(node, field)) return + setValue(node, current, desired, apply, state) +} + +function numbersEqual(current: readonly number[], desired: readonly number[]): boolean { + return ( + current.length === desired.length && current.every((value, index) => value === desired[index]) + ) +} + +function applyAppearance(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { + const appearance = spec.appearance + if (!appearance) return + + if ('fills' in node) { + applyPaint(node, spec, 'fill', state) + applyPaint(node, spec, 'stroke', state) + } + if ('strokeWeight' in node && (!node.strokeStyleId || spec.styles?.stroke)) { + setValue( + node, + node.strokeWeight, + appearance.strokeTopWeight !== undefined || + spec.variables?.strokeWeight || + hasDesiredVariable(spec, STROKE_WEIGHT_FIELDS) || + currentBoundVariableId(node, 'strokeWeight') || + hasCurrentVariable(node, STROKE_WEIGHT_FIELDS) + ? undefined + : appearance.strokeWeight, + (value) => (node.strokeWeight = value), + state + ) + if ('strokeTopWeight' in node) { + applyIndividualValue( + node, + node.strokeTopWeight, + appearance.strokeTopWeight, + 'strokeTopWeight', + 'strokeWeight', + spec, + (value) => (node.strokeTopWeight = value), + state + ) + applyIndividualValue( + node, + node.strokeRightWeight, + appearance.strokeRightWeight, + 'strokeRightWeight', + 'strokeWeight', + spec, + (value) => (node.strokeRightWeight = value), + state + ) + applyIndividualValue( + node, + node.strokeBottomWeight, + appearance.strokeBottomWeight, + 'strokeBottomWeight', + 'strokeWeight', + spec, + (value) => (node.strokeBottomWeight = value), + state + ) + applyIndividualValue( + node, + node.strokeLeftWeight, + appearance.strokeLeftWeight, + 'strokeLeftWeight', + 'strokeWeight', + spec, + (value) => (node.strokeLeftWeight = value), + state + ) + } + } + if ('cornerRadius' in node) { + setValue( + node, + node.cornerRadius, + appearance.topLeftRadius !== undefined || + spec.variables?.cornerRadius || + hasDesiredVariable(spec, CORNER_RADIUS_FIELDS) || + currentBoundVariableId(node, 'cornerRadius') || + hasCurrentVariable(node, CORNER_RADIUS_FIELDS) + ? undefined + : appearance.cornerRadius, + (value) => (node.cornerRadius = value), + state + ) + if ('topLeftRadius' in node) { + applyIndividualValue( + node, + node.topLeftRadius, + appearance.topLeftRadius, + 'topLeftRadius', + 'cornerRadius', + spec, + (value) => (node.topLeftRadius = value), + state + ) + applyIndividualValue( + node, + node.topRightRadius, + appearance.topRightRadius, + 'topRightRadius', + 'cornerRadius', + spec, + (value) => (node.topRightRadius = value), + state + ) + applyIndividualValue( + node, + node.bottomRightRadius, + appearance.bottomRightRadius, + 'bottomRightRadius', + 'cornerRadius', + spec, + (value) => (node.bottomRightRadius = value), + state + ) + applyIndividualValue( + node, + node.bottomLeftRadius, + appearance.bottomLeftRadius, + 'bottomLeftRadius', + 'cornerRadius', + spec, + (value) => (node.bottomLeftRadius = value), + state + ) + } + } + if ('clipsContent' in node) { + setValue( + node, + node.clipsContent, + appearance.clipsContent, + (value) => (node.clipsContent = value), + state + ) + } + if ('opacity' in node) { + setValue( + node, + node.opacity, + spec.variables?.opacity || currentBoundVariableId(node, 'opacity') + ? undefined + : appearance.opacity, + (value) => (node.opacity = value), + state + ) + } + + const stroke = spec.figma?.stroke + if (stroke) { + if (!('strokeAlign' in node)) { + specError(`Stroke geometry is not supported on ${node.type} node "${spec.key}".`) + } + setValue(node, node.strokeAlign, stroke.align, (value) => (node.strokeAlign = value), state) + if ('strokeCap' in node) { + setValue(node, node.strokeCap, stroke.cap, (value) => (node.strokeCap = value), state) + } + setValue(node, node.strokeJoin, stroke.join, (value) => (node.strokeJoin = value), state) + if ('strokeMiterLimit' in node) { + setValue( + node, + node.strokeMiterLimit, + stroke.miterLimit, + (value) => (node.strokeMiterLimit = value), + state + ) + } + if (stroke.dashPattern !== undefined && !numbersEqual(node.dashPattern, stroke.dashPattern)) { + node.dashPattern = stroke.dashPattern + markMutation(state, node) + } + } + if ('cornerSmoothing' in node) { + setValue( + node, + node.cornerSmoothing, + spec.figma?.corners?.smoothing, + (value) => (node.cornerSmoothing = value), + state + ) + } +} + +function resolvedComponent(reference: CanvasDesignReference, state: ApplyState): ComponentNode { + const component = state.componentCache.get(designReferenceCacheKey(reference)) + if (!component) specError('A preflighted component could not be resolved.') + return component +} + +function nativeShaderValue( + value: CanvasFigmaShaderPropertyValue, + state: ApplyState +): ShaderPropertyValue { + if (!isRecord(value)) return value + if (isShaderVariable(value)) { + return figma.variables.createVariableAlias(resolvedVariable(value.variable, state.variables)) + } + if ('color' in value) { + return { + ...value, + color: nativeShaderValue(value.color as CanvasFigmaShaderPropertyValue, state) as + | RGB + | RGBA + | VariableAlias + } + } + if ('stops' in value) { + return { + stops: ( + value.stops as Array<{ + position: number + color: CanvasFigmaShaderPropertyValue + }> + ).map((stop) => ({ + position: stop.position, + color: nativeShaderValue(stop.color, state) as RGB | RGBA | VariableAlias + })) + } + } + return value +} + +function nativeShaderProperties( + id: string, + values: Record | undefined, + state: ApplyState +): Record | undefined { + const shader = state.shaderCache.get(id) + if (!shader) specError(`Shader "${id}" was not preflighted.`) + const properties = Object.fromEntries( + Object.entries(shader.propertyDefinitions ?? {}) + .filter(([, definition]) => definition.defaultValue !== undefined) + .map(([propertyId, definition]) => [propertyId, definition.defaultValue!]) + ) as Record + for (const [propertyId, value] of Object.entries(values ?? {})) { + properties[propertyId] = nativeShaderValue(value, state) + } + return Object.keys(properties).length ? properties : undefined +} + +function paintDefaults(paint: { visible?: boolean; opacity?: number; blendMode?: BlendMode }) { + return { + visible: paint.visible ?? true, + opacity: paint.opacity ?? 1, + blendMode: paint.blendMode ?? 'NORMAL' + } as const +} + +function nativePaint(paint: CanvasFigmaPaint, state: ApplyState): Paint { + switch (paint.type) { + case 'SOLID': { + const { variables, ...fields } = paint + const value: SolidPaint = { + ...fields, + ...paintDefaults(fields) + } + return variables + ? figma.variables.setBoundVariableForPaint( + value, + 'color', + resolvedVariable(variables.color, state.variables) + ) + : value + } + case 'GRADIENT_LINEAR': + case 'GRADIENT_RADIAL': + case 'GRADIENT_ANGULAR': + case 'GRADIENT_DIAMOND': + return { + ...paint, + gradientStops: paint.gradientStops.map(({ variables, ...stop }) => ({ + ...stop, + ...(variables + ? { + boundVariables: { + color: figma.variables.createVariableAlias( + resolvedVariable(variables.color, state.variables) + ) + } + } + : {}) + })), + ...paintDefaults(paint) + } + case 'IMAGE': { + const { assetKey, imageUrl, ...fields } = paint + return { + ...fields, + imageHash: + assetKey !== undefined + ? state.imageHashes.get(`asset:${assetKey}`)! + : imageUrl === undefined + ? (fields.imageHash ?? null) + : state.imageHashes.get(imageUrl)!, + ...paintDefaults(paint) + } + } + case 'VIDEO': { + const { videoUrl, ...fields } = paint + return { + ...fields, + videoHash: + videoUrl === undefined ? (fields.videoHash ?? null) : state.videoHashes.get(videoUrl)!, + ...paintDefaults(paint) + } + } + case 'PATTERN': { + const { sourceCanvasKey, ...fields } = paint + return { + ...fields, + sourceNodeId: + sourceCanvasKey === undefined + ? fields.sourceNodeId! + : resolveCanvasKey(sourceCanvasKey, state).id, + ...paintDefaults(paint) + } + } + case 'SHADER': { + const { properties: values, ...fields } = paint + const properties = nativeShaderProperties(paint.id, values, state) + return { + ...fields, + ...paintDefaults(paint), + ...(properties ? { properties } : {}) + } + } + } +} + +function bindEffectVariables( + effect: Effect, + bindings: + | NonNullable['variables']> + | undefined, + state: ApplyState +): Effect { + let bound = effect + for (const [field, reference] of Object.entries(bindings ?? {})) { + bound = figma.variables.setBoundVariableForEffect( + bound, + field as VariableBindableEffectField, + resolvedVariable(reference, state.variables) + ) + } + return bound +} + +function nativeEffect(effect: CanvasFigmaEffect, state: ApplyState): Effect { + switch (effect.type) { + case 'DROP_SHADOW': + case 'INNER_SHADOW': { + const { variables, ...fields } = effect + return bindEffectVariables( + { + ...fields, + ...(variables?.spread !== undefined && fields.spread === undefined ? { spread: 0 } : {}), + visible: fields.visible ?? true, + blendMode: fields.blendMode ?? 'NORMAL' + }, + variables, + state + ) + } + case 'LAYER_BLUR': + case 'BACKGROUND_BLUR': { + const { variables, ...fields } = effect + return bindEffectVariables({ ...fields, visible: fields.visible ?? true }, variables, state) + } + case 'NOISE': + return { + ...effect, + visible: effect.visible ?? true, + blendMode: effect.blendMode ?? 'NORMAL' + } + case 'TEXTURE': + case 'GLASS': + return { ...effect, visible: effect.visible ?? true } + case 'SHADER': { + const properties = nativeShaderProperties(effect.id, effect.properties, state) + return { + type: 'SHADER', + id: effect.id, + visible: effect.visible ?? true, + ...(properties ? { properties } : {}) + } + } + } +} + +function comparableEntries(value: Record): Array<[string, unknown]> { + return Object.entries(value).filter( + ([key, field]) => + !( + (key === 'boundVariables' || key === 'properties') && + isRecord(field) && + !Object.keys(field).length + ) + ) +} + +function nativeValueEqual(current: unknown, desired: unknown): boolean { + if (Object.is(current, desired)) return true + if (Array.isArray(current) || Array.isArray(desired)) { + return ( + Array.isArray(current) && + Array.isArray(desired) && + current.length === desired.length && + current.every((value, index) => nativeValueEqual(value, desired[index])) + ) + } + if (!isRecord(current) || !isRecord(desired)) return false + const currentEntries = comparableEntries(current) + const desiredEntries = comparableEntries(desired) + return ( + currentEntries.length === desiredEntries.length && + currentEntries.every(([key, value]) => nativeValueEqual(value, desired[key])) + ) +} + +function nativeLayoutGrid(grid: CanvasFigmaLayoutGrid, state: ApplyState): LayoutGrid { + const { variables, ...fields } = grid + let native: LayoutGrid = + fields.pattern === 'GRID' + ? fields + : { + ...fields, + count: fields.count === 'AUTO' ? Infinity : fields.count + } + for (const [field, reference] of Object.entries(variables ?? {}) as Array< + [VariableBindableLayoutGridField, CanvasVariableReference] + >) { + native = figma.variables.setBoundVariableForLayoutGrid( + native, + field, + resolvedVariable(reference, state.variables) + ) + } + return native +} + +function comparableLayoutGrid(grid: LayoutGrid): LayoutGrid { + return { + ...grid, + visible: grid.visible ?? true + } +} + +function layoutGridsEqual(current: readonly LayoutGrid[], desired: readonly LayoutGrid[]): boolean { + return ( + current.length === desired.length && + current.every((grid, index) => + nativeValueEqual(comparableLayoutGrid(grid), comparableLayoutGrid(desired[index]!)) + ) + ) +} + +const IMAGE_FILTER_FIELDS = [ + 'exposure', + 'contrast', + 'saturation', + 'temperature', + 'tint', + 'highlights', + 'shadows' +] as const satisfies ReadonlyArray + +function comparablePaint(paint: Paint): Paint { + if (paint.type === 'IMAGE' || paint.type === 'VIDEO') { + return { + ...paint, + ...paintDefaults(paint), + filters: Object.fromEntries( + IMAGE_FILTER_FIELDS.map((field) => [field, paint.filters?.[field] ?? 0]) + ) + } + } + return { + ...paint, + ...paintDefaults(paint) + } +} + +function paintStacksEqual(current: readonly Paint[], desired: readonly Paint[]): boolean { + return ( + current.length === desired.length && + current.every((paint, index) => { + const expected = desired[index]! + return ( + paint.type === expected.type && + nativeValueEqual(comparablePaint(paint), comparablePaint(expected)) + ) + }) + ) +} + +function isShadowEffect(effect: Effect): effect is DropShadowEffect | InnerShadowEffect { + return effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW' +} + +function comparableShadow(effect: DropShadowEffect | InnerShadowEffect): Effect { + return { + ...effect, + spread: effect.spread ?? 0, + ...(effect.type === 'DROP_SHADOW' + ? { showShadowBehindNode: effect.showShadowBehindNode ?? false } + : {}) + } +} + +function effectsEqual(current: readonly Effect[], desired: readonly Effect[]): boolean { + if (current.length !== desired.length) return false + return current.every((effect, index) => { + const expected = desired[index]! + if (effect.type !== expected.type) return false + if (isShadowEffect(effect) && isShadowEffect(expected)) { + return nativeValueEqual(comparableShadow(effect), comparableShadow(expected)) + } + return nativeValueEqual(effect, expected) + }) +} + +function setStyleValue( + current: T, + desired: T | undefined, + apply: (value: T) => void, + state: ApplyState, + equal: (left: T, right: T) => boolean = nativeValueEqual +): void { + if (desired === undefined || equal(current, desired)) return + apply(desired) + state.mutations.count += 1 +} + +function applyStyleMetadata(style: BaseStyle, spec: CanvasStyleResource, state: ApplyState): void { + setStyleValue(style.name, spec.name, (value) => (style.name = value), state) + setStyleValue( + style.descriptionMarkdown, + spec.descriptionMarkdown, + (value) => (style.descriptionMarkdown = value), + state + ) + if (spec.documentationLink === undefined) return + const links = spec.documentationLink === null ? [] : [{ uri: spec.documentationLink }] + setStyleValue( + style.documentationLinks, + links, + (value) => (style.documentationLinks = value), + state + ) +} + +function applyTextStyle(style: TextStyle, spec: TextStyleResource, state: ApplyState): void { + for (const [field, reference] of textStyleVariableEntries(spec)) { + if (reference !== null || !style.boundVariables?.[field]) continue + style.setBoundVariable(field, null) + state.mutations.count += 1 + } + for (const field of TEXT_STYLE_VALUE_FIELDS) { + const desired = spec[field] + if ( + desired === undefined || + TEXT_STYLE_VARIABLES_BY_VALUE[field].some( + (variableField) => style.boundVariables?.[variableField] + ) || + nativeValueEqual(style[field], desired) + ) { + continue + } + Object.assign(style, { [field]: desired }) + state.mutations.count += 1 + } + for (const [field, reference] of textStyleVariableEntries(spec)) { + if (!reference) continue + const variable = resolvedVariable(reference, state.variables) + if (style.boundVariables?.[field]?.id === variable.id) continue + style.setBoundVariable(field, variable) + state.mutations.count += 1 + } +} + +function applyStyleResources(state: ApplyState): void { + for (const { spec, style } of state.styles.resources) { + applyStyleMetadata(style, spec, state) + switch (spec.type) { + case 'PAINT': { + if (spec.paints === undefined) break + const desired = spec.paints.map((paint) => nativePaint(paint, state)) + setStyleValue( + (style as PaintStyle).paints, + desired, + (value) => ((style as PaintStyle).paints = value), + state, + paintStacksEqual + ) + break + } + case 'TEXT': + applyTextStyle(style as TextStyle, spec, state) + break + case 'EFFECT': { + if (spec.effects === undefined) break + const desired = spec.effects.map((effect) => nativeEffect(effect, state)) + setStyleValue( + (style as EffectStyle).effects, + desired, + (value) => ((style as EffectStyle).effects = value), + state, + effectsEqual + ) + break + } + case 'GRID': { + if (spec.layoutGrids === undefined) break + const desired = spec.layoutGrids.map((grid) => nativeLayoutGrid(grid, state)) + setStyleValue( + (style as GridStyle).layoutGrids, + desired, + (value) => ((style as GridStyle).layoutGrids = value), + state, + layoutGridsEqual + ) + break + } + } + } +} + +function applyPaintStacks( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + for (const [property, styleProperty] of [ + ['fills', 'fillStyleId'], + ['strokes', 'strokeStyleId'] + ] as const) { + const paints = spec.figma?.[property] + if (paints === undefined) continue + if (!('fills' in node)) { + specError(`Direct paints are not supported on ${node.type} node "${spec.key}".`) + } + const desired = paints.map((paint) => nativePaint(paint, state)) + const current = node[property] + if (current !== figma.mixed && !node[styleProperty] && paintStacksEqual(current, desired)) { + continue + } + node[property] = desired + markMutation(state, node) + } +} + +function validateShadowSpread(node: SupportedCanvasNode, spec: CanvasNodeSpec): void { + const hasSpread = spec.figma?.effects?.some( + (effect) => + (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') && + (effect.spread !== undefined || effect.variables?.spread !== undefined) + ) + if (!hasSpread || node.type === 'RECTANGLE' || node.type === 'ELLIPSE') return + if ( + (isFrameContainer(node) || node.type === 'INSTANCE') && + node.clipsContent && + node.fills !== figma.mixed && + node.fills.some((paint) => paint.visible ?? true) + ) { + return + } + specError( + `Shadow spread on "${spec.key}" requires a rectangle, ellipse, or a clipped frame/instance with a visible fill; authored components and component sets count as frames.` + ) +} + +function applyEffects(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { + const effects = spec.figma?.effects + if (effects === undefined) return + if (!('effects' in node)) { + specError(`Effects are not supported on ${node.type} node "${spec.key}".`) + } + validateShadowSpread(node, spec) + const desired = effects.map((effect) => nativeEffect(effect, state)) + if (!node.effectStyleId && effectsEqual(node.effects, desired)) return + node.effects = desired + markMutation(state, node) +} + +function applyLayoutAids(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { + if (!isFrameContainer(node) && node.type !== 'INSTANCE') return + const layoutGrids = spec.figma?.layoutGrids + if (layoutGrids !== undefined) { + const desired = layoutGrids.map((grid) => nativeLayoutGrid(grid, state)) + if (node.gridStyleId || !layoutGridsEqual(node.layoutGrids, desired)) { + node.layoutGrids = desired + markMutation(state, node) + } + } + applyGuides(node, spec.figma?.guides, state) +} + +function applyGuides( + node: CanvasFrameContainerNode | InstanceNode | PageNode, + guides: CanvasPageProperties['guides'], + state: ApplyState +): void { + if (guides === undefined || nativeValueEqual(node.guides, guides)) return + node.guides = guides + markMutation(state, node) +} + +async function nativeVectorNetwork( + network: CanvasFigmaVectorNetwork, + state: ApplyState +): Promise { + const { regions, ...geometry } = network + if (!regions) return geometry + + return { + ...geometry, + regions: await Promise.all( + regions.map(async ({ fills, fillStyle, ...region }) => ({ + ...region, + ...(fills === undefined ? {} : { fills: fills.map((paint) => nativePaint(paint, state)) }), + ...(fillStyle ? { fillStyleId: (await resolveStyle(fillStyle, state.styles)).id } : {}) + })) + ) + } +} + +function comparableVectorNetwork(network: VectorNetwork): unknown { + return { + vertices: network.vertices, + segments: network.segments.map((segment) => ({ + ...segment, + tangentStart: segment.tangentStart ?? { x: 0, y: 0 }, + tangentEnd: segment.tangentEnd ?? { x: 0, y: 0 } + })), + regions: (network.regions ?? []).map(({ fills, fillStyleId, ...region }) => ({ + ...region, + ...(fillStyleId + ? { fillStyleId } + : fills === undefined + ? {} + : { fills: fills.map(comparablePaint) }) + })) + } +} + +function vectorNetworksEqual(current: VectorNetwork, desired: VectorNetwork): boolean { + return nativeValueEqual(comparableVectorNetwork(current), comparableVectorNetwork(desired)) +} + +async function applyShape( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState, + resolveCanvasReferences = false +): Promise { + const shape = spec.figma?.shape + if (!shape) return + + switch (shape.type) { + case 'RECTANGLE': + case 'LINE': + return + case 'ELLIPSE': { + if (!shape.arc) return + if (node.type !== 'ELLIPSE') specError(`Native shape "${spec.key}" is not an ellipse.`) + const desired = { + startingAngle: (shape.arc.startAngle * Math.PI) / 180, + endingAngle: (shape.arc.endAngle * Math.PI) / 180, + innerRadius: shape.arc.innerRadius + } + const current = node.arcData + if ( + Math.abs(current.startingAngle - desired.startingAngle) <= 1e-6 && + Math.abs(current.endingAngle - desired.endingAngle) <= 1e-6 && + Math.abs(current.innerRadius - desired.innerRadius) <= 1e-6 + ) { + return + } + node.arcData = desired + markMutation(state, node) + return + } + case 'POLYGON': + if (shape.pointCount === undefined) return + if (node.type !== 'POLYGON') specError(`Native shape "${spec.key}" is not a polygon.`) + setValue(node, node.pointCount, shape.pointCount, (value) => (node.pointCount = value), state) + return + case 'STAR': + if (node.type !== 'STAR') specError(`Native shape "${spec.key}" is not a star.`) + setValue(node, node.pointCount, shape.pointCount, (value) => (node.pointCount = value), state) + setValue( + node, + node.innerRadius, + shape.innerRadius, + (value) => (node.innerRadius = value), + state + ) + return + case 'VECTOR': { + if (node.type !== 'VECTOR') specError(`Native shape "${spec.key}" is not a vector.`) + setValue( + node, + node.handleMirroring, + shape.handleMirroring, + (value) => (node.handleMirroring = value), + state + ) + if (shape.paths !== undefined) { + if (vectorPathsEqual(node.vectorPaths, shape.paths)) return + node.vectorPaths = canonicalVectorPaths(shape.paths) + markMutation(state, node) + return + } + if (shape.network !== undefined) { + if (!resolveCanvasReferences && hasCanvasKeyVectorPattern(spec)) return + const network = await nativeVectorNetwork(shape.network, state) + if (vectorNetworksEqual(node.vectorNetwork, network)) return + await node.setVectorNetworkAsync(network) + markMutation(state, node) + } + } + } +} + +async function loadTextFonts( + node: TextNode, + spec: CanvasNodeSpec, + state: ApplyState +): Promise { + const text = spec.text + const currentFont = node.fontName + const hasTextStyle = !!(spec.styles?.text || node.textStyleId) + const fontFamily = + hasTextStyle || spec.variables?.fontFamily || currentBoundVariableId(node, 'fontFamily') + ? undefined + : text?.fontFamily + const fontStyle = + hasTextStyle || spec.variables?.fontStyle || currentBoundVariableId(node, 'fontStyle') + ? undefined + : text?.fontStyle + const hasExplicitFont = fontFamily !== undefined || fontStyle !== undefined + if (currentFont === figma.mixed && hasExplicitFont && (!fontFamily || !fontStyle)) { + specError( + `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` + ) + } + + const desiredFont: FontName | null = hasExplicitFont + ? { + family: fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family), + style: fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style) + } + : null + const fonts = desiredFont + ? [desiredFont] + : currentFont === figma.mixed + ? node.getRangeAllFontNames(0, node.characters.length) + : [currentFont] + await loadFonts(fonts, state) + return desiredFont +} + +function preservesComponentPropertyReference( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + field: ComponentPropertyReferenceField +): boolean { + const desired = spec.figma?.componentPropertyReferences?.[field] + return desired === undefined + ? node.componentPropertyReferences?.[field] !== undefined + : desired !== null +} + +async function applyText(node: TextNode, spec: CanvasNodeSpec, state: ApplyState): Promise { + const text = spec.text + if (!text) return + const native = spec.figma?.text + const desiredFont = await loadTextFonts(node, spec, state) + const hasTextStyle = !!(spec.styles?.text || node.textStyleId) + if ( + desiredFont && + (node.fontName === figma.mixed || + node.fontName.family !== desiredFont.family || + node.fontName.style !== desiredFont.style) + ) { + node.fontName = desiredFont + markMutation(state, node) + } + setValue( + node, + node.textAutoResize, + text.autoResize, + (value) => (node.textAutoResize = value), + state + ) + setValue(node, node.autoRename, native?.autoRename, (value) => (node.autoRename = value), state) + setValue( + node, + node.characters, + preservesComponentPropertyReference(node, spec, 'characters') || + spec.variables?.characters || + currentBoundVariableId(node, 'characters') + ? undefined + : text.characters, + (value) => (node.characters = value), + state + ) + setValue( + node, + node.fontSize, + hasTextStyle || spec.variables?.fontSize || currentBoundVariableId(node, 'fontSize') + ? undefined + : text.fontSize, + (value) => (node.fontSize = value), + state + ) + setTextMeasure( + node, + node.lineHeight, + hasTextStyle || spec.variables?.lineHeight || currentBoundVariableId(node, 'lineHeight') + ? undefined + : text.lineHeight, + (value) => (node.lineHeight = value), + state + ) + setTextMeasure( + node, + node.letterSpacing, + hasTextStyle || spec.variables?.letterSpacing || currentBoundVariableId(node, 'letterSpacing') + ? undefined + : text.letterSpacing, + (value) => (node.letterSpacing = value), + state + ) + setValue( + node, + node.textAlignHorizontal, + text.alignHorizontal, + (value) => (node.textAlignHorizontal = value), + state + ) + setValue( + node, + node.textAlignVertical, + text.alignVertical, + (value) => (node.textAlignVertical = value), + state + ) + setValue( + node, + node.textCase, + native?.case ?? (hasTextStyle ? undefined : text.textCase), + (value) => (node.textCase = value), + state + ) + setValue( + node, + node.textDecoration, + hasTextStyle ? undefined : text.textDecoration, + (value) => (node.textDecoration = value), + state + ) + setValue( + node, + node.textTruncation, + text.textTruncation, + (value) => (node.textTruncation = value), + state + ) + setValue(node, node.maxLines, text.maxLines, (value) => (node.maxLines = value), state) + setValue( + node, + node.paragraphIndent, + spec.variables?.paragraphIndent || currentBoundVariableId(node, 'paragraphIndent') + ? undefined + : native?.paragraphIndent, + (value) => (node.paragraphIndent = value), + state + ) + setValue( + node, + node.paragraphSpacing, + spec.variables?.paragraphSpacing || currentBoundVariableId(node, 'paragraphSpacing') + ? undefined + : native?.paragraphSpacing, + (value) => (node.paragraphSpacing = value), + state + ) + setValue( + node, + node.listSpacing, + native?.listSpacing, + (value) => (node.listSpacing = value), + state + ) + setValue( + node, + node.hangingPunctuation, + native?.hangingPunctuation, + (value) => (node.hangingPunctuation = value), + state + ) + setValue( + node, + node.hangingList, + native?.hangingList, + (value) => (node.hangingList = value), + state + ) + setValue( + node, + node.leadingTrim, + native?.leadingTrim, + (value) => (node.leadingTrim = value), + state + ) + if (!isCanvasKeyHyperlink(native?.hyperlink)) { + applyTextHyperlink(node, native?.hyperlink, state) + } +} + +function textMeasuresEqual( + current: LineHeight | LetterSpacing | typeof figma.mixed, + desired: LineHeight | LetterSpacing +): boolean { + return ( + current !== figma.mixed && + current.unit === desired.unit && + (current.unit === 'AUTO' || (desired.unit !== 'AUTO' && current.value === desired.value)) + ) +} + +function setTextMeasure( + node: TextNode, + current: T | typeof figma.mixed, + desired: T | undefined, + apply: (value: T) => void, + state: ApplyState +): void { + if (desired === undefined || textMeasuresEqual(current, desired)) return + apply(desired) + markMutation(state, node) +} + +function hyperlinksEqual( + current: HyperlinkTarget | null | typeof figma.mixed, + desired: HyperlinkTarget | null +): boolean { + return ( + current !== figma.mixed && + (current === desired || + (!!current && !!desired && current.type === desired.type && current.value === desired.value)) + ) +} + +function nativeHyperlink( + hyperlink: CanvasHyperlink | undefined, + state: ApplyState +): HyperlinkTarget | null | undefined { + if (hyperlink === undefined || hyperlink === null || hyperlink.type === 'URL') { + return hyperlink + } + return { + type: 'NODE', + value: + typeof hyperlink.value === 'string' + ? hyperlink.value + : resolveCanvasKey(hyperlink.value.canvasKey, state).id + } +} + +function applyTextHyperlink( + node: TextNode, + hyperlink: CanvasHyperlink | undefined, + state: ApplyState +): void { + const desired = nativeHyperlink(hyperlink, state) + if (desired === undefined || hyperlinksEqual(node.hyperlink, desired)) return + node.hyperlink = desired + markMutation(state, node) +} + +function applyTextRangeValue( + node: TextNode, + current: T | typeof figma.mixed | null, + desired: T | undefined, + apply: (value: T) => void, + state: ApplyState +): void { + if (desired === undefined || nativeValueEqual(current, desired)) return + apply(desired) + markMutation(state, node) +} + +async function applyTextRangeStyle( + node: TextNode, + reference: CanvasStyleReference | null | undefined, + current: () => string | typeof figma.mixed, + apply: (id: string) => Promise, + state: ApplyState +): Promise { + if (reference === undefined) return + const styleId = reference ? (await resolveStyle(reference, state.styles)).id : '' + if (current() === styleId) return + await apply(styleId) + markMutation(state, node) +} + +function applyTextRangeFills(node: TextNode, range: CanvasFigmaTextRange, state: ApplyState): void { + if (range.fills === undefined) return + const desired = range.fills.map((paint) => nativePaint(paint, state)) + const current = node.getRangeFills(range.start, range.end) + const style = node.getRangeFillStyleId(range.start, range.end) + if (current !== figma.mixed && !style && paintStacksEqual(current, desired)) return + node.setRangeFills(range.start, range.end, desired) + markMutation(state, node) +} + +function nativeTextDecorationColor( + range: CanvasFigmaTextRange, + state: ApplyState +): TextDecorationColor | undefined { + const color = range.textDecorationColor + if (!color || color.value === 'AUTO') return color + return { value: nativePaint(color.value, state) as SolidPaint } +} + +type FontVariableBindings = Pick + +function resolvedFontVariableValue( + node: TextNode, + reference: CanvasVariableReference, + state: ApplyState +): string { + const value = resolvedVariable(reference, state.variables).resolveForConsumer(node).value + if (typeof value !== 'string') { + specError('A preflighted font variable did not resolve to a string.') + } + return value +} + +async function loadVariableFonts( + node: TextNode, + bindings: FontVariableBindings | undefined, + state: ApplyState, + range?: Pick +): Promise { + const familyReference = bindings?.fontFamily + const styleReference = bindings?.fontStyle + if (!familyReference && !styleReference) return + + const currentFonts = !familyReference || !styleReference ? currentTextFonts(node, range) : [] + const families = familyReference + ? [resolvedFontVariableValue(node, familyReference, state)] + : currentFonts.map((font) => font.family) + const styles = styleReference + ? [resolvedFontVariableValue(node, styleReference, state)] + : currentFonts.map((font) => font.style) + const fonts: FontName[] = [] + for (const family of families) { + for (const style of styles) { + fonts.push({ family, style }) + } + } + await loadFonts(fonts, state) +} + +async function applyTextRangeVariables( + node: TextNode, + range: CanvasFigmaTextRange, + state: ApplyState +): Promise { + await loadVariableFonts(node, range.variables, state, range) + for (const [field, reference] of Object.entries(range.variables ?? {}) as Array< + [VariableBindableTextField, CanvasVariableReference | null] + >) { + const variable = reference ? resolvedVariable(reference, state.variables) : null + const current = node.getRangeBoundVariable(range.start, range.end, field) + if (current !== figma.mixed && current?.id === variable?.id) continue + node.setRangeBoundVariable(range.start, range.end, field, variable) + markMutation(state, node) + } +} + +async function applyTextRanges( + node: TextNode, + ranges: CanvasFigmaTextRange[] | undefined, + state: ApplyState +): Promise { + for (const range of ranges ?? []) { + if (range.end > node.characters.length) { + specError( + `Text range ${range.start}:${range.end} exceeds TEXT node "${node.id}" with ${node.characters.length} UTF-16 code units.` + ) + } + await applyTextRangeStyle( + node, + range.textStyle, + () => node.getRangeTextStyleId(range.start, range.end), + (id) => node.setRangeTextStyleIdAsync(range.start, range.end, id), + state + ) + await applyTextRangeStyle( + node, + range.fillStyle, + () => node.getRangeFillStyleId(range.start, range.end), + (id) => node.setRangeFillStyleIdAsync(range.start, range.end, id), + state + ) + applyTextRangeFills(node, range, state) + applyTextRangeValue( + node, + node.getRangeFontName(range.start, range.end), + range.fontName, + (value) => node.setRangeFontName(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeFontSize(range.start, range.end), + range.fontSize, + (value) => node.setRangeFontSize(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextCase(range.start, range.end), + range.textCase, + (value) => node.setRangeTextCase(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeLetterSpacing(range.start, range.end), + range.letterSpacing, + (value) => node.setRangeLetterSpacing(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeLineHeight(range.start, range.end), + range.lineHeight, + (value) => node.setRangeLineHeight(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecoration(range.start, range.end), + range.textDecoration, + (value) => node.setRangeTextDecoration(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecorationStyle(range.start, range.end), + range.textDecorationStyle, + (value) => node.setRangeTextDecorationStyle(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecorationOffset(range.start, range.end), + range.textDecorationOffset, + (value) => node.setRangeTextDecorationOffset(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecorationThickness(range.start, range.end), + range.textDecorationThickness, + (value) => node.setRangeTextDecorationThickness(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecorationColor(range.start, range.end), + nativeTextDecorationColor(range, state), + (value) => node.setRangeTextDecorationColor(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecorationSkipInk(range.start, range.end), + range.textDecorationSkipInk, + (value) => node.setRangeTextDecorationSkipInk(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeListOptions(range.start, range.end), + range.listOptions, + (value) => node.setRangeListOptions(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeListSpacing(range.start, range.end), + range.listSpacing, + (value) => node.setRangeListSpacing(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeIndentation(range.start, range.end), + range.indentation, + (value) => node.setRangeIndentation(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeParagraphIndent(range.start, range.end), + range.paragraphIndent, + (value) => node.setRangeParagraphIndent(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeParagraphSpacing(range.start, range.end), + range.paragraphSpacing, + (value) => node.setRangeParagraphSpacing(range.start, range.end, value), + state + ) + const hyperlink = nativeHyperlink(range.hyperlink, state) + if ( + hyperlink !== undefined && + !hyperlinksEqual(node.getRangeHyperlink(range.start, range.end), hyperlink) + ) { + node.setRangeHyperlink(range.start, range.end, hyperlink) + markMutation(state, node) + } + await applyTextRangeVariables(node, range, state) + } +} + +async function applyComponent( + node: InstanceNode, + spec: CanvasNodeSpec, + state: ApplyState +): Promise { + const instance = spec.figma?.instance + if (!preservesComponentPropertyReference(node, spec, 'mainComponent')) { + const component = await resolveComponent(spec.component!, state) + const currentComponent = await node.getMainComponentAsync() + if (currentComponent?.id !== component.id) { + if (instance?.preserveOverrides === false) node.mainComponent = component + else node.swapComponent(component) + markMutation(state, node) + } + } + + setValue( + node, + node.scaleFactor, + instance?.scaleFactor, + (value) => (node.scaleFactor = value), + state + ) + setValue( + node, + node.isExposedInstance, + instance?.exposed, + (value) => (node.isExposedInstance = value), + state + ) + + const changedProperties = Object.entries(spec.componentProperties ?? {}).filter( + ([name, value]) => { + const current = node.componentProperties[name] + return isComponentPropertyVariable(value) + ? current?.boundVariables?.value?.id !== + resolvedVariable(value.variable, state.variables).id + : current?.value !== value || current?.boundVariables?.value !== undefined + } + ) + if (changedProperties.length) { + node.setProperties( + Object.fromEntries( + changedProperties.map(([name, value]) => [ + name, + isComponentPropertyVariable(value) + ? figma.variables.createVariableAlias(resolvedVariable(value.variable, state.variables)) + : value + ]) + ) + ) + markMutation(state, node) + } +} + +const STYLE_FIELDS = ['fill', 'stroke', 'text', 'effect', 'grid'] as const + +type StyleField = (typeof STYLE_FIELDS)[number] + +function styleTarget( + node: SupportedCanvasNode, + field: StyleField +): { + apply: (id: string) => Promise + current: string | symbol + text?: TextNode +} { + switch (field) { + case 'fill': + if (!('fillStyleId' in node)) { + specError(`Fill styles are not supported on ${node.type} nodes.`) + } + return { + current: node.fillStyleId, + apply: (id) => node.setFillStyleIdAsync(id) + } + case 'stroke': + if (!('strokeStyleId' in node)) { + specError(`Stroke styles are not supported on ${node.type} nodes.`) + } + return { + current: node.strokeStyleId, + apply: (id) => node.setStrokeStyleIdAsync(id) + } + case 'text': + if (node.type !== 'TEXT') specError(`Text styles require a TEXT node, not ${node.type}.`) + return { + current: node.textStyleId, + apply: (id) => node.setTextStyleIdAsync(id), + text: node + } + case 'effect': + if (!('effectStyleId' in node)) { + specError(`Effect styles are not supported on ${node.type} nodes.`) + } + return { + current: node.effectStyleId, + apply: (id) => node.setEffectStyleIdAsync(id) + } + case 'grid': + if (!isFrameContainer(node) && node.type !== 'INSTANCE') { + specError('Grid styles require a frame container or instance node.') + } + return { + current: node.gridStyleId, + apply: (id) => node.setGridStyleIdAsync(id) + } + } +} + +async function setStyleLink( + node: SupportedCanvasNode, + field: StyleField, + id: string, + state: ApplyState +): Promise { + const target = styleTarget(node, field) + if (target.current === id) return + if (!id && target.text) await loadFonts(currentTextFonts(target.text), state) + await target.apply(id) + markMutation(state, node) +} + +async function unlinkStyles( + node: SupportedCanvasNode, + bindings: CanvasStyleBindings | undefined, + state: ApplyState +): Promise { + for (const field of STYLE_FIELDS) { + if (bindings?.[field] !== null) continue + await setStyleLink(node, field, '', state) + } +} + +async function applyStyles( + node: SupportedCanvasNode, + bindings: CanvasStyleBindings | undefined, + state: ApplyState +): Promise { + if (!bindings) return + for (const field of STYLE_FIELDS) { + const reference = bindings[field] + if (!reference) continue + const style = await resolveStyle(reference, state.styles) + await setStyleLink(node, field, style.id, state) + } +} + +type DirectVariableField = Exclude + +const DIRECT_VARIABLE_FIELDS: Record< + DirectVariableField, + VariableBindableNodeField | VariableBindableTextField +> = { + characters: 'characters', + visible: 'visible', + width: 'width', + height: 'height', + minWidth: 'minWidth', + maxWidth: 'maxWidth', + minHeight: 'minHeight', + maxHeight: 'maxHeight', + gap: 'itemSpacing', + counterAxisSpacing: 'counterAxisSpacing', + gridRowGap: 'gridRowGap', + gridColumnGap: 'gridColumnGap', + paddingTop: 'paddingTop', + paddingRight: 'paddingRight', + paddingBottom: 'paddingBottom', + paddingLeft: 'paddingLeft', + cornerRadius: 'cornerRadius', + topLeftRadius: 'topLeftRadius', + topRightRadius: 'topRightRadius', + bottomRightRadius: 'bottomRightRadius', + bottomLeftRadius: 'bottomLeftRadius', + strokeWeight: 'strokeWeight', + strokeTopWeight: 'strokeTopWeight', + strokeRightWeight: 'strokeRightWeight', + strokeBottomWeight: 'strokeBottomWeight', + strokeLeftWeight: 'strokeLeftWeight', + opacity: 'opacity', + fontFamily: 'fontFamily', + fontStyle: 'fontStyle', + fontWeight: 'fontWeight', + fontSize: 'fontSize', + lineHeight: 'lineHeight', + letterSpacing: 'letterSpacing', + paragraphIndent: 'paragraphIndent', + paragraphSpacing: 'paragraphSpacing' +} + +function currentBoundVariableId( + node: SupportedCanvasNode, + field: VariableBindableNodeField | VariableBindableTextField +): string | undefined { + const value = node.boundVariables?.[field] + const directId = Array.isArray(value) ? value[0]?.id : value?.id + if (directId || field !== 'cornerRadius') return directId + + const aliases = [ + node.boundVariables?.topLeftRadius, + node.boundVariables?.topRightRadius, + node.boundVariables?.bottomLeftRadius, + node.boundVariables?.bottomRightRadius + ] + const radiusId = aliases[0]?.id + return radiusId && aliases.every((alias) => alias?.id === radiusId) ? radiusId : undefined +} + +function applyPaintVariable( + node: SupportedCanvasNode, + field: 'fill' | 'stroke', + variable: Variable | null, + state: ApplyState +): void { + if (!('fills' in node)) { + specError(`${field} variables are not supported on ${node.type} node "${node.id}".`) + } + const property = field === 'fill' ? 'fills' : 'strokes' + const currentVariable = node.boundVariables?.[property]?.[0] + if (currentVariable?.id === variable?.id || (!currentVariable && !variable)) return + const styleId = field === 'fill' ? node.fillStyleId : node.strokeStyleId + if (!variable && styleId) { + specError( + `${field} variable bindings cannot be cleared without replacing the existing Paint style on node "${node.id}".` + ) + } + + const currentPaints = node[property] + if (currentPaints === figma.mixed) { + specError(`${field} variable bindings cannot target mixed paints on node "${node.id}".`) + } + const paints = [...currentPaints] + if (paints.length !== 1 || paints[0]?.type !== 'SOLID') { + specError(`${field} variable bindings require exactly one solid paint on node "${node.id}".`) + } + paints[0] = figma.variables.setBoundVariableForPaint(paints[0], 'color', variable) + node[property] = paints + markMutation(state, node) +} + +function clearVariables( + node: SupportedCanvasNode, + bindings: CanvasVariableBindings | undefined, + state: ApplyState +): void { + if (!bindings) return + for (const field of Object.keys(bindings) as Array) { + if (bindings[field] !== null) continue + if (field === 'fill' || field === 'stroke') { + applyPaintVariable(node, field, null, state) + continue + } + const figmaField = DIRECT_VARIABLE_FIELDS[field] + if (!currentBoundVariableId(node, figmaField)) continue + node.setBoundVariable(figmaField, null) + markMutation(state, node) + } +} + +async function applyVariables( + node: SupportedCanvasNode, + bindings: CanvasVariableBindings | undefined, + state: ApplyState +): Promise { + if (!bindings) return + if (node.type === 'TEXT') await loadVariableFonts(node, bindings, state) + for (const field of Object.keys(bindings) as Array) { + const reference = bindings[field] + if (!reference) continue + const variable = await resolveVariable(reference, state.variables) + if (field === 'fill' || field === 'stroke') { + applyPaintVariable(node, field, variable, state) + continue + } + const figmaField = DIRECT_VARIABLE_FIELDS[field] + if (currentBoundVariableId(node, figmaField) === variable.id) continue + node.setBoundVariable(figmaField, variable) + markMutation(state, node) + } +} + +function applyVariableModes( + node: SupportedCanvasNode | PageNode, + modes: CanvasNodeSpec['variableModes'], + state: ApplyState +): void { + for (const [collectionReference, modeReference] of Object.entries(modes ?? {})) { + const collection = resolvedCollection(collectionReference, state.variables) + const current = node.explicitVariableModes[collection.id] + if (modeReference === null) { + if (current === undefined) continue + node.clearExplicitVariableModeForCollection(collection) + } else { + const modeId = resolvedModeId(collection, modeReference, state.variables) + if (current === modeId) continue + node.setExplicitVariableModeForCollection(collection, modeId) + } + markMutation(state, node) + } +} + +function applyPage(page: PageNode, properties: CanvasPageProperties, state: ApplyState): void { + if (properties.index !== undefined) { + const current = figma.root.children.indexOf(page) + if (current !== properties.index) { + figma.root.insertChild(properties.index, page) + markMutation(state, page) + } + } + setValue(page, page.name, properties.name, (value) => (page.name = value), state) + if (properties.background) { + const { a: opacity, ...color } = properties.background + const background: SolidPaint[] = [{ type: 'SOLID', color, opacity }] + if (!paintStacksEqual(page.backgrounds, background)) { + page.backgrounds = background + markMutation(state, page) + } + } + applyGuides(page, properties.guides, state) + applyVariableModes(page, properties.variableModes, state) +} + +function nativeComponentPropertyDefault( + definition: CanvasFigmaComponentPropertyDefinition, + state: ApplyState +): string | boolean | VariableAlias { + const value = definition.defaultValue + if (isComponentPropertyVariable(value)) { + return figma.variables.createVariableAlias(resolvedVariable(value.variable, state.variables)) + } + return definition.type === 'INSTANCE_SWAP' + ? resolvedComponent(value as CanvasDesignReference, state).id + : (value as string | boolean) +} + +function componentPropertyDefaultMatches( + current: ComponentPropertyDefinitions[string], + desired: string | boolean | VariableAlias +): boolean { + return isRecord(desired) + ? current.boundVariables?.defaultValue?.id === desired.id + : current.boundVariables?.defaultValue === undefined && current.defaultValue === desired +} + +function componentPropertyOptions( + definition: CanvasFigmaComponentPropertyDefinition +): ComponentPropertyOptions | undefined { + return definition.type === 'INSTANCE_SWAP' && definition.preferredValues !== undefined + ? { preferredValues: definition.preferredValues } + : undefined +} + +function applyAuthoredComponentProperties( + owner: ComponentPropertyOwner, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const properties = spec.figma?.component?.properties + if (!properties) return + const keys = componentPropertyKeys(owner, state) + for (const [key, desired] of Object.entries(properties)) { + const propertyName = keys[key] ?? key + const current = owner.componentPropertyDefinitions[propertyName] + if (desired === null) { + if (!current) continue + if (!keys[key]) setComponentPropertyKey(owner, key, propertyName, state) + owner.deleteComponentProperty(propertyName) + markMutation(state, owner) + continue + } + + const defaultValue = nativeComponentPropertyDefault(desired, state) + if (!current) { + const createdName = owner.addComponentProperty( + desired.name, + desired.type, + defaultValue, + componentPropertyOptions(desired) + ) + markMutation(state, owner) + setComponentPropertyKey(owner, key, createdName, state) + continue + } + + const edit: { + name?: string + defaultValue?: string | boolean | VariableAlias + preferredValues?: InstanceSwapPreferredValue[] + } = {} + if (componentPropertyDisplayName(propertyName) !== desired.name) { + edit.name = desired.name + } + if (!componentPropertyDefaultMatches(current, defaultValue)) { + edit.defaultValue = defaultValue + } + if ( + desired.type === 'INSTANCE_SWAP' && + desired.preferredValues !== undefined && + !nativeValueEqual(current.preferredValues ?? [], desired.preferredValues) + ) { + edit.preferredValues = desired.preferredValues + } + if (!Object.keys(edit).length) continue + const editedName = owner.editComponentProperty(propertyName, edit) + markMutation(state, owner) + setComponentPropertyKey(owner, key, editedName, state) + } +} + +function slotSettingsChanged( + current: SlotSettings | undefined, + desired: NonNullable +): boolean { + return Object.entries(desired).some( + ([field, value]) => current?.[field as keyof SlotSettings] !== value + ) +} + +function applySlotProperty(node: SlotNode, spec: CanvasNodeSpec, state: ApplyState): void { + const desired = spec.figma?.slot?.property + if (!desired) return + const owner = componentPropertyOwner(node) + if (!owner) specError(`Slot "${spec.key}" has no authored component owner.`) + const propertyName = slotPropertyName(owner, spec, state) + if (!propertyName) specError(`Slot property for "${spec.key}" could not be resolved.`) + const current = owner.componentPropertyDefinitions[propertyName] + if (!current || current.type !== 'SLOT') { + specError(`Component property "${propertyName}" for "${spec.key}" is not a slot.`) + } + const edit: { + name?: string + preferredValues?: InstanceSwapPreferredValue[] + description?: string + slotSettings?: SlotSettings + } = {} + if (componentPropertyDisplayName(propertyName) !== desired.name) { + edit.name = desired.name + } + if ( + desired.preferredValues !== undefined && + !nativeValueEqual(current.preferredValues ?? [], desired.preferredValues) + ) { + edit.preferredValues = desired.preferredValues + } + if (desired.description !== undefined && current.description !== desired.description) { + edit.description = desired.description + } + if (desired.settings && slotSettingsChanged(current.slotSettings, desired.settings)) { + edit.slotSettings = { ...current.slotSettings, ...desired.settings } + } + if (!Object.keys(edit).length) return + const editedName = owner.editComponentProperty(propertyName, edit) + markMutation(state, owner) + setComponentPropertyKey(owner, spec.key, editedName, state) +} + +function applyComponentPropertyReferences( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const desired = spec.figma?.componentPropertyReferences + if (!desired) return + const owner = componentPropertyOwner(node) + if (!owner) { + specError(`Component property references on "${spec.key}" require a component sublayer.`) + } + const next = { ...(node.componentPropertyReferences ?? {}) } + for (const [field, key] of Object.entries(desired) as Array< + [ComponentPropertyReferenceField, string | null] + >) { + if (key === null) { + delete next[field] + continue + } + const propertyName = componentPropertyName(owner, key, state) + if (!propertyName || !owner.componentPropertyDefinitions[propertyName]) { + specError(`Component property reference "${key}" on "${spec.key}" could not be resolved.`) + } + next[field] = propertyName + } + const references = Object.keys(next).length ? next : null + if (nativeValueEqual(node.componentPropertyReferences, references)) return + node.componentPropertyReferences = references + markMutation(state, node) +} + +function applyComponentMetadata( + node: ComponentNode | ComponentSetNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const metadata = spec.figma!.component! + setValue( + node, + node.descriptionMarkdown, + metadata.descriptionMarkdown, + (value) => (node.descriptionMarkdown = value), + state + ) + if (metadata.documentationLink === undefined) return + const links = metadata.documentationLink === null ? [] : [{ uri: metadata.documentationLink }] + if (nativeValueEqual(node.documentationLinks, links)) return + node.documentationLinks = links + markMutation(state, node) +} + +function isOwnedSvgChild(node: SceneNode): node is FrameNode { + return ( + node.type === 'FRAME' && + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_CHILD_NAME) === 'true' + ) +} + +function countSceneNodes(node: SceneNode): number { + return ( + 1 + + ('children' in node + ? node.children.reduce((count, child) => count + countSceneNodes(child), 0) + : 0) + ) +} + +function placeSvgChild(child: FrameNode, wrapper: FrameNode, state: ApplyState): void { + if ( + ![child.width, child.height, wrapper.width, wrapper.height].every( + (value) => Number.isFinite(value) && value > 0 + ) + ) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.SVG_IMPORT_FAILED, + 'SVG import produced invalid geometry.' + ) + } + const scale = Math.min(wrapper.width / child.width, wrapper.height / child.height) + let changed = false + if (Math.abs(scale - 1) > 0.0001) { + child.rescale(scale) + changed = true + } + const x = (wrapper.width - child.width) / 2 + const y = (wrapper.height - child.height) / 2 + if (Math.abs(child.x - x) > 0.001 || Math.abs(child.y - y) > 0.001) { + child.x = x + child.y = y + changed = true + } + if (changed) state.mutations.count += 1 +} + +function setSvgMetadata( + wrapper: FrameNode, + digest: string, + color: string | undefined, + state: ApplyState +): void { + let changed = false + for (const [name, value] of [ + [CANVAS_SVG_DIGEST_NAME, digest], + [CANVAS_SVG_COLOR_NAME, color?.toUpperCase() ?? ''], + [CANVAS_SVG_POLICY_NAME, SVG_POLICY_VERSION] + ] as const) { + if (wrapper.getSharedPluginData(CANVAS_KEY_NAMESPACE, name) === value) continue + wrapper.setSharedPluginData(CANVAS_KEY_NAMESPACE, name, value) + changed = true + } + if (changed) markMutation(state, wrapper) +} + +async function applySvg( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): Promise { + const placement = spec.figma?.svg + if (!placement) return + if (node.type !== 'FRAME') { + specError(`SVG binding "${spec.key}" requires a frame wrapper.`) + } + const asset = resolvedSvgAsset(state.assets, placement.assetKey, placement.color) + if (!asset) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.ASSET_NOT_FOUND, + `SVG asset "${placement.assetKey}" was not resolved.` + ) + } + const owned = node.children.filter(isOwnedSvgChild) + const unexpected = node.children.filter((child) => !isOwnedSvgChild(child)) + if (owned.length > 1 || unexpected.length) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.SVG_WRAPPER_DIRTY, + `SVG wrapper "${spec.key}" contains unexpected children.` + ) + } + if ( + owned.length === 1 && + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_DIGEST_NAME) === asset.digest + ) { + placeSvgChild(owned[0]!, node, state) + setSvgMetadata(node, asset.digest, placement.color, state) + return + } + + let imported: FrameNode + try { + imported = figma.createNodeFromSvg(asset.svg) + } catch { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.SVG_IMPORT_FAILED, + `SVG asset "${placement.assetKey}" could not be imported by Figma.` + ) + } + if ( + !Number.isFinite(imported.width) || + !Number.isFinite(imported.height) || + imported.width <= 0 || + imported.height <= 0 || + countSceneNodes(imported) > 500 + ) { + imported.remove() + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.SVG_IMPORT_FAILED, + `SVG asset "${placement.assetKey}" produced invalid or excessive Figma layers.` + ) + } + imported.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_CHILD_NAME, 'true') + node.appendChild(imported) + state.mutations.count += 1 + placeSvgChild(imported, node, state) + for (const child of owned) { + child.remove() + state.mutations.count += 1 + } + setSvgMetadata(node, asset.digest, placement.color, state) +} + +async function applyNodeProperties( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState, + parent?: CanvasParentNode +): Promise { + if (node.type === 'INSTANCE') await applyComponent(node, spec, state) + applyVariableModes(node, spec.variableModes, state) + await unlinkStyles(node, spec.styles, state) + clearVariables(node, spec.variables, state) + setValue( + node, + node.name, + node.type === 'TEXT' && spec.figma?.text?.autoRename ? undefined : spec.displayName, + (value) => (node.name = value), + state + ) + if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') { + applyComponentMetadata(node, spec, state) + applyAuthoredComponentProperties(node, spec, state) + } + if (node.type === 'SLOT') applySlotProperty(node, spec, state) + if (isFrameContainer(node)) applyLayout(node, spec, state) + if (node.type === 'BOOLEAN_OPERATION') { + setValue( + node, + node.booleanOperation, + spec.figma?.booleanOperation, + (value) => (node.booleanOperation = value), + state + ) + } + if (node.type === 'SECTION') { + setValue( + node, + node.sectionContentsHidden, + spec.figma?.section?.contentsHidden, + (value) => (node.sectionContentsHidden = value), + state + ) + } + await applyShape(node, spec, state) + applySize(node, spec, state) + await applySvg(node, spec, state) + if (parent) applyPosition(node, spec, parent, state) + applyRelativeTransform(node, spec, parent, state) + applyAppearance(node, spec, state) + await applyStyles(node, spec.styles, state) + applyLayoutAids(node, spec, state) + if (!hasCanvasKeyPaints(spec)) { + applyPaintStacks(node, spec, state) + } + applyEffects(node, spec, state) + if (node.type === 'TEXT') await applyText(node, spec, state) + await applyVariables(node, spec.variables, state) + if (node.type === 'TEXT' && !hasDeferredTextRanges(spec)) { + await applyTextRanges(node, spec.figma?.text?.ranges, state) + } + applySharedLayerState(node, spec, state) + applyComponentPropertyReferences(node, spec, state) + // Text and layout setters can leave a derived sizing mode or its geometry stale. + if (node.type === 'TEXT') applySizingModes(node, spec, state) + stabilizeCrossAxisFill(node, spec, parent, state) +} + +function collectSvgColors(root: CanvasNodeSpec): Map> { + const colors = new Map>() + const visit = (spec: CanvasNodeSpec): void => { + const svg = spec.figma?.svg + if (svg) { + const values = colors.get(svg.assetKey) ?? new Set() + values.add(svg.color) + colors.set(svg.assetKey, values) + } + for (const child of spec.children ?? []) visit(child) + } + visit(root) + return colors +} + +async function applyCanvasKeyReferences( + spec: CanvasNodeSpec, + state: ApplyState, + parent?: CanvasParentNode +): Promise { + const node = figma.getNodeById(state.nodeIdsByKey[spec.key]!) + if (!isSupportedSceneNode(node)) { + specError(`Desired node "${spec.key}" was not reconciled.`) + } + if (hasCanvasKeyVectorPattern(spec)) { + await applyShape(node, spec, state, true) + applySize(node, spec, state) + if (parent) applyPosition(node, spec, parent, state) + applyRelativeTransform(node, spec, parent, state) + } + if (hasCanvasKeyPaints(spec)) { + applyPaintStacks(node, spec, state) + } + if (node.type === 'TEXT') { + if (isCanvasKeyHyperlink(spec.figma?.text?.hyperlink)) { + applyTextHyperlink(node, spec.figma.text.hyperlink, state) + } + if (hasDeferredTextRanges(spec)) { + await applyTextRanges(node, spec.figma?.text?.ranges, state) + } + } + stabilizeCrossAxisFill(node, spec, parent, state) + for (const child of spec.children ?? []) { + await applyCanvasKeyReferences(child, state, node as CanvasParentNode) + } +} + +function rotationsMatch(current: number, desired: number): boolean { + const delta = ((((current - desired + 180) % 360) + 360) % 360) - 180 + return Math.abs(delta) <= 0.001 +} + +function applySharedLayerState( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + setValue( + node, + node.visible, + preservesComponentPropertyReference(node, spec, 'visible') || + spec.variables?.visible || + currentBoundVariableId(node, 'visible') + ? undefined + : spec.visible, + (value) => (node.visible = value), + state + ) + if ('blendMode' in node) { + setValue(node, node.blendMode, spec.blendMode, (value) => (node.blendMode = value), state) + } + if ( + 'rotation' in node && + spec.rotation !== undefined && + !rotationsMatch(node.rotation, spec.rotation) + ) { + node.rotation = spec.rotation + markMutation(state, node) + } + const aspectRatioLocked = spec.figma?.aspectRatioLocked + if (aspectRatioLocked !== undefined) { + if (!('targetAspectRatio' in node)) { + specError(`Aspect-ratio locking is not supported on ${node.type} node "${spec.key}".`) + } + if ((node.targetAspectRatio !== null) !== aspectRatioLocked) { + if (aspectRatioLocked) node.lockAspectRatio() + else node.unlockAspectRatio() + markMutation(state, node) + } + } + setValue(node, node.locked, spec.figma?.locked, (value) => (node.locked = value), state) +} + +function applyMask(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { + const mask = spec.figma?.mask + if (mask === undefined) return + if (!('isMask' in node)) { + specError(`Masks are not supported on ${node.type} node "${spec.key}".`) + } + if (mask !== null) { + setValue(node, node.maskType, mask, (value) => (node.maskType = value), state) + } + setValue(node, node.isMask, mask !== null, (value) => (node.isMask = value), state) +} + +function applyGridTracks( + node: CanvasFrameContainerNode, + field: 'gridColumnSizes' | 'gridRowSizes', + desired: CanvasGridTrack[], + state: ApplyState, + preserveTrailing = false +): void { + const current = node[field] + if (current.length < desired.length || (!preserveTrailing && current.length !== desired.length)) { + specError(`Figma returned ${current.length} ${field}, expected ${desired.length}.`) + } + for (const [index, track] of desired.entries()) { + const target = current[index]! + const valueMatches = + track.type === 'HUG' || + (target.value ?? (target.type === 'FLEX' ? 1 : undefined)) === track.value + if (target.type === track.type && valueMatches) continue + target.type = track.type + if (track.type !== 'HUG') target.value = track.value + markMutation(state, node) + } +} + +type GridChildNode = Exclude + +function applyGridChildAlignment( + node: GridChildNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const grid = spec.gridChild + if (!grid) return + setValue( + node, + node.gridChildHorizontalAlign, + grid.horizontalAlign, + (value) => (node.gridChildHorizontalAlign = value), + state + ) + setValue( + node, + node.gridChildVerticalAlign, + grid.verticalAlign, + (value) => (node.gridChildVerticalAlign = value), + state + ) +} + +function setGridChildSpans( + node: GridChildNode, + rowSpan: number, + columnSpan: number, + state: ApplyState +): void { + setValue(node, node.gridRowSpan, rowSpan, (value) => (node.gridRowSpan = value), state) + setValue(node, node.gridColumnSpan, columnSpan, (value) => (node.gridColumnSpan = value), state) +} + +function setGridChildPosition( + node: GridChildNode, + row: number, + column: number, + state: ApplyState +): void { + if (node.gridRowAnchorIndex === row && node.gridColumnAnchorIndex === column) return + node.setGridChildPosition(row, column) + markMutation(state, node) +} + +type ReconciledGridChild = { + node: GridChildNode + spec: CanvasNodeSpec +} + +type ReconciledChild = { + node: SupportedCanvasNode + spec: CanvasNodeSpec +} + +function liveGridExtent(node: CanvasFrameContainerNode): { columns: number; rows: number } { + return node.children.reduce( + (extent, child) => + 'gridRowAnchorIndex' in child + ? { + columns: Math.max(extent.columns, child.gridColumnAnchorIndex + child.gridColumnSpan), + rows: Math.max(extent.rows, child.gridRowAnchorIndex + child.gridRowSpan) + } + : extent, + { columns: 1, rows: 1 } + ) +} + +function finalizeManualGrid( + node: CanvasFrameContainerNode, + layout: CanvasGridLayout, + children: ReconciledGridChild[], + state: ApplyState +): void { + const autoRows = layout.autoRows ?? (layout.rows === undefined && node.gridAutoTracks === 'ROWS') + const rowCount = + layout.rows?.length ?? + (autoRows + ? Math.max(1, ...children.map(({ spec }) => spec.gridChild!.row! + spec.gridChild!.rowSpan)) + : node.gridRowCount) + setValue( + node, + node.gridItemsPositioning, + 'MANUAL' as const, + (value) => (node.gridItemsPositioning = value), + state + ) + + const moving = children.filter(({ node: child, spec }) => { + const grid = spec.gridChild! + return ( + child.gridRowAnchorIndex !== grid.row || + child.gridColumnAnchorIndex !== grid.column || + child.gridRowSpan !== grid.rowSpan || + child.gridColumnSpan !== grid.columnSpan + ) + }) + if (moving.length) { + setValue( + node, + node.gridAutoTracks, + 'NONE' as const, + (value) => (node.gridAutoTracks = value), + state + ) + const stagingStart = Math.max(node.gridRowCount, rowCount) + setValue( + node, + node.gridRowCount, + stagingStart + moving.length, + (value) => (node.gridRowCount = value), + state + ) + for (const [index, { node: child }] of moving.entries()) { + setGridChildSpans(child, 1, 1, state) + setGridChildPosition(child, stagingStart + index, 0, state) + } + } + + for (const { node: child, spec } of moving) { + const grid = spec.gridChild! + setGridChildPosition(child, grid.row!, grid.column!, state) + setGridChildSpans(child, grid.rowSpan, grid.columnSpan, state) + } + const extent = liveGridExtent(node) + const finalColumnCount = Math.max(layout.columns.length, extent.columns) + setValue( + node, + node.gridColumnCount, + finalColumnCount, + (value) => (node.gridColumnCount = value), + state + ) + if (!autoRows || moving.length) { + const finalRowCount = autoRows ? extent.rows : Math.max(rowCount, extent.rows) + setValue(node, node.gridRowCount, finalRowCount, (value) => (node.gridRowCount = value), state) + } + + applyGridTracks( + node, + 'gridColumnSizes', + layout.columns, + state, + finalColumnCount > layout.columns.length + ) + if (layout.rows) { + applyGridTracks( + node, + 'gridRowSizes', + layout.rows, + state, + node.gridRowCount > layout.rows.length + ) + } + if (autoRows && moving.length) { + setValue( + node, + node.gridAutoTracks, + 'ROWS' as const, + (value) => (node.gridAutoTracks = value), + state + ) + } +} + +function finalizeFlowGrid( + node: CanvasFrameContainerNode, + layout: CanvasGridLayout, + children: ReconciledGridChild[], + state: ApplyState +): void { + setValue( + node, + node.gridItemsPositioning, + 'ROW_AUTO_FLOW' as const, + (value) => (node.gridItemsPositioning = value), + state + ) + for (const { node: child, spec } of children) { + const grid = spec.gridChild! + setGridChildSpans(child, grid.rowSpan, grid.columnSpan, state) + } + const extent = liveGridExtent(node) + const finalColumnCount = Math.max(layout.columns.length, extent.columns) + setValue( + node, + node.gridColumnCount, + finalColumnCount, + (value) => (node.gridColumnCount = value), + state + ) + + if (layout.rows) { + const finalRowCount = Math.max(layout.rows.length, extent.rows) + setValue(node, node.gridRowCount, finalRowCount, (value) => (node.gridRowCount = value), state) + applyGridTracks(node, 'gridRowSizes', layout.rows, state, finalRowCount > layout.rows.length) + } + applyGridTracks( + node, + 'gridColumnSizes', + layout.columns, + state, + finalColumnCount > layout.columns.length + ) +} + +function finalizeGrid( + node: CanvasFrameContainerNode, + spec: CanvasNodeSpec, + children: ReconciledChild[], + state: ApplyState +): void { + const layout = spec.layout + if (layout?.mode !== 'GRID') return + const gridChildren: ReconciledGridChild[] = children + .filter(({ spec: child }) => child.gridChild) + .map((child) => { + if (child.node.type === 'SECTION') { + specError(`Section "${child.spec.key}" cannot be a child of a grid frame.`) + } + return { ...child, node: child.node } + }) + if ((layout.itemsPositioning ?? node.gridItemsPositioning) === 'MANUAL') { + finalizeManualGrid(node, layout, gridChildren, state) + } else { + finalizeFlowGrid(node, layout, gridChildren, state) + } + for (const child of gridChildren) { + applyGridChildAlignment(child.node, child.spec, state) + } +} + +function createWrappedContainer( + spec: WrappedContainerSpec, + children: SupportedCanvasNode[], + parent: CanvasParentNode | undefined, + index: number, + state: ApplyState +): WrappedContainerNode { + const destination = parent ?? figma.currentPage + const destinationIndex = parent ? index : undefined + let node: WrappedContainerNode + switch (spec.type) { + case 'COMPONENT_SET': { + const variants = children.filter( + (child): child is ComponentNode => child.type === 'COMPONENT' + ) + if (variants.length !== children.length) { + specError(`Component set "${spec.key}" can contain only component nodes.`) + } + node = figma.combineAsVariants(variants, destination, destinationIndex) + break + } + case 'GROUP': + node = figma.group(children, destination, destinationIndex) + break + case 'BOOLEAN_OPERATION': + switch (spec.figma!.booleanOperation!) { + case 'UNION': + node = figma.union(children, destination, destinationIndex) + break + case 'SUBTRACT': + node = figma.subtract(children, destination, destinationIndex) + break + case 'INTERSECT': + node = figma.intersect(children, destination, destinationIndex) + break + case 'EXCLUDE': + node = figma.exclude(children, destination, destinationIndex) + break + } + break + } + recordCreatedNode(node, state) + return node +} + +async function reconcileNewWrappedContainer( + spec: WrappedContainerSpec, + state: ApplyState, + parent: CanvasParentNode | undefined, + index: number +): Promise { + state.nodeIdsByKey[spec.key] = '' + const stagingParent = parent ? containingPage(parent) : figma.currentPage + if (spec.type === 'COMPONENT_SET') { + const children = spec.children! + const variants = children.map((child) => { + const variant = figma.createComponent() + recordCreatedNode(variant, state, false) + setValue(variant, variant.name, child.displayName, (value) => (variant.name = value), state) + if (variant.parent?.id !== stagingParent.id) { + moveIntoParent(variant, stagingParent, stagingParent.children.length, state) + } + return variant + }) + const node = createWrappedContainer(spec, variants, parent, index, state) as ComponentSetNode + setNodeKey(state, node, spec.key) + await applyNodeProperties(node, spec, state, parent) + state.nodeIdsByKey[spec.key] = node.id + const reconciled: ReconciledChild[] = [] + for (const [childIndex, child] of children.entries()) { + const variant = await reconcileNode( + child, + state, + node, + desiredChildIndex( + child, + children.slice(childIndex + 1), + state, + node, + reconciled.at(-1)?.node, + variants[childIndex] + ), + variants[childIndex] + ) + reconciled.push({ node: variant, spec: child }) + } + finalizeGrid(node, spec, reconciled, state) + for (const child of reconciled) applyMask(child.node, child.spec, state) + return node + } + + const stagingIndex = stagingParent.children.length + const children: ReconciledChild[] = [] + for (const [childIndex, child] of spec.children!.entries()) { + children.push({ + node: await reconcileNode( + child, + state, + stagingParent, + desiredChildIndex( + child, + spec.children!.slice(childIndex + 1), + state, + stagingParent, + children.at(-1)?.node, + undefined, + stagingIndex + ) + ), + spec: child + }) + } + + const node = createWrappedContainer( + spec, + children.map(({ node: child }) => child), + parent, + index, + state + ) + setNodeKey(state, node, spec.key) + await applyNodeProperties(node, spec, state, parent) + state.nodeIdsByKey[spec.key] = node.id + for (const child of children) applyMask(child.node, child.spec, state) + return node +} + +function desiredChildIndex( + spec: CanvasNodeSpec, + following: CanvasNodeSpec[], + state: ApplyState, + parent: CanvasParentNode, + previous?: SupportedCanvasNode, + forcedNode?: SupportedCanvasNode, + minimumIndex = 0 +): number { + const existing = findExistingNode(spec, state, forcedNode) + const currentIndex = existing?.parent?.id === parent.id ? parent.children.indexOf(existing) : -1 + if (!previous) { + if (currentIndex >= 0) return currentIndex + for (const candidate of following) { + const followingNode = findExistingNode(candidate, state) + if (followingNode?.parent?.id !== parent.id) continue + return Math.max(minimumIndex, parent.children.indexOf(followingNode)) + } + return Math.max(minimumIndex, parent.children.length) + } + + const previousIndex = parent.children.indexOf(previous) + if (currentIndex > previousIndex) return currentIndex + const afterPrevious = previousIndex + 1 + return currentIndex >= 0 && currentIndex < afterPrevious ? afterPrevious - 1 : afterPrevious +} + +async function reconcileNode( + spec: CanvasNodeSpec, + state: ApplyState, + parent?: CanvasParentNode, + index = 0, + forcedNode?: SupportedCanvasNode +): Promise { + const existing = resolveExistingNode(spec, state, forcedNode) + if (!existing && isWrappedSpec(spec)) { + return reconcileNewWrappedContainer(spec, state, parent, index) + } + const node = + existing ?? + (spec.type === 'SLOT' ? createSlotNode(spec, parent, state) : await createNode(spec, state)) + + if (parent) moveIntoParent(node, parent, index, state) + setNodeKey(state, node, spec.key) + if (!isIntrinsicNode(node)) { + await applyNodeProperties(node, spec, state, parent) + } + state.nodeIdsByKey[spec.key] = node.id + + const children: ReconciledChild[] = [] + if (spec.children?.length) { + if ( + node.type !== 'BOOLEAN_OPERATION' && + node.type !== 'COMPONENT' && + node.type !== 'COMPONENT_SET' && + node.type !== 'FRAME' && + node.type !== 'GROUP' && + node.type !== 'SECTION' && + node.type !== 'SLOT' + ) { + specError(`Node "${spec.key}" of type ${node.type} cannot contain desired children.`) + } + for (const [childIndex, child] of spec.children.entries()) { + children.push({ + node: await reconcileNode( + child, + state, + node, + desiredChildIndex( + child, + spec.children.slice(childIndex + 1), + state, + node, + children.at(-1)?.node + ) + ), + spec: child + }) + } + } + if (isFrameContainer(node)) finalizeGrid(node, spec, children, state) + for (const child of children) applyMask(child.node, child.spec, state) + if (isIntrinsicNode(node)) { + await applyNodeProperties(node, spec, state, parent) + } + return node +} + +function placementBounds(node: SceneNode): Rect | null { + const bounds = ('absoluteRenderBounds' in node ? node.absoluteRenderBounds : null) ?? + ('absoluteBoundingBox' in node ? node.absoluteBoundingBox : null) ?? { + x: node.x, + y: node.y, + width: node.width, + height: node.height + } + return [bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite) && + bounds.width >= 0 && + bounds.height >= 0 + ? bounds + : null +} + +function placementOverlap(candidate: Rect, obstacle: Rect): boolean { + return ( + candidate.x < obstacle.x + obstacle.width + ROOT_PLACEMENT_GAP && + candidate.x + candidate.width + ROOT_PLACEMENT_GAP > obstacle.x && + candidate.y < obstacle.y + obstacle.height + ROOT_PLACEMENT_GAP && + candidate.y + candidate.height + ROOT_PLACEMENT_GAP > obstacle.y + ) +} + +function placeCreatedRoot(node: SupportedCanvasNode, page: PageNode, state: ApplyState): void { + const bounds = placementBounds(node) + if (!bounds) specError(`Created root "${node.id}" has invalid placement bounds.`) + const center = figma.viewport.center + const candidate = { + x: center.x - bounds.width / 2, + y: center.y - bounds.height / 2, + width: bounds.width, + height: bounds.height + } + const obstacles = page.children + .filter((child) => child.id !== node.id) + .map(placementBounds) + .filter((value): value is Rect => value !== null) + .sort((a, b) => a.x - b.x) + + for (const obstacle of obstacles) { + if (!placementOverlap(candidate, obstacle)) continue + candidate.x = obstacle.x + obstacle.width + ROOT_PLACEMENT_GAP + if (!Number.isFinite(candidate.x)) { + specError(`Page "${page.id}" has invalid placement bounds.`) + } + } + + const x = node.x + candidate.x - bounds.x + const y = node.y + candidate.y - bounds.y + if (node.x === x && node.y === y) return + node.x = x + node.y = y + markMutation(state, node) +} + +function createApplyState( + target: SupportedCanvasNode | null, + desiredKeys: Set, + assets: ResolvedCanvasAssets = new Map() +): ApplyState { + return { + assets, + claimedNodeIds: new Set(), + componentCache: new Map(), + componentPropertyKeys: new Map(), + createdNodeIds: new Set(), + desiredKeys, + fontLoads: new Map(), + imageHashes: new Map(), + imageAssetKeys: new Set(), + imageUrls: new Set(), + keyedNodes: target ? collectKeyedNodes(target) : new Map(), + mutations: { count: 0 }, + nodeIdsByKey: Object.create(null) as Record, + removalNodeIds: new Set(), + referencedNodeIds: new Set(), + scope: target, + shaderCache: new Map(), + styles: createStyleState(), + updatedNodeIds: new Set(), + variables: createVariableState(), + videoHashes: new Map(), + videoUrls: new Set() + } +} + +function passedVerification(nodesChecked = 0, referencesChecked = 0) { + return { + status: 'passed' as const, + nodesChecked, + referencesChecked, + warnings: [] + } +} + +function removedRootResult( + rootNodeId: string, + removedNodeIds: string[] = [], + state?: ApplyState +): ApplyCanvasResult { + return { + rootNodeId, + rootRemoved: true, + nodeIdsByKey: state?.nodeIdsByKey ?? {}, + createdNodeIds: [], + updatedNodeIds: [], + removedNodeIds, + mutationCount: state?.mutations.count ?? 0, + verification: passedVerification() + } +} + +function appliedVariableId( + node: SupportedCanvasNode, + field: keyof CanvasVariableBindings +): string | undefined { + if (field === 'fill' || field === 'stroke') { + const paintField = field === 'fill' ? 'fills' : 'strokes' + return node.boundVariables?.[paintField]?.[0]?.id + } + return currentBoundVariableId(node, DIRECT_VARIABLE_FIELDS[field]) +} + +function verifySizingGeometry( + spec: CanvasNodeSpec, + node: SupportedCanvasNode, + parent?: SupportedCanvasNode +): void { + if (!isIntrinsicNode(node) && 'layoutSizingHorizontal' in node) { + if ( + node.layoutSizingHorizontal !== spec.size.horizontal || + node.layoutSizingVertical !== spec.size.vertical || + (spec.grow !== undefined && node.layoutGrow !== (spec.grow ? 1 : 0)) + ) { + specError(`Verification failed for "${spec.key}": sizing modes do not match.`) + } + } + + const fixedWidth = + spec.size.horizontal === 'FIXED' && + spec.size.width !== undefined && + !spec.variables?.width && + !currentBoundVariableId(node, 'width') + ? spec.size.width + : null + const fixedHeight = + spec.size.vertical === 'FIXED' && + spec.size.height !== undefined && + !spec.variables?.height && + !currentBoundVariableId(node, 'height') + ? spec.size.height + : null + if ( + (fixedWidth !== null && Math.abs(node.width - fixedWidth) > GEOMETRY_TOLERANCE) || + (fixedHeight !== null && Math.abs(node.height - fixedHeight) > GEOMETRY_TOLERANCE) + ) { + specError(`Verification failed for "${spec.key}": fixed geometry does not match.`) + } + + const fill = expectedCrossAxisFill(node, spec, parent) + if (fill) { + const actual = fill.axis === 'horizontal' ? node.width : node.height + if (Math.abs(actual - fill.size) > GEOMETRY_TOLERANCE) { + specError(`Verification failed for "${spec.key}": fill geometry does not match.`) + } + } + + if (node.type !== 'TEXT') return + if (node.textAutoResize !== spec.text?.autoResize) { + specError(`Verification failed for "${spec.key}": text auto-resize does not match.`) + } + if ( + node.characters.length > 0 && + (node.textAutoResize === 'HEIGHT' || node.textAutoResize === 'WIDTH_AND_HEIGHT') && + node.height <= GEOMETRY_TOLERANCE + ) { + specError(`Verification failed for "${spec.key}": auto-resizing text has no height.`) + } + if ( + node.characters.length > 0 && + node.textAutoResize === 'WIDTH_AND_HEIGHT' && + node.width <= GEOMETRY_TOLERANCE + ) { + specError(`Verification failed for "${spec.key}": auto-resizing text has no width.`) + } +} + +async function verifyAppliedNode( + spec: CanvasNodeSpec, + node: SupportedCanvasNode, + state: ApplyState, + parent?: SupportedCanvasNode +): Promise<{ nodes: number; references: number }> { + if (node.type !== spec.type) { + specError(`Verification failed for "${spec.key}": expected ${spec.type}, found ${node.type}.`) + } + if (state.nodeIdsByKey[spec.key] !== node.id) { + specError(`Verification failed for "${spec.key}": stable identity did not resolve to its node.`) + } + if (node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_KEY_NAME) !== spec.key) { + specError(`Verification failed for "${spec.key}": native stable identity is missing.`) + } + if (parent && node.parent?.id !== parent.id) { + specError(`Verification failed for "${spec.key}": parent does not match the desired tree.`) + } + const geometry = [ + node.x, + node.y, + node.width, + node.height, + ...('rotation' in node ? [node.rotation] : []) + ] + if (!geometry.every(Number.isFinite)) { + specError(`Verification failed for "${spec.key}": geometry is not finite.`) + } + verifySizingGeometry(spec, node, parent) + + let references = 0 + if (spec.component) { + const expected = resolvedComponent(spec.component, state) + const actual = node.type === 'INSTANCE' ? await node.getMainComponentAsync() : null + references += 1 + if (actual?.id !== expected.id) { + specError(`Verification failed for "${spec.key}": component link does not match.`) + } + } + for (const [field, reference] of Object.entries(spec.variables ?? {}) as Array< + [keyof CanvasVariableBindings, CanvasVariableBindings[keyof CanvasVariableBindings]] + >) { + const expected = reference ? resolvedVariable(reference, state.variables).id : undefined + references += 1 + if (appliedVariableId(node, field) !== expected) { + specError(`Verification failed for "${spec.key}": variable link "${field}" does not match.`) + } + } + for (const field of STYLE_FIELDS) { + const reference = spec.styles?.[field] + if (reference === undefined) continue + const expected = reference ? (await resolveStyle(reference, state.styles)).id : '' + references += 1 + if (styleTarget(node, field).current !== expected) { + specError(`Verification failed for "${spec.key}": style link "${field}" does not match.`) + } + } + for (const [collectionReference, modeReference] of Object.entries(spec.variableModes ?? {})) { + const collection = resolvedCollection(collectionReference, state.variables) + const expected = + modeReference === null + ? undefined + : resolvedModeId(collection, modeReference, state.variables) + references += 1 + if (node.explicitVariableModes[collection.id] !== expected) { + specError(`Verification failed for "${spec.key}": variable mode does not match.`) + } + } + if (spec.figma?.mask !== undefined) { + const mask = spec.figma.mask + if ( + !('isMask' in node) || + node.isMask !== (mask !== null) || + (mask !== null && (!('maskType' in node) || node.maskType !== mask)) + ) { + specError(`Verification failed for "${spec.key}": mask state does not match.`) + } + } + if (spec.figma?.svg) { + if (node.type !== 'FRAME') { + specError(`Verification failed for "${spec.key}": SVG wrapper is not a frame.`) + } + const asset = resolvedSvgAsset(state.assets, spec.figma.svg.assetKey, spec.figma.svg.color) + const owned = node.children.filter(isOwnedSvgChild) + const unexpected = node.children.filter((child) => !isOwnedSvgChild(child)) + if ( + !asset || + owned.length !== 1 || + unexpected.length || + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_DIGEST_NAME) !== asset.digest || + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_POLICY_NAME) !== + SVG_POLICY_VERSION || + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_COLOR_NAME) !== + (spec.figma.svg.color?.toUpperCase() ?? '') + ) { + specError(`Verification failed for "${spec.key}": SVG import state does not match.`) + } + const child = owned[0]! + if ( + child.x < -0.01 || + child.y < -0.01 || + child.x + child.width > node.width + 0.01 || + child.y + child.height > node.height + 0.01 + ) { + specError(`Verification failed for "${spec.key}": SVG is outside its wrapper.`) + } + references += 1 + } + + const childSpecs = spec.children ?? [] + const childNodes = childSpecs.map((child) => { + const candidate = figma.getNodeById(state.nodeIdsByKey[child.key]!) + if (!isSupportedSceneNode(candidate)) { + specError(`Verification failed for "${child.key}": desired child is missing.`) + } + return candidate + }) + if (childNodes.length) { + if (!('children' in node)) { + specError(`Verification failed for "${spec.key}": desired children have no container.`) + } + let previous = -1 + for (const child of childNodes) { + const index = node.children.findIndex((candidate) => candidate.id === child.id) + if (index <= previous) { + specError(`Verification failed for "${spec.key}": desired child order does not match.`) + } + previous = index + } + } + let nodes = 1 + for (const [index, childSpec] of childSpecs.entries()) { + const verified = await verifyAppliedNode(childSpec, childNodes[index]!, state, node) + nodes += verified.nodes + references += verified.references + } + return { nodes, references } +} + +async function withUndoBoundary( + apply: () => Promise, + mutations: MutationCounter +): Promise { + try { + figma.commitUndo() + const result = await apply() + figma.commitUndo() + return result + } catch (error) { + const readOnly = canvasReadOnlyError(error) + if (mutations.count > 0) { + try { + figma.triggerUndo() + } catch { + if (readOnly) throw readOnly + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + 'Canvas apply failed and automatic rollback was not available. Use Figma Undo.' + ) + } + } + if (readOnly) throw readOnly + throw error + } +} + +async function removeUpdateRoot(targetNodeId: string): Promise { + const candidate = figma.getNodeById(targetNodeId) + if (!candidate) return removedRootResult(targetNodeId) + if (!isSupportedSceneNode(candidate)) { + scopeError('The requested removal target is not a supported scene node.') + } + + const state = createApplyState(candidate, new Set()) + validateRemovalAncestors(candidate) + validateRemovalOwnership(candidate, state) + state.removalNodeIds.add(candidate.id) + + return withUndoBoundary(async () => { + const removedNodeIds = await applyRemovals([candidate], state) + if (figma.getNodeById(candidate.id)) { + specError(`Verification failed: root "${candidate.id}" is still present.`) + } + return removedRootResult(candidate.id, removedNodeIds, state) + }, state.mutations) +} + +export async function reconcileCanvas(input: ParsedCanvasInput): Promise { + if (input.root === null) return removeUpdateRoot(input.targetNodeId) + + const rootSpec = input.root + let target: SupportedCanvasNode | null = null + if (input.mode === 'update') { + const candidate = figma.getNodeById(input.targetNodeId!) + if (!isSupportedSceneNode(candidate)) { + scopeError('The requested update target does not exist or is not a supported scene node.') + } + target = candidate + } + if (target && target.type !== rootSpec.type) { + specError( + `The update root expects ${rootSpec.type}, but target "${target.id}" is ${target.type}.` + ) + } + const assets = await resolveCanvasAssets(input.assets, collectSvgColors(rootSpec)) + const state = createApplyState(target, collectDesiredKeys(rootSpec), assets) + const removalNodes = resolveRemovalNodes(input, state) + + return withUndoBoundary(async () => { + const page = await resolveResultPage(input.page, target, state) + await validateRemovalComponents(outermostNodes(removalNodes)) + preflightMasks(rootSpec, state, target) + preflightContainers(rootSpec, state, target) + await reconcileVariableCollections(input.variableCollections, state.variables, state.mutations) + await prepareStyleResources(input.styles, state.styles, state.mutations) + await preflightStyleResources(state) + await preflightVariableModes(input.page?.variableModes, state) + await preflightResources(rootSpec, state, target ?? undefined) + await resolveImageUrls(state) + resolveImageAssets(state) + await resolveVideoUrls(state) + applyStyleResources(state) + if (input.page) applyPage(page, input.page, state) + const destination = + input.mode === 'create' && page.id !== figma.currentPage.id ? page : undefined + const root = await reconcileNode( + rootSpec, + state, + destination, + destination?.children.length ?? 0, + target ?? undefined + ) + await applyCanvasKeyReferences(rootSpec, state) + if (input.mode === 'create' && rootSpec.figma?.relativeTransform === undefined) { + placeCreatedRoot(root, page, state) + } + applyMask(root, rootSpec, state) + const removedNodeIds = await applyRemovals(removalNodes, state) + await removeStyleResources(state.styles, state.mutations) + await removeVariableResources(state.variables, state.mutations) + const verified = await verifyAppliedNode(rootSpec, root, state) + return { + rootNodeId: root.id, + nodeIdsByKey: state.nodeIdsByKey, + createdNodeIds: [...state.createdNodeIds], + updatedNodeIds: [...state.updatedNodeIds], + removedNodeIds, + mutationCount: state.mutations.count, + verification: passedVerification(verified.nodes, verified.references) + } + }, state.mutations) +} diff --git a/packages/extension/mcp/tools/canvas/resolve.ts b/packages/extension/mcp/tools/canvas/resolve.ts new file mode 100644 index 00000000..f6d985e6 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/resolve.ts @@ -0,0 +1,139 @@ +import type { + ApplyCanvasParameters, + CanvasBinding, + CanvasResolvedApplyParameters +} from '@tempad-dev/shared' + +import { CanvasResolvedApplyParametersSchema } from '@tempad-dev/shared' + +import { + requireDesignSystemCatalog, + type CatalogEntry, + type DesignSystemCatalog +} from '../design-system-catalog' +import { formatSchemaError } from './errors' + +type Resolution = { + catalog?: DesignSystemCatalog + input: CanvasResolvedApplyParameters +} + +const CATALOG_REF_PATTERN = /^(?:[chksv]\d+|m\d+_\d+)$/ +const MAX_RESOLUTION_DEPTH = 64 + +function inputError(message: string): never { + throw new Error(message) +} + +function catalogEntry( + catalog: DesignSystemCatalog | undefined, + ref: string +): CatalogEntry | undefined { + const entry = catalog?.entries.get(ref) + if (!entry && CATALOG_REF_PATTERN.test(ref)) { + inputError( + catalog + ? `Unknown design-system ref "${ref}" in catalog "${catalog.id}".` + : `Design-system ref "${ref}" requires catalogId.` + ) + } + return entry +} + +function resolveDeep(value: unknown, catalog: DesignSystemCatalog | undefined, depth = 0): unknown { + if (depth > MAX_RESOLUTION_DEPTH) { + inputError(`Canvas native data may be at most ${MAX_RESOLUTION_DEPTH} levels deep.`) + } + if (Array.isArray(value)) return value.map((item) => resolveDeep(item, catalog, depth + 1)) + if (value === null || typeof value !== 'object') return value + const record = value as Record + if (typeof record.ref === 'string') { + if (Object.keys(record).length !== 1) { + inputError('A design-system { ref } value cannot contain other fields.') + } + if (!catalog) inputError(`Design-system ref "${record.ref}" requires catalogId.`) + const entry = catalogEntry(catalog, record.ref) + if (!entry) { + inputError(`Unknown design-system ref "${record.ref}" in catalog "${catalog.id}".`) + } + if (entry.kind === 'mode' || entry.kind === 'shader') return entry.id + return entry.reference + } + return Object.fromEntries( + Object.entries(record).map(([key, item]) => [key, resolveDeep(item, catalog, depth + 1)]) + ) +} + +function resolveNativeBinding( + binding: NonNullable[string], + catalog: DesignSystemCatalog | undefined +): CanvasBinding { + const variableModes = binding.variableModes + ? Object.fromEntries( + Object.entries(binding.variableModes).map(([collectionRef, modeRef]) => { + const collection = catalogEntry(catalog, collectionRef) + const mode = modeRef === null ? null : catalogEntry(catalog, modeRef) + if (collection && collection.kind !== 'collection') { + inputError(`Design-system ref "${collectionRef}" is not a collection.`) + } + if (mode && mode.kind !== 'mode') { + inputError(`Design-system ref "${modeRef}" is not a mode.`) + } + if (collection?.kind === 'collection' && mode?.kind === 'mode') { + if (mode.collectionRef !== collection.ref) { + inputError(`Mode "${modeRef}" does not belong to collection "${collectionRef}".`) + } + } + const resolvedCollection = + collection?.kind === 'collection' + ? (collection.reference.id ?? collection.reference.key) + : collectionRef + return [resolvedCollection, mode?.kind === 'mode' ? mode.id : modeRef] + }) + ) + : undefined + return { + ...(binding.variables ? { variables: binding.variables } : {}), + ...(variableModes ? { variableModes } : {}), + ...(binding.styles ? { styles: binding.styles } : {}), + ...(binding.figma + ? { figma: resolveDeep(binding.figma, catalog) as CanvasBinding['figma'] } + : {}) + } +} + +export function resolveCanvasInput(input: ApplyCanvasParameters): Resolution { + const catalog = input.catalogId + ? requireDesignSystemCatalog( + input.catalogId, + typeof figma === 'undefined' ? undefined : figma.fileKey + ) + : undefined + const candidate = { + mode: input.mode, + ...(input.targetNodeId ? { targetNodeId: input.targetNodeId } : {}), + markup: input.markup, + ...(input.native + ? { + bindings: Object.fromEntries( + Object.entries(input.native).map(([key, binding]) => [ + key, + resolveNativeBinding(binding, catalog) + ]) + ) + } + : {}), + ...(input.variableCollections === undefined + ? {} + : { variableCollections: resolveDeep(input.variableCollections, catalog) }), + ...(input.styles === undefined ? {} : { styles: resolveDeep(input.styles, catalog) }), + ...(input.assets === undefined ? {} : { assets: input.assets }), + ...(input.removeKeys === undefined ? {} : { removeKeys: input.removeKeys }), + ...(input.page === undefined ? {} : { page: resolveDeep(input.page, catalog) }) + } + const parsed = CanvasResolvedApplyParametersSchema.safeParse(candidate) + if (!parsed.success) { + inputError(formatSchemaError(parsed.error)) + } + return { input: parsed.data, ...(catalog ? { catalog } : {}) } +} diff --git a/packages/extension/mcp/tools/canvas/styles.ts b/packages/extension/mcp/tools/canvas/styles.ts new file mode 100644 index 00000000..aae265e1 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/styles.ts @@ -0,0 +1,158 @@ +import type { CanvasStyleReference, CanvasStyleResource, CanvasStyles } from '@tempad-dev/shared' + +import { getLocalStyles } from '../../local-styles' +import { scopeError, specError } from './errors' +import { + CANVAS_STYLE_KEY_NAME, + type MutationCounter, + claimAuthoringKey, + designReferenceCacheKey, + readAuthoringKey +} from './identity' + +type CanvasStyleResourceState = { + key: string + spec: CanvasStyleResource + style: BaseStyle +} + +export type CanvasStyleState = { + byKey: Map + cache: Map + indexed: boolean + removals: Array<{ key: string; style: BaseStyle }> + resources: CanvasStyleResourceState[] +} + +function indexStyle(styles: Map, key: string, style: BaseStyle): void { + const existing = styles.get(key) + if (existing && existing.id !== style.id) { + specError(`Style key "${key}" identifies more than one local style.`) + } + styles.set(key, style) +} + +async function ensureLocalIndex(state: CanvasStyleState): Promise { + if (state.indexed) return + state.indexed = true + const styles = await getLocalStyles() + for (const style of styles) { + state.cache.set(`id:${style.id}`, style) + if (style.key) state.cache.set(`key:${style.key}`, style) + const key = readAuthoringKey(style, CANVAS_STYLE_KEY_NAME) + if (key) indexStyle(state.byKey, key, style) + } +} + +function createStyle(type: StyleType): BaseStyle { + switch (type) { + case 'PAINT': + return figma.createPaintStyle() + case 'TEXT': + return figma.createTextStyle() + case 'EFFECT': + return figma.createEffectStyle() + case 'GRID': + return figma.createGridStyle() + } +} + +async function selectStyle( + key: string, + spec: CanvasStyleResource, + state: CanvasStyleState, + mutations: MutationCounter +): Promise { + const keyed = state.byKey.get(key) + const explicit = spec.id ? await figma.getStyleByIdAsync(spec.id) : null + if (spec.id && !explicit) specError(`Style "${spec.id}" does not exist.`) + if (keyed && explicit && keyed.id !== explicit.id) { + specError(`Style key "${key}" does not identify "${explicit.id}".`) + } + let style = explicit ?? keyed + if (!style) { + if (!spec.name) specError(`New ${spec.type} style "${key}" requires a name.`) + style = createStyle(spec.type) + mutations.count += 1 + } + if (style.remote) specError(`Style "${style.id}" is not an editable local style.`) + if (style.type !== spec.type) { + specError(`Style "${style.id}" is ${style.type}, expected ${spec.type}.`) + } + claimAuthoringKey(style, key, CANVAS_STYLE_KEY_NAME, 'Style', mutations) + indexStyle(state.byKey, key, style) + state.cache.set(`id:${style.id}`, style) + if (style.key) state.cache.set(`key:${style.key}`, style) + return style +} + +export function createStyleState(): CanvasStyleState { + return { + byKey: new Map(), + cache: new Map(), + indexed: false, + removals: [], + resources: [] + } +} + +export async function prepareStyleResources( + specs: CanvasStyles | undefined, + state: CanvasStyleState, + mutations: MutationCounter +): Promise { + if (!specs) return + await ensureLocalIndex(state) + for (const [key, spec] of Object.entries(specs)) { + if (spec === null) { + const style = state.byKey.get(key) + if (style) state.removals.push({ key, style }) + continue + } + state.resources.push({ + key, + spec, + style: await selectStyle(key, spec, state, mutations) + }) + } +} + +export async function removeStyleResources( + state: CanvasStyleState, + mutations: MutationCounter +): Promise { + for (const { key, style } of state.removals) { + const consumer = (await style.getStyleConsumersAsync())[0] + if (consumer) { + scopeError( + `Style "${key}" is still used by node "${consumer.node.id}" in ${consumer.fields.join(', ')}.` + ) + } + } + for (const { style } of state.removals) { + style.remove() + mutations.count += 1 + } +} + +export async function resolveStyle( + reference: CanvasStyleReference, + state: CanvasStyleState +): Promise { + if ('styleKey' in reference) { + await ensureLocalIndex(state) + const style = state.byKey.get(reference.styleKey) + if (!style) specError(`Style key "${reference.styleKey}" could not be resolved.`) + return style + } + const cacheKey = designReferenceCacheKey(reference) + const cached = state.cache.get(cacheKey) + if (cached) return cached + const style = + reference.id !== undefined + ? await figma.getStyleByIdAsync(reference.id) + : await figma.importStyleByKeyAsync(reference.key) + if (!style) specError('The requested style could not be resolved.') + state.cache.set(cacheKey, style) + return style +} diff --git a/packages/extension/mcp/tools/canvas/tailwind.ts b/packages/extension/mcp/tools/canvas/tailwind.ts new file mode 100644 index 00000000..a4ee1fa0 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/tailwind.ts @@ -0,0 +1,841 @@ +import { + TAILWIND_ALIGN_ITEMS, + TAILWIND_FONT_WEIGHTS, + TAILWIND_JUSTIFY_CONTENT, + TAILWIND_TEXT_ALIGN, + TAILWIND_TEXT_CASE, + TAILWIND_TEXT_DECORATION +} from '@/utils/tailwind-semantics' + +import type { CanvasGridTrack, CanvasSizingMode } from './model' + +export const MAX_GRID_TRACKS = 100 + +const BLEND_MODES = { + 'pass-through': 'PASS_THROUGH', + normal: 'NORMAL', + darken: 'DARKEN', + multiply: 'MULTIPLY', + 'plus-darker': 'LINEAR_BURN', + 'color-burn': 'COLOR_BURN', + lighten: 'LIGHTEN', + screen: 'SCREEN', + 'plus-lighter': 'LINEAR_DODGE', + 'color-dodge': 'COLOR_DODGE', + overlay: 'OVERLAY', + 'soft-light': 'SOFT_LIGHT', + 'hard-light': 'HARD_LIGHT', + difference: 'DIFFERENCE', + exclusion: 'EXCLUSION', + hue: 'HUE', + saturation: 'SATURATION', + color: 'COLOR', + luminosity: 'LUMINOSITY' +} as const satisfies Record +const BORDER_SIDES = { + t: 'top', + r: 'right', + b: 'bottom', + l: 'left' +} as const +const BORDER_AXES = { + x: ['left', 'right'], + y: ['top', 'bottom'] +} as const +const CORNERS = { + tl: 'topLeft', + tr: 'topRight', + br: 'bottomRight', + bl: 'bottomLeft' +} as const +const CORNER_GROUPS = { + t: ['topLeft', 'topRight'], + r: ['topRight', 'bottomRight'], + b: ['bottomLeft', 'bottomRight'], + l: ['topLeft', 'bottomLeft'] +} as const +const GRID_ALIGNMENTS = { + auto: 'AUTO', + start: 'MIN', + center: 'CENTER', + end: 'MAX' +} as const +const ITEM_ALIGNMENTS = { + 'flex-start': 'MIN', + center: 'CENTER', + 'flex-end': 'MAX', + baseline: 'BASELINE' +} as const +const JUSTIFY_ALIGNMENTS = { + 'flex-start': 'MIN', + center: 'CENTER', + 'flex-end': 'MAX', + 'space-between': 'SPACE_BETWEEN' +} as const +const PADDING_SIDES = { + p: ['top', 'right', 'bottom', 'left'], + px: ['left', 'right'], + py: ['top', 'bottom'], + pt: ['top'], + pr: ['right'], + pb: ['bottom'], + pl: ['left'] +} as const +const FONT_STYLES = { + '100': 'Thin', + '200': 'Extra Light', + '300': 'Light', + '400': 'Regular', + '500': 'Medium', + '600': 'Semi Bold', + '700': 'Bold', + '800': 'Extra Bold', + '900': 'Black' +} as const +const FONT_SIZES = { + xs: [12, 16], + sm: [14, 20], + base: [16, 24], + lg: [18, 28], + xl: [20, 28], + '2xl': [24, 32], + '3xl': [30, 36], + '4xl': [36, 40], + '5xl': [48, 48], + '6xl': [60, 60], + '7xl': [72, 72], + '8xl': [96, 96], + '9xl': [128, 128] +} as const +const LINE_HEIGHTS = { + none: 100, + tight: 125, + snug: 137.5, + normal: 150, + relaxed: 162.5, + loose: 200 +} as const +const LETTER_SPACINGS = { + tighter: -5, + tight: -2.5, + normal: 0, + wide: 2.5, + wider: 5, + widest: 10 +} as const +const RADII = { + none: 0, + xs: 2, + sm: 4, + md: 6, + lg: 8, + xl: 12, + '2xl': 16, + '3xl': 24, + '4xl': 32, + full: 9999 +} as const +const CONTAINER_WIDTHS = { + '3xs': 256, + '2xs': 288, + xs: 320, + sm: 384, + md: 448, + lg: 512, + xl: 576, + '2xl': 672, + '3xl': 768, + '4xl': 896, + '5xl': 1024, + '6xl': 1152, + '7xl': 1280 +} as const +const TEXT_ALIGNMENTS = { + left: 'LEFT', + center: 'CENTER', + right: 'RIGHT', + justify: 'JUSTIFIED' +} as const +const TEXT_CASES = { + none: 'ORIGINAL', + uppercase: 'UPPER', + lowercase: 'LOWER', + capitalize: 'TITLE' +} as const +const TEXT_DECORATIONS = { + none: 'NONE', + underline: 'UNDERLINE', + 'line-through': 'STRIKETHROUGH' +} as const + +function classValues(values: Record, prefix = ''): Record { + return Object.fromEntries( + Object.entries(values).map(([value, suffix]) => [`${prefix}${suffix}`, value]) + ) +} + +const ALIGN_ITEM_CLASSES = classValues(TAILWIND_ALIGN_ITEMS, 'items-') +const JUSTIFY_CONTENT_CLASSES = classValues(TAILWIND_JUSTIFY_CONTENT, 'justify-') +const FONT_WEIGHT_CLASSES = classValues(TAILWIND_FONT_WEIGHTS, 'font-') +const TEXT_ALIGN_CLASSES = classValues(TAILWIND_TEXT_ALIGN, 'text-') +const TEXT_CASE_CLASSES = classValues(TAILWIND_TEXT_CASE) +const TEXT_DECORATION_CLASSES = classValues(TAILWIND_TEXT_DECORATION) + +type AxisSize = { + mode: CanvasSizingMode + value?: number +} + +export type CanvasClasses = { + width?: AxisSize + height?: AxisSize + minWidth?: number | null + maxWidth?: number | null + minHeight?: number | null + maxHeight?: number | null + flex: boolean + direction?: 'HORIZONTAL' | 'VERTICAL' + grid: boolean + gridColumns?: CanvasGridTrack[] + gridRows?: CanvasGridTrack[] + gridFlow?: 'MANUAL' | 'ROW_AUTO_FLOW' + gridColumn?: number + gridRow?: number + gridColumnSpan?: number + gridRowSpan?: number + gridHorizontalAlign?: 'AUTO' | 'CENTER' | 'MAX' | 'MIN' + gridVerticalAlign?: 'AUTO' | 'CENTER' | 'MAX' | 'MIN' + grow?: boolean + gap?: number + columnGap?: number + rowGap?: number + padding: Partial> + primaryAlign?: 'CENTER' | 'MAX' | 'MIN' | 'SPACE_BETWEEN' + counterAlign?: 'BASELINE' | 'CENTER' | 'MAX' | 'MIN' + counterAlignContent?: 'AUTO' | 'SPACE_BETWEEN' + wrap?: 'NO_WRAP' | 'WRAP' + strokesIncluded?: boolean + absolute?: boolean + left?: number + top?: number + fill?: `#${string}` | null + stroke?: `#${string}` + strokeWeight?: number + strokeWeights: Partial> + cornerRadius?: number + cornerRadii: Partial> + clipsContent?: boolean + opacity?: number + visible?: boolean + blendMode?: BlendMode + rotation?: number + fontFamily?: string + fontStyle?: string + fontSize?: number + lineHeight?: LineHeight + letterSpacing?: LetterSpacing + textAlign?: 'CENTER' | 'JUSTIFIED' | 'LEFT' | 'RIGHT' + textCase?: TextCase + textDecoration?: TextDecoration + textTruncation?: 'DISABLED' | 'ENDING' + maxLines?: number | null + preserveWhitespace?: boolean + frameClass?: string + gridChildClass?: string + layoutClass?: string + textClass?: string + assigned: Set +} + +function classError(message: string): never { + throw new Error(message) +} + +function finiteNumber( + raw: string, + token: string, + options: { allowNegative?: boolean; positive?: boolean } = {} +): number { + const value = Number(raw) + if ( + !Number.isFinite(value) || + (options.positive ? value <= 0 : !options.allowNegative && value < 0) + ) { + classError(`Invalid numeric class "${token}".`) + } + return value +} + +function pixels( + raw: string, + token: string, + options: { allowNegative?: boolean; numericScale?: number } = {} +): number | null { + const arbitrary = /^\[(-?(?:\d+(?:\.\d+)?|\.\d+))px\]$/.exec(raw) + if (arbitrary) { + return finiteNumber(arbitrary[1]!, token, { allowNegative: options.allowNegative }) + } + if (raw === 'px') return 1 + if (options.numericScale === undefined || !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(raw)) { + return null + } + return finiteNumber(raw, token) * options.numericScale +} + +function fixedSize(raw: string, token: string, containers = false): number | null { + const value = pixels(raw, token, { numericScale: 4 }) + if (value !== null) return value + return containers ? (CONTAINER_WIDTHS[raw as keyof typeof CONTAINER_WIDTHS] ?? null) : null +} + +function radius(raw: string, token: string): number | null { + const arbitrary = pixels(raw, token) + if (arbitrary !== null) return arbitrary + return RADII[raw as keyof typeof RADII] ?? null +} + +function color(raw: string): `#${string}` | null { + if (raw === 'white') return '#FFFFFF' + if (raw === 'black') return '#000000' + const arbitrary = /^\[(#(?:[\dA-Fa-f]{3}|[\dA-Fa-f]{4}|[\dA-Fa-f]{6}|[\dA-Fa-f]{8}))\]$/.exec(raw) + return (arbitrary?.[1] as `#${string}` | undefined) ?? null +} + +function lineHeight(raw: string, token: string): LineHeight | null { + const named = LINE_HEIGHTS[raw as keyof typeof LINE_HEIGHTS] + if (named !== undefined) return { unit: 'PERCENT', value: named } + const spacing = /^(?:\d+(?:\.\d+)?|\.\d+)$/.test(raw) + ? finiteNumber(raw, token, { positive: true }) * 4 + : null + if (spacing !== null) return { unit: 'PIXELS', value: spacing } + const arbitrary = /^\[((?:\d+(?:\.\d+)?|\.\d+))(px|%|)\]$/.exec(raw) + if (!arbitrary) return null + const value = finiteNumber(arbitrary[1]!, token, { positive: true }) + return arbitrary[2] === 'px' + ? { unit: 'PIXELS', value } + : { unit: 'PERCENT', value: arbitrary[2] === '%' ? value : value * 100 } +} + +function textSize( + raw: string, + token: string +): { defaultLineHeight?: LineHeight; value: number } | null { + const named = FONT_SIZES[raw as keyof typeof FONT_SIZES] + if (named) { + return { value: named[0], defaultLineHeight: { unit: 'PIXELS', value: named[1] } } + } + const arbitrary = /^\[((?:\d+(?:\.\d+)?|\.\d+))px\]$/.exec(raw) + if (!arbitrary) return null + const value = finiteNumber(arbitrary[1]!, token, { positive: true }) + if (value < 1) classError(`Font-size class "${token}" must be at least 1px.`) + return { value } +} + +function assignIndividuals( + classes: CanvasClasses, + group: string, + values: Partial>, + fields: readonly Key[], + value: number, + token: string +): void { + for (const field of fields) { + const assignment = `${group}-${field}` + if (classes.assigned.has(assignment)) { + classError(`Class "${token}" conflicts with another ${assignment} class.`) + } + } + for (const field of fields) { + classes.assigned.add(`${group}-${field}`) + values[field] = value + } +} + +function assign( + classes: CanvasClasses, + field: T, + value: CanvasClasses[T], + token: string +): void { + if (classes.assigned.has(field)) { + classError(`Class "${token}" conflicts with another ${field} class.`) + } + classes.assigned.add(field) + classes[field] = value +} + +function assignPadding( + classes: CanvasClasses, + sides: ReadonlyArray<'bottom' | 'left' | 'right' | 'top'>, + value: number, + token: string +): void { + for (const side of sides) { + const field = `padding-${side}` + if (classes.assigned.has(field)) { + classError(`Class "${token}" conflicts with another ${field} class.`) + } + } + for (const side of sides) { + classes.assigned.add(`padding-${side}`) + classes.padding[side] = value + } + classes.layoutClass ??= token +} + +function parseGridTracks(raw: string, token: string): CanvasGridTrack[] { + const tracks = raw.split('_').map((value): CanvasGridTrack => { + if (value === 'fit-content(100%)') return { type: 'HUG' } + const match = /^(\d+(?:\.\d+)?)(fr|px)$/.exec(value) + if (!match) classError(`Invalid grid track in class "${token}".`) + return { + type: match[2] === 'fr' ? 'FLEX' : 'FIXED', + value: finiteNumber(match[1]!, token, { positive: match[2] === 'fr' }) + } + }) + if (!tracks.length || tracks.length > MAX_GRID_TRACKS) { + classError(`Grid class "${token}" must contain 1 to ${MAX_GRID_TRACKS} tracks.`) + } + return tracks +} + +export function parseCanvasClasses(value: string): CanvasClasses { + const classes: CanvasClasses = { + flex: false, + grid: false, + cornerRadii: {}, + padding: {}, + strokeWeights: {}, + assigned: new Set() + } + let defaultLineHeight: LineHeight | undefined + const tokens = value.trim() ? value.trim().split(/\s+/) : [] + for (const token of tokens) { + if (token === 'flex') { + assign(classes, 'flex', true, token) + classes.layoutClass ??= token + continue + } + if (token === 'grid') { + assign(classes, 'grid', true, token) + classes.layoutClass ??= token + continue + } + if (token === 'flex-row' || token === 'flex-col') { + assign(classes, 'direction', token === 'flex-row' ? 'HORIZONTAL' : 'VERTICAL', token) + classes.layoutClass ??= token + continue + } + if (token === 'grow' || token === 'grow-0') { + assign(classes, 'grow', token === 'grow', token) + continue + } + if (token === 'hidden' || token === 'visible') { + assign(classes, 'visible', token === 'visible', token) + continue + } + if (token.startsWith('mix-blend-')) { + const name = token.slice('mix-blend-'.length) + const blendMode = BLEND_MODES[name as keyof typeof BLEND_MODES] + if (!blendMode) classError(`Unsupported blend mode class "${token}".`) + assign(classes, 'blendMode', blendMode, token) + continue + } + const rotation = + /^(-)?rotate-(?:\[(-?(?:\d+(?:\.\d+)?|\.\d+))deg\]|((?:\d+(?:\.\d+)?|\.\d+)))$/.exec(token) + if (rotation) { + const raw = rotation[2] ?? rotation[3]! + if (rotation[1] && raw.startsWith('-')) classError(`Invalid numeric class "${token}".`) + const value = finiteNumber(raw, token, { allowNegative: true }) + assign(classes, 'rotation', rotation[1] ? value : -value, token) + continue + } + if (token === 'rotate-none') { + assign(classes, 'rotation', 0, token) + continue + } + const simpleGridTracks = /^grid-(cols|rows)-(\d+)$/.exec(token) + if (simpleGridTracks) { + const count = Number(simpleGridTracks[2]) + if (!Number.isSafeInteger(count) || count < 1 || count > MAX_GRID_TRACKS) { + classError(`Grid class "${token}" must contain 1 to ${MAX_GRID_TRACKS} tracks.`) + } + assign( + classes, + simpleGridTracks[1] === 'cols' ? 'gridColumns' : 'gridRows', + Array.from({ length: count }, () => ({ type: 'FLEX', value: 1 })), + token + ) + classes.layoutClass ??= token + continue + } + const arbitraryGridTracks = /^grid-(cols|rows)-\[(.+)\]$/.exec(token) + if (arbitraryGridTracks) { + assign( + classes, + arbitraryGridTracks[1] === 'cols' ? 'gridColumns' : 'gridRows', + parseGridTracks(arbitraryGridTracks[2]!, token), + token + ) + classes.layoutClass ??= token + continue + } + if (token === 'grid-flow-row' || token === 'grid-flow-none') { + assign(classes, 'gridFlow', token === 'grid-flow-row' ? 'ROW_AUTO_FLOW' : 'MANUAL', token) + classes.layoutClass ??= token + continue + } + const gridPosition = /^(col|row)-(start|span)-(\d+)$/.exec(token) + if (gridPosition) { + const value = Number(gridPosition[3]) + if (!Number.isSafeInteger(value) || value < 1) { + classError(`Invalid grid placement class "${token}".`) + } + const field = + gridPosition[1] === 'col' + ? gridPosition[2] === 'start' + ? 'gridColumn' + : 'gridColumnSpan' + : gridPosition[2] === 'start' + ? 'gridRow' + : 'gridRowSpan' + assign(classes, field, gridPosition[2] === 'start' ? value - 1 : value, token) + classes.gridChildClass ??= token + continue + } + const gridAlignment = /^(justify-self|self)-(auto|start|center|end)$/.exec(token) + if (gridAlignment) { + assign( + classes, + gridAlignment[1] === 'justify-self' ? 'gridHorizontalAlign' : 'gridVerticalAlign', + GRID_ALIGNMENTS[gridAlignment[2] as keyof typeof GRID_ALIGNMENTS], + token + ) + classes.gridChildClass ??= token + continue + } + if (token === 'absolute' || token === 'static') { + assign(classes, 'absolute', token === 'absolute', token) + continue + } + const inset = /^(-)?(left|top)-(.+)$/.exec(token) + if (inset) { + const value = pixels(inset[3]!, token, { allowNegative: !inset[1], numericScale: 4 }) + if (value === null) classError(`Unsupported class "${token}".`) + assign(classes, inset[2] as 'left' | 'top', inset[1] ? -value : value, token) + continue + } + + const size = /^size-(.+)$/.exec(token) + if (size) { + if (size[1] === 'fit' || size[1] === 'full') { + const mode = size[1] === 'fit' ? 'HUG' : 'FILL' + assign(classes, 'width', { mode }, token) + assign(classes, 'height', { mode }, token) + continue + } + const value = fixedSize(size[1]!, token) + if (value === null) classError(`Unsupported class "${token}".`) + assign(classes, 'width', { mode: 'FIXED', value }, token) + assign(classes, 'height', { mode: 'FIXED', value }, token) + continue + } + const fluidSize = /^(w|h)-(fit|full)$/.exec(token) + if (fluidSize) { + const axis = fluidSize[1] === 'w' ? 'width' : 'height' + assign(classes, axis, { mode: fluidSize[2] === 'fit' ? 'HUG' : 'FILL' }, token) + continue + } + const boundedSize = /^(min|max)-(w|h)-(.+)$/.exec(token) + if (boundedSize) { + const field = `${boundedSize[1]}${boundedSize[2] === 'w' ? 'Width' : 'Height'}` as + | 'maxHeight' + | 'maxWidth' + | 'minHeight' + | 'minWidth' + const value = + boundedSize[3] === 'none' ? null : fixedSize(boundedSize[3]!, token, boundedSize[2] === 'w') + if (value === null && boundedSize[3] !== 'none') { + classError(`Unsupported class "${token}".`) + } + assign(classes, field, value, token) + continue + } + const axisSize = /^(w|h)-(.+)$/.exec(token) + if (axisSize) { + const value = fixedSize(axisSize[2]!, token, axisSize[1] === 'w') + if (value === null) classError(`Unsupported class "${token}".`) + assign(classes, axisSize[1] === 'w' ? 'width' : 'height', { mode: 'FIXED', value }, token) + continue + } + + const gap = /^gap(?:-(x|y))?-(.+)$/.exec(token) + if (gap) { + const field = gap[1] === 'x' ? 'columnGap' : gap[1] === 'y' ? 'rowGap' : 'gap' + const value = pixels(gap[2]!, token, { numericScale: 4 }) + if (value === null) classError(`Unsupported class "${token}".`) + assign(classes, field, value, token) + classes.layoutClass ??= token + continue + } + const padding = /^(p|px|py|pt|pr|pb|pl)-(.+)$/.exec(token) + if (padding) { + const value = pixels(padding[2]!, token, { numericScale: 4 }) + if (value === null) classError(`Unsupported class "${token}".`) + assignPadding(classes, PADDING_SIDES[padding[1] as keyof typeof PADDING_SIDES], value, token) + continue + } + + const itemAlignment = ALIGN_ITEM_CLASSES[token] + const counterAlign = ITEM_ALIGNMENTS[itemAlignment as keyof typeof ITEM_ALIGNMENTS] + if (counterAlign) { + assign(classes, 'counterAlign', counterAlign, token) + classes.layoutClass ??= token + continue + } + const justifyContent = JUSTIFY_CONTENT_CLASSES[token] + const primaryAlign = JUSTIFY_ALIGNMENTS[justifyContent as keyof typeof JUSTIFY_ALIGNMENTS] + if (primaryAlign) { + assign(classes, 'primaryAlign', primaryAlign, token) + classes.layoutClass ??= token + continue + } + if (token === 'flex-wrap' || token === 'flex-nowrap') { + assign(classes, 'wrap', token === 'flex-wrap' ? 'WRAP' : 'NO_WRAP', token) + classes.layoutClass ??= token + continue + } + if (token === 'content-between' || token === 'content-normal') { + assign( + classes, + 'counterAlignContent', + token === 'content-between' ? 'SPACE_BETWEEN' : 'AUTO', + token + ) + classes.layoutClass ??= token + continue + } + if (token === 'box-border' || token === 'box-content') { + assign(classes, 'strokesIncluded', token === 'box-border', token) + classes.layoutClass ??= token + continue + } + + if (token === 'bg-transparent') { + assign(classes, 'fill', null, token) + classes.frameClass ??= token + continue + } + if (token === 'overflow-hidden' || token === 'overflow-visible') { + assign(classes, 'clipsContent', token === 'overflow-hidden', token) + classes.frameClass ??= token + continue + } + const fill = /^bg-(.+)$/.exec(token) + if (fill) { + const value = color(fill[1]!) + if (value) { + assign(classes, 'fill', value, token) + classes.frameClass ??= token + continue + } + } + if (token === 'border') { + assign(classes, 'strokeWeight', 1, token) + classes.frameClass ??= token + continue + } + const borderSideWeight = /^border-(x|y|t|r|b|l)(?:-(.+))?$/.exec(token) + if (borderSideWeight) { + const value = + borderSideWeight[2] === undefined + ? 1 + : pixels(borderSideWeight[2], token, { numericScale: 1 }) + if (value === null) classError(`Unsupported class "${token}".`) + const side = borderSideWeight[1] as keyof typeof BORDER_SIDES | keyof typeof BORDER_AXES + const fields = + side in BORDER_AXES + ? BORDER_AXES[side as keyof typeof BORDER_AXES] + : [BORDER_SIDES[side as keyof typeof BORDER_SIDES]] + assignIndividuals(classes, 'stroke', classes.strokeWeights, fields, value, token) + classes.frameClass ??= token + continue + } + const borderWeight = /^border-(.+)$/.exec(token) + if (borderWeight) { + const width = pixels(borderWeight[1]!, token, { numericScale: 1 }) + if (width !== null) { + assign(classes, 'strokeWeight', width, token) + classes.frameClass ??= token + continue + } + const stroke = color(borderWeight[1]!) + if (stroke) { + assign(classes, 'stroke', stroke, token) + classes.frameClass ??= token + continue + } + } + if (token === 'rounded') { + assign(classes, 'cornerRadius', 4, token) + classes.frameClass ??= token + continue + } + const cornerRadius = /^rounded-(t|r|b|l|tl|tr|br|bl)(?:-(.+))?$/.exec(token) + if (cornerRadius) { + const value = cornerRadius[2] === undefined ? 4 : radius(cornerRadius[2], token) + if (value === null) classError(`Unsupported class "${token}".`) + const corner = cornerRadius[1] as keyof typeof CORNERS | keyof typeof CORNER_GROUPS + const fields = + corner in CORNER_GROUPS + ? CORNER_GROUPS[corner as keyof typeof CORNER_GROUPS] + : [CORNERS[corner as keyof typeof CORNERS]] + assignIndividuals(classes, 'corner', classes.cornerRadii, fields, value, token) + classes.frameClass ??= token + continue + } + const uniformRadius = /^rounded-(.+)$/.exec(token) + if (uniformRadius) { + const value = radius(uniformRadius[1]!, token) + if (value !== null) { + assign(classes, 'cornerRadius', value, token) + classes.frameClass ??= token + continue + } + } + const opacity = /^opacity-(?:\[((?:\d+(?:\.\d+)?|\.\d+))\]|((?:\d+(?:\.\d+)?|\.\d+)))$/.exec( + token + ) + if (opacity) { + const numeric = finiteNumber(opacity[1] ?? opacity[2]!, token) / (opacity[2] ? 100 : 1) + if (numeric > 1) classError(`Opacity class "${token}" must be between 0 and 1.`) + assign(classes, 'opacity', numeric, token) + continue + } + + if (token === 'font-sans') { + assign(classes, 'fontFamily', 'Inter', token) + classes.textClass ??= token + continue + } + if (token === 'whitespace-pre-wrap') { + assign(classes, 'preserveWhitespace', true, token) + classes.textClass ??= token + continue + } + const fontWeight = FONT_WEIGHT_CLASSES[token] + const fontStyle = FONT_STYLES[fontWeight as keyof typeof FONT_STYLES] + if (fontStyle) { + assign(classes, 'fontStyle', fontStyle, token) + classes.textClass ??= token + continue + } + const combinedTextSize = /^text-(\[[^\]]+\]|[^/]+)\/(.+)$/.exec(token) + if (combinedTextSize) { + const size = textSize(combinedTextSize[1]!, token) + const leading = lineHeight(combinedTextSize[2]!, token) + if (!size || !leading) classError(`Unsupported class "${token}".`) + assign(classes, 'fontSize', size.value, token) + assign(classes, 'lineHeight', leading, token) + classes.textClass ??= token + continue + } + const standaloneTextSize = /^text-(.+)$/.exec(token) + if (standaloneTextSize) { + const size = textSize(standaloneTextSize[1]!, token) + if (size) { + assign(classes, 'fontSize', size.value, token) + defaultLineHeight = size.defaultLineHeight + classes.textClass ??= token + continue + } + } + const leading = /^leading-(.+)$/.exec(token) + if (leading) { + const value = lineHeight(leading[1]!, token) + if (value === null) classError(`Unsupported class "${token}".`) + assign(classes, 'lineHeight', value, token) + classes.textClass ??= token + continue + } + const letterSpacing = /^tracking-(?:\[(-?(?:\d+(?:\.\d+)?|\.\d+))(px|%|em)\]|(\w+))$/.exec( + token + ) + if (letterSpacing) { + const named = LETTER_SPACINGS[letterSpacing[3] as keyof typeof LETTER_SPACINGS] + if (letterSpacing[3] && named === undefined) classError(`Unsupported class "${token}".`) + const unit = letterSpacing[2] + const value = + named ?? + finiteNumber(letterSpacing[1]!, token, { allowNegative: true }) * (unit === 'em' ? 100 : 1) + assign( + classes, + 'letterSpacing', + { + unit: named !== undefined || unit === '%' || unit === 'em' ? 'PERCENT' : 'PIXELS', + value + }, + token + ) + classes.textClass ??= token + continue + } + const textAlign = TEXT_ALIGN_CLASSES[token] + const textAlignment = TEXT_ALIGNMENTS[textAlign as keyof typeof TEXT_ALIGNMENTS] + if (textAlignment) { + assign(classes, 'textAlign', textAlignment, token) + classes.textClass ??= token + continue + } + const textTransform = TEXT_CASE_CLASSES[token] + const textCase = TEXT_CASES[textTransform as keyof typeof TEXT_CASES] + if (textCase) { + assign(classes, 'textCase', textCase, token) + classes.textClass ??= token + continue + } + const decorationLine = TEXT_DECORATION_CLASSES[token] + const textDecoration = TEXT_DECORATIONS[decorationLine as keyof typeof TEXT_DECORATIONS] + if (textDecoration) { + assign(classes, 'textDecoration', textDecoration, token) + classes.textClass ??= token + continue + } + if (token === 'truncate') { + assign(classes, 'textTruncation', 'ENDING', token) + assign(classes, 'maxLines', 1, token) + classes.textClass ??= token + continue + } + if (token === 'line-clamp-none') { + assign(classes, 'textTruncation', 'DISABLED', token) + assign(classes, 'maxLines', null, token) + classes.textClass ??= token + continue + } + const lineClamp = /^line-clamp-(\d+)$/.exec(token) + if (lineClamp) { + const maxLines = Number(lineClamp[1]) + if (!Number.isSafeInteger(maxLines) || maxLines < 1) { + classError(`Invalid line clamp class "${token}".`) + } + assign(classes, 'textTruncation', 'ENDING', token) + assign(classes, 'maxLines', maxLines, token) + classes.textClass ??= token + continue + } + const textColor = /^text-(.+)$/.exec(token) + if (textColor) { + const value = color(textColor[1]!) + if (value) { + assign(classes, 'fill', value, token) + classes.textClass ??= token + continue + } + } + + classError(`Unsupported class "${token}".`) + } + classes.lineHeight ??= defaultLineHeight + return classes +} diff --git a/packages/extension/mcp/tools/canvas/variables.ts b/packages/extension/mcp/tools/canvas/variables.ts new file mode 100644 index 00000000..b3bf4e2e --- /dev/null +++ b/packages/extension/mcp/tools/canvas/variables.ts @@ -0,0 +1,1089 @@ +import type { + CanvasVariableCollectionReference, + CanvasVariableCollections, + CanvasVariableReference, + CanvasVariableValue +} from '@tempad-dev/shared' + +import { getLocalStyles } from '../../local-styles' +import { collectVariableAliasIds } from '../../variable-references' +import { scopeError, specError } from './errors' +import { + CANVAS_KEY_NAMESPACE, + CANVAS_VARIABLE_COLLECTION_KEY_NAME, + CANVAS_VARIABLE_KEY_NAME, + CANVAS_VARIABLE_MODE_KEYS_NAME, + type MutationCounter, + claimAuthoringKey, + designReferenceCacheKey, + parseVariableModeKeys, + readAuthoringKey +} from './identity' + +type CollectionSpec = Exclude +type ModeSpec = Exclude[string], null> +type OverrideSpec = NonNullable[number] +type VariableSpec = Exclude[string], null> + +type ModeRemoval = { + collection: VariableCollection + modeId: string +} + +export type CanvasVariableState = { + collectionCache: Map + collectionsByKey: Map + collectionRemovals: VariableCollection[] + localIndex?: Promise + modeIdsByCollection: Map> + modeRemovals: ModeRemoval[] + variableCache: Map + variableRemovals: Variable[] + variablesByKey: Map +} + +type VariableWork = { + collection: VariableCollection + spec: VariableSpec + values: Map + variable: Variable +} + +type OverrideWork = { + collection: ExtendedVariableCollection + values: Map + variable: Variable +} + +function extendedCollection(collection: VariableCollection): ExtendedVariableCollection { + if (!collection.isExtension) { + specError(`Variable collection "${collection.id}" is not an extended collection.`) + } + return collection as unknown as ExtendedVariableCollection +} + +export function createVariableState(): CanvasVariableState { + return { + collectionCache: new Map(), + collectionsByKey: new Map(), + collectionRemovals: [], + modeIdsByCollection: new Map(), + modeRemovals: [], + variableCache: new Map(), + variableRemovals: [], + variablesByKey: new Map() + } +} + +export function variableReferenceCacheKey(reference: CanvasVariableReference): string { + if ('variableKey' in reference) return `variable-key:${reference.variableKey}` + return designReferenceCacheKey(reference) +} + +function readModeIds(collection: VariableCollection): Map { + const raw = collection.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_VARIABLE_MODE_KEYS_NAME) + const modeIds = parseVariableModeKeys(raw, collection.modes) + if (!modeIds) { + scopeError(`Variable mode identity data on collection "${collection.id}" is invalid.`) + } + return modeIds +} + +function serializeModeIds(modeIds: Map): string { + return JSON.stringify(Object.fromEntries(modeIds)) +} + +function indexResource( + resources: Map, + key: string, + resource: T, + kind: string +): void { + const existing = resources.get(key) + if (existing && existing.id !== resource.id) { + scopeError(`${kind} authoring key "${key}" is duplicated in this file.`) + } + resources.set(key, resource) +} + +async function ensureLocalIndex(state: CanvasVariableState): Promise { + state.localIndex ??= (async () => { + const [collections, variables] = await Promise.all([ + figma.variables.getLocalVariableCollectionsAsync(), + figma.variables.getLocalVariablesAsync() + ]) + for (const collection of collections) { + state.collectionCache.set(`id:${collection.id}`, collection) + const key = readAuthoringKey(collection, CANVAS_VARIABLE_COLLECTION_KEY_NAME) + if (key) indexResource(state.collectionsByKey, key, collection, 'Variable collection') + state.modeIdsByCollection.set(collection.id, readModeIds(collection)) + } + for (const variable of variables) { + state.variableCache.set(`id:${variable.id}`, variable) + const key = readAuthoringKey(variable, CANVAS_VARIABLE_KEY_NAME) + if (key) indexResource(state.variablesByKey, key, variable, 'Variable') + } + })() + await state.localIndex +} + +export async function resolveVariable( + reference: CanvasVariableReference, + state: CanvasVariableState +): Promise { + const cacheKey = variableReferenceCacheKey(reference) + const cached = state.variableCache.get(cacheKey) + if (cached) return cached + + let variable: Variable | null | undefined + if ('variableKey' in reference) { + await ensureLocalIndex(state) + variable = state.variablesByKey.get(reference.variableKey) + } else { + variable = + reference.id !== undefined + ? await figma.variables.getVariableByIdAsync(reference.id) + : await figma.variables.importVariableByKeyAsync(reference.key) + } + if (!variable) specError('The requested variable could not be resolved.') + state.variableCache.set(cacheKey, variable) + state.variableCache.set(`id:${variable.id}`, variable) + return variable +} + +export function resolvedVariable( + reference: CanvasVariableReference, + state: CanvasVariableState +): Variable { + const variable = state.variableCache.get(variableReferenceCacheKey(reference)) + if (!variable) specError('A preflighted variable could not be resolved.') + return variable +} + +export async function resolveCollection( + reference: string, + state: CanvasVariableState +): Promise { + const cached = state.collectionCache.get(`id:${reference}`) + if (cached) return cached + + const byId = await figma.variables.getVariableCollectionByIdAsync(reference) + if (byId) { + state.collectionCache.set(`id:${reference}`, byId) + return byId + } + await ensureLocalIndex(state) + const collection = state.collectionsByKey.get(reference) + if (!collection) specError(`Variable collection "${reference}" could not be resolved.`) + return collection +} + +export function resolvedCollection( + reference: string, + state: CanvasVariableState +): VariableCollection { + const collection = + state.collectionCache.get(`id:${reference}`) ?? state.collectionsByKey.get(reference) + if (!collection) { + specError(`Preflighted variable collection "${reference}" could not be resolved.`) + } + return collection +} + +export async function resolveModeId( + collection: VariableCollection, + reference: string, + state: CanvasVariableState +): Promise { + if (collection.modes.some((mode) => mode.modeId === reference)) return reference + await ensureLocalIndex(state) + const modeId = state.modeIdsByCollection.get(collection.id)?.get(reference) + if (modeId) return modeId + if (collection.isExtension) { + const extended = extendedCollection(collection) + const direct = extended.modes.find((mode) => mode.parentModeId === reference) + if (direct) return direct.modeId + const parent = await resolveCollection(extended.parentVariableCollectionId, state) + const parentModeId = await resolveModeId(parent, reference, state) + const inherited = extended.modes.find((mode) => mode.parentModeId === parentModeId) + if (inherited) return inherited.modeId + } + specError(`Variable collection "${collection.id}" has no mode "${reference}".`) +} + +export function resolvedModeId( + collection: VariableCollection, + reference: string, + state: CanvasVariableState +): string { + if (collection.modes.some((mode) => mode.modeId === reference)) return reference + const modeId = state.modeIdsByCollection.get(collection.id)?.get(reference) + if (modeId) return modeId + if (collection.isExtension) { + const extended = extendedCollection(collection) + const direct = extended.modes.find((mode) => mode.parentModeId === reference) + if (direct) return direct.modeId + const parent = resolvedCollection(extended.parentVariableCollectionId, state) + const parentModeId = resolvedModeId(parent, reference, state) + const inherited = extended.modes.find((mode) => mode.parentModeId === parentModeId) + if (inherited) return inherited.modeId + } + specError(`Preflighted variable mode "${reference}" could not be resolved.`) +} + +async function collectionByReference( + reference: CanvasVariableCollectionReference, + state: CanvasVariableState +): Promise { + if ('collectionKey' in reference) { + return resolveCollection(reference.collectionKey, state) + } + if (reference.id !== undefined) { + const collection = await resolveCollection(reference.id, state) + if (reference.key !== undefined && collection.key !== reference.key) { + specError(`Variable collection "${reference.id}" does not have key "${reference.key}".`) + } + return collection + } + specError('A published collection key cannot be resolved before extension.') +} + +async function createExtendedCollection( + reference: CanvasVariableCollectionReference, + name: string, + state: CanvasVariableState +): Promise { + let collection: ExtendedVariableCollection + if (!('collectionKey' in reference) && reference.id === undefined) { + collection = await figma.variables.extendLibraryCollectionByKeyAsync(reference.key, name) + } else { + const parent = await collectionByReference(reference, state) + collection = parent.remote + ? await figma.variables.extendLibraryCollectionByKeyAsync(parent.key, name) + : parent.extend(name) + } + return collection as unknown as VariableCollection +} + +async function validateExtendedParent( + collection: VariableCollection, + reference: CanvasVariableCollectionReference, + state: CanvasVariableState +): Promise { + const extended = extendedCollection(collection) + const parent = await figma.variables.getVariableCollectionByIdAsync( + extended.parentVariableCollectionId + ) + if (!parent) { + specError(`Parent of extended collection "${collection.id}" does not exist.`) + } + if (!('collectionKey' in reference) && reference.id === undefined) { + if (parent.key !== reference.key) { + specError(`Extended collection "${collection.id}" does not inherit "${reference.key}".`) + } + return + } + const expected = await collectionByReference(reference, state) + if (parent.id !== expected.id) { + specError(`Extended collection "${collection.id}" does not inherit "${expected.id}".`) + } +} + +async function selectCollection( + key: string, + spec: CollectionSpec, + state: CanvasVariableState, + mutations: MutationCounter +): Promise<{ collection: VariableCollection; isNew: boolean }> { + const keyed = state.collectionsByKey.get(key) + const explicit = spec.id + ? await figma.variables.getVariableCollectionByIdAsync(spec.id) + : undefined + if (spec.id && !explicit) specError(`Variable collection "${spec.id}" does not exist.`) + if (keyed && explicit && keyed.id !== explicit.id) { + specError(`Variable collection key "${key}" does not identify "${explicit.id}".`) + } + let collection = explicit ?? keyed + const isNew = !collection + if (!collection) { + if (!spec.name) specError(`New variable collection "${key}" requires a name.`) + if (spec.extends) { + collection = await createExtendedCollection(spec.extends, spec.name, state) + } else { + collection = figma.variables.createVariableCollection(spec.name) + } + mutations.count += 1 + } + if (collection.remote) { + specError(`Variable collection "${collection.id}" is not an editable local collection.`) + } + if (collection.isExtension) { + if (spec.modes || spec.variables) { + specError(`Extended collection "${collection.id}" cannot define modes or variables.`) + } + if (spec.extends) { + await validateExtendedParent(collection, spec.extends, state) + } + } else if (spec.extends || spec.overrides) { + specError(`Base collection "${collection.id}" cannot declare extension overrides.`) + } + claimAuthoringKey(collection, key, CANVAS_VARIABLE_COLLECTION_KEY_NAME, 'Resource', mutations) + indexResource(state.collectionsByKey, key, collection, 'Variable collection') + state.collectionCache.set(`id:${collection.id}`, collection) + state.modeIdsByCollection.set( + collection.id, + state.modeIdsByCollection.get(collection.id) ?? readModeIds(collection) + ) + return { collection, isNew } +} + +function orderedCollectionEntries( + specs: CanvasVariableCollections +): Array<[string, CollectionSpec]> { + const pending = new Map( + Object.entries(specs).filter((entry): entry is [string, CollectionSpec] => entry[1] !== null) + ) + const ordered: Array<[string, CollectionSpec]> = [] + while (pending.size) { + let progressed = false + for (const [key, spec] of pending) { + const parentKey = + spec.extends && 'collectionKey' in spec.extends ? spec.extends.collectionKey : undefined + if (parentKey && pending.has(parentKey)) continue + ordered.push([key, spec]) + pending.delete(key) + progressed = true + } + if (!progressed) { + specError('Extended variable collections contain a parent cycle.') + } + } + return ordered +} + +function selectMode( + collection: VariableCollection, + key: string, + spec: ModeSpec, + isNewCollection: boolean, + first: boolean, + modeIds: Map, + mutations: MutationCounter +): void { + const mappedId = modeIds.get(key) + if (mappedId && spec.id && mappedId !== spec.id) { + specError(`Variable mode key "${key}" does not identify "${spec.id}".`) + } + let modeId = spec.id ?? mappedId + if (modeId && !collection.modes.some((mode) => mode.modeId === modeId)) { + specError(`Variable collection "${collection.id}" has no mode "${modeId}".`) + } + if (!modeId) { + if (!spec.name) specError(`New variable mode "${key}" requires a name.`) + modeId = isNewCollection && first ? collection.defaultModeId : collection.addMode(spec.name) + if (!(isNewCollection && first)) mutations.count += 1 + } + const claimedKey = [...modeIds].find( + ([existingKey, existingId]) => existingId === modeId && existingKey !== key + )?.[0] + if (claimedKey) { + specError(`Variable mode "${modeId}" is already owned by authoring key "${claimedKey}".`) + } + modeIds.set(key, modeId) + const current = collection.modes.find((mode) => mode.modeId === modeId)! + if (spec.name !== undefined && current.name !== spec.name) { + collection.renameMode(modeId, spec.name) + mutations.count += 1 + } +} + +function reconcileModes( + collection: VariableCollection, + specs: CollectionSpec['modes'], + isNew: boolean, + state: CanvasVariableState, + mutations: MutationCounter +): string[] { + if (!specs) return [] + const existingIds = new Set(collection.modes.map((mode) => mode.modeId)) + const modeIds = state.modeIdsByCollection.get(collection.id) ?? new Map() + const before = serializeModeIds(modeIds) + let first = true + for (const [key, spec] of Object.entries(specs)) { + if (spec === null) { + const modeId = modeIds.get(key) + if (modeId) state.modeRemovals.push({ collection, modeId }) + continue + } + selectMode(collection, key, spec, isNew, first, modeIds, mutations) + first = false + } + state.modeIdsByCollection.set(collection.id, modeIds) + const after = serializeModeIds(modeIds) + if (after !== before) { + collection.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_VARIABLE_MODE_KEYS_NAME, after) + mutations.count += 1 + } + return isNew + ? [] + : collection.modes.map((mode) => mode.modeId).filter((modeId) => !existingIds.has(modeId)) +} + +function setResourceValue( + current: T, + desired: T | undefined, + apply: (value: T) => void, + mutations: MutationCounter, + equal: (left: T, right: T) => boolean = Object.is +): void { + if (desired === undefined || equal(current, desired)) return + apply(desired) + mutations.count += 1 +} + +function setCollectionProperties( + collection: VariableCollection, + spec: CollectionSpec, + mutations: MutationCounter +): void { + setResourceValue(collection.name, spec.name, (value) => (collection.name = value), mutations) + setResourceValue( + collection.hiddenFromPublishing, + spec.hiddenFromPublishing, + (value) => (collection.hiddenFromPublishing = value), + mutations + ) +} + +async function resolveValues( + collection: VariableCollection, + values: Record | undefined, + state: CanvasVariableState, + kind: string +): Promise> { + const resolved = new Map() + for (const [modeReference, value] of Object.entries(values ?? {})) { + const modeId = await resolveModeId(collection, modeReference, state) + if (resolved.has(modeId)) { + specError(`${kind} describes mode "${modeId}" more than once.`) + } + resolved.set(modeId, value) + } + return resolved +} + +async function selectVariable( + collection: VariableCollection, + key: string, + spec: VariableSpec, + state: CanvasVariableState, + mutations: MutationCounter +): Promise { + const keyed = state.variablesByKey.get(key) + const explicit = spec.id ? await figma.variables.getVariableByIdAsync(spec.id) : undefined + if (spec.id && !explicit) specError(`Variable "${spec.id}" does not exist.`) + if (keyed && explicit && keyed.id !== explicit.id) { + specError(`Variable key "${key}" does not identify "${explicit.id}".`) + } + let variable = explicit ?? keyed + const values = await resolveValues(collection, spec.values, state, 'Variable value') + if (!variable) { + if (!spec.name || !spec.type) { + specError(`New variable "${key}" requires a name and type.`) + } + const missingMode = collection.modes.find((mode) => !values.has(mode.modeId)) + if (missingMode) { + specError(`New variable "${key}" requires a value for mode "${missingMode.name}".`) + } + variable = figma.variables.createVariable(spec.name, collection, spec.type) + mutations.count += 1 + } + if (variable.remote || variable.variableCollectionId !== collection.id) { + specError(`Variable "${variable.id}" is not editable in collection "${collection.id}".`) + } + if (spec.type !== undefined && variable.resolvedType !== spec.type) { + specError(`Variable "${variable.id}" is ${variable.resolvedType}, expected ${spec.type}.`) + } + claimAuthoringKey(variable, key, CANVAS_VARIABLE_KEY_NAME, 'Resource', mutations) + indexResource(state.variablesByKey, key, variable, 'Variable') + state.variableCache.set(`id:${variable.id}`, variable) + state.variableCache.set(`variable-key:${key}`, variable) + return { collection, spec, values, variable } +} + +async function selectOverride( + collection: VariableCollection, + spec: OverrideSpec, + state: CanvasVariableState +): Promise { + const extended = extendedCollection(collection) + const variable = await resolveVariable(spec.variable, state) + if (!extended.variableIds.includes(variable.id)) { + specError(`Variable "${variable.id}" is not inherited by extended collection "${extended.id}".`) + } + return { + collection: extended, + values: await resolveValues(collection, spec.values, state, 'Extended variable override'), + variable + } +} + +function setVariableProperties(work: VariableWork, mutations: MutationCounter): void { + const { spec, variable } = work + setResourceValue(variable.name, spec.name, (value) => (variable.name = value), mutations) + setResourceValue( + variable.description, + spec.description, + (value) => (variable.description = value), + mutations + ) + setResourceValue( + variable.hiddenFromPublishing, + spec.hiddenFromPublishing, + (value) => (variable.hiddenFromPublishing = value), + mutations + ) + setResourceValue( + variable.scopes, + spec.scopes, + (value) => (variable.scopes = value), + mutations, + (left, right) => + left.length === right.length && left.every((value, index) => value === right[index]) + ) + for (const [platform, value] of Object.entries(spec.codeSyntax ?? {}) as Array< + [CodeSyntaxPlatform, string | null] + >) { + const current = variable.codeSyntax[platform] + if (value === null) { + if (current !== undefined) { + variable.removeVariableCodeSyntax(platform) + mutations.count += 1 + } + } else if (current !== value) { + variable.setVariableCodeSyntax(platform, value) + mutations.count += 1 + } + } +} + +function isAlias(value: VariableValue | CanvasVariableValue): value is VariableAlias { + return typeof value === 'object' && value !== null && 'type' in value +} + +function isVariableReference( + value: CanvasVariableValue +): value is { variable: CanvasVariableReference } { + return typeof value === 'object' && value !== null && 'variable' in value +} + +function isColor(value: unknown): value is RGB | RGBA { + return typeof value === 'object' && value !== null && 'r' in value && 'g' in value && 'b' in value +} + +function valuesEqual(left: VariableValue | undefined, right: VariableValue): boolean { + if (left === right) return true + if (left === undefined || typeof left !== 'object' || typeof right !== 'object') return false + if (isAlias(left) || isAlias(right)) { + return isAlias(left) && isAlias(right) && left.id === right.id + } + if (!isColor(left) || !isColor(right)) return false + return ( + left.r === right.r && + left.g === right.g && + left.b === right.b && + ('a' in left ? left.a : 1) === ('a' in right ? right.a : 1) + ) +} + +function literalMatchesType(value: CanvasVariableValue, type: VariableResolvedDataType): boolean { + if (type === 'BOOLEAN') return typeof value === 'boolean' + if (type === 'FLOAT') return typeof value === 'number' + if (type === 'STRING') return typeof value === 'string' + return isColor(value) +} + +async function nativeValue( + value: CanvasVariableValue, + variable: Variable, + state: CanvasVariableState +): Promise { + if (!isVariableReference(value)) { + if (!literalMatchesType(value, variable.resolvedType)) { + specError(`Value for variable "${variable.id}" must be ${variable.resolvedType}.`) + } + return value + } + const target = await resolveVariable(value.variable, state) + if (target.resolvedType !== variable.resolvedType) { + specError( + `Variable alias "${target.id}" is ${target.resolvedType}, expected ${variable.resolvedType}.` + ) + } + return figma.variables.createVariableAlias(target) +} + +async function setVariableValues( + work: VariableWork, + state: CanvasVariableState, + mutations: MutationCounter +): Promise { + for (const [modeId, value] of work.values) { + const desired = await nativeValue(value, work.variable, state) + if (valuesEqual(work.variable.valuesByMode[modeId], desired)) continue + work.variable.setValueForMode(modeId, desired) + mutations.count += 1 + } +} + +async function setOverrideValues( + work: OverrideWork, + state: CanvasVariableState, + mutations: MutationCounter +): Promise { + const current = work.collection.variableOverrides[work.variable.id] ?? {} + for (const [modeId, value] of work.values) { + if (value === null) { + if (current[modeId] === undefined) continue + work.variable.removeOverrideForMode(modeId) + } else { + const desired = await nativeValue(value, work.variable, state) + if (valuesEqual(current[modeId], desired)) continue + work.variable.setValueForMode(modeId, desired) + } + mutations.count += 1 + } +} + +function validateNewCollection(key: string, spec: CollectionSpec): void { + if (spec.extends) { + if (spec.modes || spec.variables) { + specError(`Extended collection "${key}" cannot define modes or variables.`) + } + return + } + if (spec.overrides) { + specError(`Base collection "${key}" cannot declare extension overrides.`) + } + const modes = Object.entries(spec.modes ?? {}) + .filter(([, mode]) => mode !== null) + .map(([modeKey]) => modeKey) + if (!modes.length) { + specError(`New variable collection "${key}" requires at least one mode.`) + } + for (const [modeKey, mode] of Object.entries(spec.modes ?? {})) { + if (mode === null) continue + if (mode.id) { + specError(`New variable mode "${modeKey}" cannot declare an existing id.`) + } + } + for (const [variableKey, variable] of Object.entries(spec.variables ?? {})) { + if (variable === null) continue + if (variable.id) { + specError(`Variable "${variableKey}" cannot be adopted into new collection "${key}".`) + } + if (!variable.name || !variable.type) { + specError(`New variable "${variableKey}" requires a name and type.`) + } + const values = new Set(Object.keys(variable.values ?? {})) + const missing = modes.find((modeKey) => !values.has(modeKey)) + if (missing) { + specError(`New variable "${variableKey}" requires a value for mode "${missing}".`) + } + const unknown = [...values].find((modeKey) => !modes.includes(modeKey)) + if (unknown) { + specError(`New collection "${key}" has no mode "${unknown}".`) + } + } +} + +async function initializeAddedModes( + collection: VariableCollection, + modeIds: string[], + works: VariableWork[], + state: CanvasVariableState, + mutations: MutationCounter +): Promise { + if (!modeIds.length) return + const desiredByVariable = new Map(works.map((work) => [work.variable.id, work.values])) + for (const variableId of collection.variableIds) { + const variable = await resolveVariable({ id: variableId }, state) + for (const modeId of modeIds) { + if (desiredByVariable.get(variableId)?.has(modeId)) continue + const fallback = variable.valuesByMode[collection.defaultModeId] + if (fallback === undefined) { + specError( + `Variable "${variable.id}" requires a value before adding a mode to collection "${collection.id}".` + ) + } + variable.setValueForMode(modeId, fallback) + mutations.count += 1 + } + } +} + +export async function reconcileVariableCollections( + specs: CanvasVariableCollections | undefined, + state: CanvasVariableState, + mutations: MutationCounter +): Promise { + if (!specs) return + await ensureLocalIndex(state) + const collections: Array<{ + addedModeIds: string[] + collection: VariableCollection + spec: CollectionSpec + }> = [] + + for (const [key, spec] of Object.entries(specs)) { + if (spec !== null) continue + const collection = state.collectionsByKey.get(key) + if (collection) state.collectionRemovals.push(collection) + } + + for (const [key, spec] of orderedCollectionEntries(specs)) { + if (!spec.id && !state.collectionsByKey.has(key)) validateNewCollection(key, spec) + const { collection, isNew } = await selectCollection(key, spec, state, mutations) + const addedModeIds = reconcileModes(collection, spec.modes, isNew, state, mutations) + setCollectionProperties(collection, spec, mutations) + collections.push({ addedModeIds, collection, spec }) + } + + const variables: VariableWork[] = [] + for (const { collection, spec } of collections) { + for (const [key, variable] of Object.entries(spec.variables ?? {})) { + if (variable === null) { + const existing = state.variablesByKey.get(key) + if (!existing) continue + if (existing.variableCollectionId !== collection.id) { + specError(`Variable "${existing.id}" is not in collection "${collection.id}".`) + } + state.variableRemovals.push(existing) + continue + } + variables.push(await selectVariable(collection, key, variable, state, mutations)) + } + } + const overrides: OverrideWork[] = [] + const overridden = new Set() + for (const { collection, spec } of collections) { + if (!spec.overrides) continue + for (const override of spec.overrides) { + const work = await selectOverride(collection, override, state) + const key = `${collection.id}:${work.variable.id}` + if (overridden.has(key)) { + specError( + `Variable "${work.variable.id}" has more than one override entry in collection "${collection.id}".` + ) + } + overridden.add(key) + overrides.push(work) + } + } + for (const { addedModeIds, collection } of collections) { + await initializeAddedModes( + collection, + addedModeIds, + variables.filter((variable) => variable.collection.id === collection.id), + state, + mutations + ) + } + for (const variable of variables) setVariableProperties(variable, mutations) + for (const variable of variables) await setVariableValues(variable, state, mutations) + for (const override of overrides) await setOverrideValues(override, state, mutations) +} + +function assertNoRemovedVariable( + value: unknown, + removedVariableIds: Set, + consumer: string +): void { + const referencedIds = new Set() + collectVariableAliasIds(value, referencedIds) + const variableId = [...referencedIds].find((id) => removedVariableIds.has(id)) + if (variableId) { + scopeError(`Variable "${variableId}" is still used by ${consumer}.`) + } +} + +function assertNoRemovedVariableInRetainedModes( + values: Record, + removedModeIds: Set, + removedVariableIds: Set, + consumer: string +): void { + for (const [modeId, value] of Object.entries(values)) { + if (!removedModeIds.has(modeId)) { + assertNoRemovedVariable(value, removedVariableIds, consumer) + } + } +} + +function assertModeAvailable( + consumer: SceneNode | PageNode, + removedCollectionIds: Set, + removedModeIds: Set +): void { + for (const [collectionId, modeId] of Object.entries(consumer.explicitVariableModes)) { + if (removedCollectionIds.has(collectionId) || removedModeIds.has(modeId)) { + scopeError(`Variable mode "${modeId}" is still selected on "${consumer.id}".`) + } + } +} + +async function collectDocumentConsumers(): Promise<{ + nodes: SceneNode[] + pages: PageNode[] +}> { + const pages = [...figma.root.children] + const nodes: SceneNode[] = [] + for (const page of pages) { + try { + await page.loadAsync() + } catch { + scopeError(`Page "${page.id}" could not be inspected before variable removal.`) + } + const pending = [...page.children] + while (pending.length) { + const node = pending.pop()! + nodes.push(node) + if ('children' in node) pending.push(...node.children) + } + } + return { nodes, pages } +} + +async function collectShadersForRemoval(): Promise { + try { + return await figma.listAvailableShaders() + } catch { + scopeError('Shaders could not be inspected before variable removal.') + } +} + +function inspectNodeVariables(node: SceneNode, removedVariableIds: Set): void { + assertNoRemovedVariable(node.boundVariables, removedVariableIds, `node "${node.id}"`) + const record = node as unknown as Record + for (const field of ['fills', 'strokes', 'effects', 'layoutGrids'] as const) { + assertNoRemovedVariable(record[field], removedVariableIds, `node "${node.id}"`) + } + if (node.type === 'VECTOR') { + assertNoRemovedVariable( + node.vectorNetwork.regions, + removedVariableIds, + `vector regions on node "${node.id}"` + ) + } + if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') { + assertNoRemovedVariable( + node.componentPropertyDefinitions, + removedVariableIds, + `component properties on node "${node.id}"` + ) + } + if (node.type !== 'TEXT') return + try { + const segments = node.getStyledTextSegments(['boundVariables', 'fills']) + assertNoRemovedVariable(segments, removedVariableIds, `rich text on node "${node.id}"`) + } catch { + scopeError(`Rich text on node "${node.id}" could not be inspected before variable removal.`) + } +} + +function modeRemovalPlan( + state: CanvasVariableState, + collections: VariableCollection[], + removedCollectionIds: Set +): { removals: ModeRemoval[]; removedModeIds: Set } { + const removals = state.modeRemovals.filter( + ({ collection }) => !removedCollectionIds.has(collection.id) + ) + const removedModeIds = new Set(removals.map(({ modeId }) => modeId)) + for (const collection of state.collectionRemovals) { + for (const mode of collection.modes) removedModeIds.add(mode.modeId) + } + + let changed = true + while (changed) { + changed = false + for (const collection of collections) { + if (!collection.isExtension || removedCollectionIds.has(collection.id)) continue + for (const mode of extendedCollection(collection).modes) { + if (!removedModeIds.has(mode.parentModeId) || removedModeIds.has(mode.modeId)) continue + removals.push({ collection, modeId: mode.modeId }) + removedModeIds.add(mode.modeId) + changed = true + } + } + } + return { removals, removedModeIds } +} + +function validateCollectionRemovals( + collections: VariableCollection[], + removedCollectionIds: Set +): void { + for (const collection of collections) { + if ( + collection.isExtension && + removedCollectionIds.has(extendedCollection(collection).parentVariableCollectionId) && + !removedCollectionIds.has(collection.id) + ) { + scopeError( + `Extended collection "${collection.id}" still depends on a collection marked for removal.` + ) + } + } +} + +function updateModeKeys( + collection: VariableCollection, + modeId: string, + state: CanvasVariableState +): number { + const modeIds = state.modeIdsByCollection.get(collection.id) + if (!modeIds) return 0 + const key = [...modeIds].find(([, id]) => id === modeId)?.[0] + if (!key) return 0 + modeIds.delete(key) + collection.setSharedPluginData( + CANVAS_KEY_NAMESPACE, + CANVAS_VARIABLE_MODE_KEYS_NAME, + serializeModeIds(modeIds) + ) + return 1 +} + +function collectionDepth( + collection: VariableCollection, + collectionsById: Map +): number { + let depth = 0 + let current = collection + const seen = new Set() + while (current.isExtension && !seen.has(current.id)) { + seen.add(current.id) + const parent = collectionsById.get(extendedCollection(current).parentVariableCollectionId) + if (!parent) break + current = parent + depth += 1 + } + return depth +} + +export async function removeVariableResources( + state: CanvasVariableState, + mutations: MutationCounter +): Promise { + if ( + !state.collectionRemovals.length && + !state.modeRemovals.length && + !state.variableRemovals.length + ) { + return + } + + const [collections, variables, styles, shaders, document] = await Promise.all([ + figma.variables.getLocalVariableCollectionsAsync(), + figma.variables.getLocalVariablesAsync(), + getLocalStyles(), + collectShadersForRemoval(), + collectDocumentConsumers() + ]) + const removedCollectionIds = new Set(state.collectionRemovals.map((collection) => collection.id)) + validateCollectionRemovals(collections, removedCollectionIds) + + const removedVariableIds = new Set(state.variableRemovals.map((variable) => variable.id)) + for (const collection of state.collectionRemovals) { + if (!collection.isExtension) { + for (const variableId of collection.variableIds) removedVariableIds.add(variableId) + } + } + const { removals: modeRemovals, removedModeIds } = modeRemovalPlan( + state, + collections, + removedCollectionIds + ) + for (const collection of collections) { + if (removedCollectionIds.has(collection.id)) continue + const removedCount = modeRemovals.filter( + (removal) => removal.collection.id === collection.id + ).length + if (collection.modes.length === removedCount) { + scopeError(`Variable collection "${collection.id}" must retain at least one mode.`) + } + } + + for (const page of document.pages) { + assertModeAvailable(page, removedCollectionIds, removedModeIds) + assertNoRemovedVariable(page.backgrounds, removedVariableIds, `page "${page.id}"`) + } + for (const node of document.nodes) { + assertModeAvailable(node, removedCollectionIds, removedModeIds) + inspectNodeVariables(node, removedVariableIds) + } + for (const style of styles) { + assertNoRemovedVariable(style.boundVariables, removedVariableIds, `style "${style.id}"`) + if (style.type === 'PAINT') { + assertNoRemovedVariable(style.paints, removedVariableIds, `style "${style.id}"`) + } else if (style.type === 'EFFECT') { + assertNoRemovedVariable(style.effects, removedVariableIds, `style "${style.id}"`) + } else if (style.type === 'GRID') { + assertNoRemovedVariable(style.layoutGrids, removedVariableIds, `style "${style.id}"`) + } + } + for (const variable of variables) { + if (removedVariableIds.has(variable.id)) continue + assertNoRemovedVariableInRetainedModes( + variable.valuesByMode, + removedModeIds, + removedVariableIds, + `variable "${variable.id}"` + ) + } + for (const collection of collections) { + if (!collection.isExtension || removedCollectionIds.has(collection.id)) continue + for (const [variableId, values] of Object.entries( + extendedCollection(collection).variableOverrides + )) { + if (removedVariableIds.has(variableId)) continue + assertNoRemovedVariableInRetainedModes( + values, + removedModeIds, + removedVariableIds, + `extended collection "${collection.id}"` + ) + } + } + for (const shader of shaders) { + assertNoRemovedVariable(shader.propertyDefinitions, removedVariableIds, `shader "${shader.id}"`) + } + + for (const variable of state.variableRemovals) { + for (const collection of collections) { + if (!collection.isExtension || removedCollectionIds.has(collection.id)) continue + const extended = extendedCollection(collection) + if (extended.variableOverrides[variable.id] === undefined) continue + extended.removeOverridesForVariable(variable) + mutations.count += 1 + } + } + for (const { collection, modeId } of modeRemovals) { + collection.removeMode(modeId) + mutations.count += 1 + updateModeKeys(collection, modeId, state) + } + for (const variable of state.variableRemovals) { + variable.remove() + mutations.count += 1 + } + const collectionsById = new Map(collections.map((collection) => [collection.id, collection])) + const collectionRemovals = [...state.collectionRemovals].sort( + (left, right) => + collectionDepth(right, collectionsById) - collectionDepth(left, collectionsById) + ) + for (const collection of collectionRemovals) { + collection.remove() + mutations.count += 1 + } +} diff --git a/packages/extension/mcp/tools/canvas/vector.ts b/packages/extension/mcp/tools/canvas/vector.ts new file mode 100644 index 00000000..e7604c56 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/vector.ts @@ -0,0 +1,108 @@ +import type { CanvasFigmaVectorPath } from '@tempad-dev/shared' + +const NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/ +const ARGUMENT_COUNTS = { + M: 2, + L: 2, + Q: 4, + C: 6, + Z: 0 +} as const + +type PathCommand = keyof typeof ARGUMENT_COUNTS + +function numberToken(token: string | undefined): number { + if (!token || !NUMBER_PATTERN.test(token)) { + throw new Error(`Expected a finite path number, received "${token ?? ''}".`) + } + const value = Number(token) + if (!Number.isFinite(value)) throw new Error(`Path number "${token}" is not finite.`) + return Object.is(value, -0) ? 0 : value +} + +function formatNumber(value: number): string { + return String(Object.is(value, -0) ? 0 : value) +} + +function canonicalVectorPathData(data: string): string { + const tokens = data.trim().split(/\s+/) + const output: string[] = [] + let current: { x: number; y: number } | undefined + let subpathStart: { x: number; y: number } | undefined + + for (let index = 0; index < tokens.length;) { + const token = tokens[index++]! + if (!Object.hasOwn(ARGUMENT_COUNTS, token)) { + throw new Error(`Unsupported vector path command "${token}".`) + } + const command = token as PathCommand + const count = ARGUMENT_COUNTS[command] + if (tokens.length - index < count) { + throw new Error(`Vector path command "${command}" requires ${count} numbers.`) + } + const values = tokens.slice(index, index + count).map(numberToken) + index += count + + if (command === 'M') { + current = { x: values[0]!, y: values[1]! } + subpathStart = current + output.push(command, ...values.map(formatNumber)) + continue + } + if (!current || !subpathStart) { + throw new Error(`Vector path command "${command}" requires a preceding M command.`) + } + if (command === 'Z') { + current = subpathStart + output.push(command) + continue + } + if (command === 'L') { + current = { x: values[0]!, y: values[1]! } + output.push(command, ...values.map(formatNumber)) + continue + } + if (command === 'Q') { + const control = { x: values[0]!, y: values[1]! } + const end = { x: values[2]!, y: values[3]! } + const cubic = [ + current.x + (2 / 3) * (control.x - current.x), + current.y + (2 / 3) * (control.y - current.y), + end.x + (2 / 3) * (control.x - end.x), + end.y + (2 / 3) * (control.y - end.y), + end.x, + end.y + ] + current = end + output.push('C', ...cubic.map(formatNumber)) + continue + } + current = { x: values[4]!, y: values[5]! } + output.push(command, ...values.map(formatNumber)) + } + + if (!output.length) throw new Error('Vector path data cannot be empty.') + return output.join(' ') +} + +export function canonicalVectorPaths( + paths: readonly CanvasFigmaVectorPath[] +): CanvasFigmaVectorPath[] { + return paths.map((path) => ({ ...path, data: canonicalVectorPathData(path.data) })) +} + +export function vectorPathsEqual( + current: readonly VectorPath[], + desired: readonly CanvasFigmaVectorPath[] +): boolean { + if (current.length !== desired.length) return false + try { + return current.every( + (path, index) => + path.windingRule === desired[index]!.windingRule && + canonicalVectorPathData(path.data) === canonicalVectorPathData(desired[index]!.data) + ) + } catch { + return false + } +} diff --git a/packages/extension/mcp/tools/code/assets/index.ts b/packages/extension/mcp/tools/code/assets/index.ts index cfdaca5d..fdb65815 100644 --- a/packages/extension/mcp/tools/code/assets/index.ts +++ b/packages/extension/mcp/tools/code/assets/index.ts @@ -1,4 +1,4 @@ export * from './vector' -export * from './image' +export * from './media' export * from './plan' export * from './export' diff --git a/packages/extension/mcp/tools/code/assets/image.ts b/packages/extension/mcp/tools/code/assets/media.ts similarity index 50% rename from packages/extension/mcp/tools/code/assets/image.ts rename to packages/extension/mcp/tools/code/assets/media.ts index 95d37d71..8953428c 100644 --- a/packages/extension/mcp/tools/code/assets/image.ts +++ b/packages/extension/mcp/tools/code/assets/media.ts @@ -3,6 +3,7 @@ import type { AssetDescriptor } from '@tempad-dev/shared' import type { CodegenConfig } from '@/utils/codegen' import { ensureAssetUploaded } from '@/mcp/assets' +import { detectImageMime, isVisibleMediaPaint } from '@/mcp/media' import { BG_URL_RE } from '@/utils/css' import { logger } from '@/utils/log' import { toDecimalPlace } from '@/utils/number' @@ -13,38 +14,33 @@ import { getNodeSemanticsCached } from '../cache' const imageBytesCache = new Map>() -export function hasImageFills(node: SceneNode, ctx?: GetCodeCacheContext): boolean { +export function hasMediaFills(node: SceneNode, ctx?: GetCodeCacheContext): boolean { if (ctx) { - return getNodeSemanticsCached(node, ctx).paint.hasImageFill + return getNodeSemanticsCached(node, ctx).paint.hasMediaFill } - return ( - 'fills' in node && - Array.isArray(node.fills) && - node.fills.some((f) => f.type === 'IMAGE' && f.visible !== false) - ) + return 'fills' in node && Array.isArray(node.fills) && node.fills.some(isVisibleMediaPaint) } -export async function replaceImageUrlsWithAssets( +export async function replaceMediaUrlsWithAssets( style: Record, node: SceneNode, config: CodegenConfig, assetRegistry: Map ): Promise> { if (!style['background-color'] && !style['background-image'] && !style.background) return style - - const fills = await collectImageFillAssets(node, assetRegistry) - if (!fills.length) { - return replaceImageUrlsWithPlaceholder(style, node, config) - } + const fills = await collectMediaFillAssets(node, assetRegistry) + if (!fills.length) return replaceMediaUrlsWithPlaceholder(style, node, config) const result = { ...style } const regex = new RegExp(BG_URL_RE.source, 'gi') + const lastAsset = fills.at(-1) + if (!lastAsset) return replaceMediaUrlsWithPlaceholder(style, node, config) for (const key of ['background', 'background-image']) { if (!result[key]) continue let index = 0 result[key] = result[key].replace(regex, () => { - const asset = fills[Math.min(index, fills.length - 1)] + const asset = fills[index] ?? lastAsset index++ return `url('${asset.url}')` }) @@ -53,7 +49,7 @@ export async function replaceImageUrlsWithAssets( return result } -function replaceImageUrlsWithPlaceholder( +function replaceMediaUrlsWithPlaceholder( style: Record, node: SceneNode, config: CodegenConfig @@ -82,7 +78,7 @@ function replaceImageUrlsWithPlaceholder( return result } -async function collectImageFillAssets( +async function collectMediaFillAssets( node: SceneNode, assetRegistry: Map ): Promise { @@ -90,29 +86,59 @@ async function collectImageFillAssets( const fills = Array.isArray(node.fills) ? (node.fills as Paint[]) : null if (!fills?.length) return [] + const imageHashes = collectMediaHashes(fills, (fill) => + fill.type === 'IMAGE' ? fill.imageHash : null + ) + const videoHashes = collectMediaHashes(fills, (fill) => + fill.type === 'VIDEO' ? fill.videoHash : null + ) + const hasVisibleImage = fills.some( + (fill) => isVisibleMediaPaint(fill) && fill.type === 'IMAGE' && !!fill.imageHash + ) const assets: AssetDescriptor[] = [] + let preview: Promise | undefined + const getPreview = () => + (preview ??= node + .exportAsync({ format: 'PNG' }) + .then((bytes) => ensureAssetUploaded(bytes, 'image/png'))) + for (const fill of fills) { - if (!isRenderableImagePaint(fill)) continue + if (!isVisibleMediaPaint(fill)) continue + + if (fill.type === 'VIDEO') { + if (!fill.videoHash) continue + try { + const asset = { + ...(await getPreview()), + figmaVideoHashes: videoHashes + } + registerAsset(assetRegistry, asset) + if (!hasVisibleImage) assets.push(asset) + } catch (error) { + logger.warn('Failed to export video fill preview:', error) + } + continue + } + const hash = fill.imageHash if (!hash) continue - try { const bytes = await loadImageBytes(hash) - const mimeType = detectImageMime(bytes) - const asset = await ensureAssetUploaded(bytes, mimeType) - assetRegistry.set(asset.hash, asset) + const asset = { + ...(await ensureAssetUploaded(bytes, detectImageMime(bytes) ?? 'application/octet-stream')), + figmaImageHash: hash + } + registerAsset(assetRegistry, asset) assets.push(asset) } catch (error) { - logger.warn('Failed to process image fill asset, falling back to node export.') + logger.warn(`Image bytes unavailable for hash ${hash}, falling back to node export.`, error) try { - logger.warn(`Image bytes unavailable for hash ${hash}, falling back to node export.`, error) - const bytes = await node.exportAsync({ format: 'PNG' }) - cacheImageBytes(hash, bytes) - const mimeType = detectImageMime(bytes) - const asset = await ensureAssetUploaded(bytes, mimeType) - assetRegistry.set(asset.hash, asset) + const asset = { + ...(await getPreview()), + figmaImageHashes: imageHashes + } + registerAsset(assetRegistry, asset) assets.push(asset) - continue } catch (fallbackError) { logger.warn('Failed to export node for image fill fallback:', fallbackError) } @@ -122,8 +148,21 @@ async function collectImageFillAssets( return assets } -function isRenderableImagePaint(paint: Paint): paint is ImagePaint { - return paint.type === 'IMAGE' && paint.visible !== false +function collectMediaHashes( + fills: Paint[], + getHash: (fill: ImagePaint | VideoPaint) => string | null | undefined +): string[] { + const hashes = new Set() + for (const fill of fills) { + if (!isVisibleMediaPaint(fill)) continue + const hash = getHash(fill) + if (hash) hashes.add(hash) + } + return [...hashes] +} + +function registerAsset(registry: Map, asset: AssetDescriptor): void { + registry.set(asset.hash, { ...registry.get(asset.hash), ...asset }) } function loadImageBytes(hash: string): Promise { @@ -133,61 +172,11 @@ function loadImageBytes(hash: string): Promise { if (!image) { throw new Error(`Unable to resolve image for hash ${hash}.`) } - promise = image - .getBytesAsync() - .then((bytes) => { - imageBytesCache.set(hash, Promise.resolve(bytes)) - return bytes - }) - .catch((error) => { - imageBytesCache.delete(hash) - throw error - }) + promise = image.getBytesAsync().catch((error) => { + imageBytesCache.delete(hash) + throw error + }) imageBytesCache.set(hash, promise) } return promise } - -function cacheImageBytes(hash: string, bytes: Uint8Array): void { - imageBytesCache.set(hash, Promise.resolve(bytes)) -} - -function detectImageMime(bytes: Uint8Array): string { - if ( - bytes.length >= 4 && - bytes[0] === 0x89 && - bytes[1] === 0x50 && - bytes[2] === 0x4e && - bytes[3] === 0x47 - ) { - return 'image/png' - } - if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { - return 'image/jpeg' - } - if ( - bytes.length >= 6 && - bytes[0] === 0x47 && - bytes[1] === 0x49 && - bytes[2] === 0x46 && - bytes[3] === 0x38 && - (bytes[4] === 0x37 || bytes[4] === 0x39) && - bytes[5] === 0x61 - ) { - return 'image/gif' - } - if ( - bytes.length >= 12 && - bytes[0] === 0x52 && - bytes[1] === 0x49 && - bytes[2] === 0x46 && - bytes[3] === 0x46 && - bytes[8] === 0x57 && - bytes[9] === 0x45 && - bytes[10] === 0x42 && - bytes[11] === 0x50 - ) { - return 'image/webp' - } - return 'application/octet-stream' -} diff --git a/packages/extension/mcp/tools/code/assets/paint.ts b/packages/extension/mcp/tools/code/assets/paint.ts index cdab2692..9842243f 100644 --- a/packages/extension/mcp/tools/code/assets/paint.ts +++ b/packages/extension/mcp/tools/code/assets/paint.ts @@ -55,8 +55,8 @@ export function resolveStylePaintChannel( const visible = style.paints.filter(isVisiblePaint) if (visible.length !== 1) return null - const paint = visible[0] - if (paint.type !== 'SOLID' || !paint.color) return null + const [paint] = visible + if (paint?.type !== 'SOLID') return null return resolveSolidPaintChannel(paint) } catch { diff --git a/packages/extension/mcp/tools/code/assets/plan.ts b/packages/extension/mcp/tools/code/assets/plan.ts index 91246f18..229361d1 100644 --- a/packages/extension/mcp/tools/code/assets/plan.ts +++ b/packages/extension/mcp/tools/code/assets/plan.ts @@ -75,8 +75,7 @@ function computeVectorInfo( ): Map { const info = new Map() - for (let i = tree.order.length - 1; i >= 0; i--) { - const id = tree.order[i] + for (const id of [...tree.order].reverse()) { if (ignoredIds?.has(id)) continue const node = tree.nodes.get(id) if (!node) continue diff --git a/packages/extension/mcp/tools/code/assets/svg.ts b/packages/extension/mcp/tools/code/assets/svg.ts index 2e73c3e5..09aee152 100644 --- a/packages/extension/mcp/tools/code/assets/svg.ts +++ b/packages/extension/mcp/tools/code/assets/svg.ts @@ -278,18 +278,28 @@ function parseViewBoxSize(value: string): { width: number; height: number } | nu .trim() .split(/[\s,]+/) .map((item) => Number.parseFloat(item)) - if (parts.length !== 4 || parts.some((item) => !Number.isFinite(item))) return null + const width = parts[2] + const height = parts[3] + if ( + parts.length !== 4 || + width === undefined || + height === undefined || + parts.some((item) => !Number.isFinite(item)) + ) { + return null + } return { - width: parts[2], - height: parts[3] + width, + height } } function parseLength(value?: string): number | null { if (!value) return null const match = value.trim().match(/^(-?(?:\d+\.?\d*|\.\d+))/) - if (!match) return null - const parsed = Number.parseFloat(match[1]) + const rawLength = match?.[1] + if (!rawLength) return null + const parsed = Number.parseFloat(rawLength) return Number.isFinite(parsed) ? parsed : null } diff --git a/packages/extension/mcp/tools/code/cache/node-semantics.ts b/packages/extension/mcp/tools/code/cache/node-semantics.ts index 93afbf51..710a34ec 100644 --- a/packages/extension/mcp/tools/code/cache/node-semantics.ts +++ b/packages/extension/mcp/tools/code/cache/node-semantics.ts @@ -1,3 +1,5 @@ +import { isVisibleMediaPaint } from '@/mcp/media' + import type { GetCodeCacheContext, NodeSemanticSnapshot, PaintArrayState } from './types' export function getNodeSemanticsCached( @@ -25,7 +27,7 @@ export function getNodeSemanticsCached( hasVisibleFill: hasVisiblePaints(fillsState), hasVisibleStroke: hasVisiblePaints(strokesState), hasRenderableStroke: hasRenderableStrokes(node), - hasImageFill: hasImageFill(fillsState), + hasMediaFill: hasMediaFill(fillsState), hasVisibleEffect: hasVisibleEffects(node) }, layout: { @@ -142,9 +144,9 @@ function hasVisiblePaints(state: PaintArrayState): boolean { return state.paints.some(isVisiblePaint) } -function hasImageFill(state: PaintArrayState): boolean { +function hasMediaFill(state: PaintArrayState): boolean { if (state.kind !== 'array') return false - return state.paints.some((paint) => paint.type === 'IMAGE' && paint.visible !== false) + return state.paints.some(isVisibleMediaPaint) } function hasRenderableStrokes(node: SceneNode): boolean { diff --git a/packages/extension/mcp/tools/code/cache/types.ts b/packages/extension/mcp/tools/code/cache/types.ts index 97350633..26833069 100644 --- a/packages/extension/mcp/tools/code/cache/types.ts +++ b/packages/extension/mcp/tools/code/cache/types.ts @@ -29,7 +29,7 @@ export type NodeSemanticSnapshot = { hasVisibleFill: boolean hasVisibleStroke: boolean hasRenderableStroke: boolean - hasImageFill: boolean + hasMediaFill: boolean hasVisibleEffect: boolean } layout: { diff --git a/packages/extension/mcp/tools/code/collect.ts b/packages/extension/mcp/tools/code/collect.ts index 99d0054d..ce2ef1ba 100644 --- a/packages/extension/mcp/tools/code/collect.ts +++ b/packages/extension/mcp/tools/code/collect.ts @@ -11,7 +11,7 @@ import { formatNodeStyleForMcp } from '@/utils/variable-output' import type { GetCodeCacheContext } from './cache' import type { CollectedData, NodeSnapshot, VisibleTree } from './model' -import { hasImageFills, replaceImageUrlsWithAssets } from './assets' +import { hasMediaFills, replaceMediaUrlsWithAssets } from './assets' import { getNodeSemanticsCached, getPaintsFromState } from './cache' import { getLayoutParent } from './layout-parent' import { preprocessStyles, stripInertShadows } from './styles' @@ -54,8 +54,8 @@ export async function collectNodeData( processed = applyConstraintsPosition(processed, snapshot, tree, cache) } - if (hasImageFills(node, cache)) { - processed = await replaceImageUrlsWithAssets(processed, node, config, assetRegistry) + if (hasMediaFills(node, cache)) { + processed = await replaceMediaUrlsWithAssets(processed, node, config, assetRegistry) } stripInertShadows(processed, node, cache) diff --git a/packages/extension/mcp/tools/code/index.ts b/packages/extension/mcp/tools/code/index.ts index d9d79c29..40907378 100644 --- a/packages/extension/mcp/tools/code/index.ts +++ b/packages/extension/mcp/tools/code/index.ts @@ -137,11 +137,11 @@ export async function handleGetCode( const { now, stamp } = trace const traceInfo: TraceInfo = { now, stamp } - if (nodes.length !== 1) { + const [node] = nodes + if (nodes.length !== 1 || !node) { throw new Error('Select exactly one node or provide a single root node id.') } - const node = nodes[0] if (!node.visible) { throw new Error('The selected node is not visible.') } @@ -286,12 +286,13 @@ export async function handleGetCode( const warnings = buildGetCodeWarnings(output.code, { cappedNodeIds: tree.stats.cappedNodeIds }) - const result = buildCodeResult(output, codegen, allAssets, warnings) + const assets = filterAssetsReferencedInCode(allAssets, output.code) + const result = buildCodeResult(output, codegen, assets, warnings) assertToolResponseWithinBudget(buildGetCodeToolResult(result), codeBudget) logTrace( trace, - `nodes=${tree.order.length} text=${collected.textSegments.size} vectors=${plan.vectorRoots.size} assets=${allAssets.length}${runtimeOptions.unbounded ? ' budget=unbounded' : ''}${formatCacheMetrics(cache)}` + `nodes=${tree.order.length} text=${collected.textSegments.size} vectors=${plan.vectorRoots.size} assets=${assets.length}${runtimeOptions.unbounded ? ' budget=unbounded' : ''}${formatCacheMetrics(cache)}` ) return result diff --git a/packages/extension/mcp/tools/code/render/index.ts b/packages/extension/mcp/tools/code/render/index.ts index 0a151307..ba1a3846 100644 --- a/packages/extension/mcp/tools/code/render/index.ts +++ b/packages/extension/mcp/tools/code/render/index.ts @@ -87,7 +87,8 @@ async function renderNode( const mergedProps = Object.keys(props).length ? props : undefined return raw(svgEntry.raw, mergedProps as Record | undefined) } - if (classNames.length) svgProps[classAttr] = props[classAttr] + const className = props[classAttr] + if (classNames.length && className) svgProps[classAttr] = className Object.entries(props).forEach(([key, val]) => { if (key === classAttr) return svgProps[key] = val diff --git a/packages/extension/mcp/tools/code/sanitize/stacking.ts b/packages/extension/mcp/tools/code/sanitize/stacking.ts index 8872ca88..e0411327 100644 --- a/packages/extension/mcp/tools/code/sanitize/stacking.ts +++ b/packages/extension/mcp/tools/code/sanitize/stacking.ts @@ -14,8 +14,7 @@ function visit(nodeId: string, tree: VisibleTree, styles: StyleMap): void { if (children.length) { const needsIsolation = new Set() - for (let i = 0; i < children.length; i += 1) { - const childId = children[i] + for (const [i, childId] of children.entries()) { const childStyle = styles.get(childId) if (!isAbsolute(childStyle)) continue diff --git a/packages/extension/mcp/tools/code/styles/background.ts b/packages/extension/mcp/tools/code/styles/background.ts index 6af680fd..35588d25 100644 --- a/packages/extension/mcp/tools/code/styles/background.ts +++ b/packages/extension/mcp/tools/code/styles/background.ts @@ -266,6 +266,7 @@ function parseGradient(value: string): { fn: string; args: string[] } | null { if (!match || match.index == null) return null const fn = match[1] + if (!fn) return null const start = value.indexOf('(', match.index) if (start < 0) return null diff --git a/packages/extension/mcp/tools/code/styles/normalize.ts b/packages/extension/mcp/tools/code/styles/normalize.ts index d9082c6b..f04aef53 100644 --- a/packages/extension/mcp/tools/code/styles/normalize.ts +++ b/packages/extension/mcp/tools/code/styles/normalize.ts @@ -269,7 +269,7 @@ function parseBorderShorthand(normalized: string): { width?: string } { const matched = normalized.match(/^\s*(\S+)\s+(\S+)\s+(.+)\s*$/) if (matched) { const [, width] = matched - return { width: width.trim() } + if (width) return { width: width.trim() } } const parts = normalized.split(/\s+/).filter(Boolean) @@ -278,7 +278,7 @@ function parseBorderShorthand(normalized: string): { width?: string } { function parseBoxValues(value: string): [string, string, string, string] { const parts = value.trim().split(/\s+/) - const [t, r = t, b = t, l = r] = parts + const [t = '', r = t, b = t, l = r] = parts return [t, r, b, l] } @@ -292,11 +292,9 @@ function getBorderWidth(style: StyleMap): string | null { return parsed.width ? normalizeStyleValue(parsed.width) : null }) - if (sideWidths.every((width): width is string => typeof width === 'string' && width.length > 0)) { - const [first, ...rest] = sideWidths - if (rest.every((width) => width === first)) { - return first - } + const [first] = sideWidths + if (first && sideWidths.every((width) => width === first)) { + return first } const borderWidth = style['border-width'] @@ -336,6 +334,7 @@ function negateLengthLiteral(value: string): string | null { if (!matched) return null const [, amount, unit] = matched + if (!amount || !unit) return null if (amount.startsWith('-')) { return `${amount.slice(1)}${unit}` } diff --git a/packages/extension/mcp/tools/code/styles/overflow.ts b/packages/extension/mcp/tools/code/styles/overflow.ts index a875ec71..ecc5ebbc 100644 --- a/packages/extension/mcp/tools/code/styles/overflow.ts +++ b/packages/extension/mcp/tools/code/styles/overflow.ts @@ -1,19 +1,11 @@ +import { isVectorLikeNode } from '@/mcp/semantic-tree' import { toDecimalPlace } from '@/utils/number' import type { LayoutBounds, OverflowDirection, StyleMap } from './types' -const VECTOR_LIKE_TYPES = new Set([ - 'VECTOR', - 'BOOLEAN_OPERATION', - 'STAR', - 'LINE', - 'ELLIPSE', - 'POLYGON' -]) - export function applyOverflowStyles(style: StyleMap, node?: SceneNode): StyleMap { if (!node || !('overflowDirection' in node)) return style - if (VECTOR_LIKE_TYPES.has(node.type)) return style + if (isVectorLikeNode(node)) return style const dir = getOverflowDirection(node) const next = style diff --git a/packages/extension/mcp/tools/code/text/render.ts b/packages/extension/mcp/tools/code/text/render.ts index f53dc9ed..18866ebc 100644 --- a/packages/extension/mcp/tools/code/text/render.ts +++ b/packages/extension/mcp/tools/code/text/render.ts @@ -55,9 +55,9 @@ export async function renderTextSegments( const resolved = ctx.resolveStyleVars ? ctx.resolveStyleVars(cleaned, node) : cleaned const hoistableCandidate: Record = {} - for (const key in resolved) { + for (const [key, value] of Object.entries(resolved)) { if (HOIST_ALLOWLIST.has(key)) { - hoistableCandidate[key] = resolved[key] + hoistableCandidate[key] = value } } @@ -140,18 +140,23 @@ function renderBlock( const rootList: DevComponent = { name: rootTag, props: { [classProp]: rootCls }, children: [] } - const stack: ListStackItem[] = [{ list: rootList, level: lines[0]?.attrs.indentation || 1 }] + const rootStackItem: ListStackItem = { + list: rootList, + level: lines[0]?.attrs.indentation || 1 + } + const stack: ListStackItem[] = [rootStackItem] for (const line of lines) { const currentIndent = line.attrs.indentation - while (stack.length > 0 && currentIndent < stack[stack.length - 1].level) { + while (stack.length > 1) { + const activeItem = stack.at(-1) + if (!activeItem || currentIndent >= activeItem.level) break stack.pop() } - if (stack.length > 0 && currentIndent > stack[stack.length - 1].level) { - const parentStackItem = stack[stack.length - 1] - + const parentStackItem = stack.at(-1) ?? rootStackItem + if (currentIndent > parentStackItem.level) { if (!parentStackItem.lastLi) { const dummyLi: DevComponent = { name: 'li', props: {}, children: [] } parentStackItem.list.children.push(dummyLi) @@ -182,7 +187,7 @@ function renderBlock( const li: DevComponent = { name: 'li', props: {}, children: lineChildren } - const activeItem = stack[stack.length - 1] + const activeItem = stack.at(-1) ?? rootStackItem activeItem.list.children.push(li) activeItem.lastLi = li } @@ -229,8 +234,8 @@ function optimizeComponentTree(node: DevComponent | string, classProp: string) { ]) if (UNWRAP_WHITELIST.has(node.name) && node.children && node.children.length === 1) { - const child = node.children[0] - if (typeof child !== 'string' && child.name === 'span') { + const [child] = node.children + if (child && typeof child !== 'string' && child.name === 'span') { const childProps = child.props || {} const extraChildProps = Object.keys(childProps).filter((key) => key !== classProp) if (extraChildProps.length === 0) { @@ -266,8 +271,10 @@ function buildInlineTree( let k = 0 while (k < sortedMarks.length && k < stack.length - 1) { - const { markType, linkHref } = stack[k + 1] + const stackNode = stack[k + 1] const currentMark = sortedMarks[k] + if (!stackNode || !currentMark) break + const { markType, linkHref } = stackNode if (markType === currentMark && (currentMark !== 'link' || linkHref === run.link)) { k++ @@ -283,9 +290,11 @@ function buildInlineTree( while (stack.length - 1 < sortedMarks.length) { const mark = sortedMarks[stack.length - 1] + const parent = stack.at(-1) + if (!mark || !parent) break const component = createMarkComponent(mark, run) - stack[stack.length - 1].container.children.push(component) + parent.container.children.push(component) stack.push({ container: component, @@ -301,7 +310,7 @@ function buildInlineTree( const style = omitCommon(resolvedAttrs, commonStyle) const classNames = styleToClassNames(style, ctx.config) const cls = joinClassNames(classNames) - const top = stack[stack.length - 1].container + const top = stack.at(-1)?.container ?? root if (cls) { top.children.push({ diff --git a/packages/extension/mcp/tools/code/text/segments.ts b/packages/extension/mcp/tools/code/text/segments.ts index 689cb5bd..0cc0768c 100644 --- a/packages/extension/mcp/tools/code/text/segments.ts +++ b/packages/extension/mcp/tools/code/text/segments.ts @@ -40,9 +40,7 @@ function splitIntoLines(node: TextNode, segments: StyledTextSegmentSubset[]): Te const text = seg.characters const parts = text.split(NEWLINE_RE) - for (let i = 0; i < parts.length; i++) { - const partText = parts[i] - + for (const [i, partText] of parts.entries()) { if (partText.length > 0) { const run = createRun(node, seg, partText) currentRuns.push(run) @@ -73,8 +71,6 @@ function groupLinesIntoBlocks(lines: TextLine[]): TextBlock[] { const blocks: TextBlock[] = [] if (!lines.length) return blocks - let currentBlock: TextBlock | null = null - for (const line of lines) { const { listType } = line.attrs const isList = listType !== 'NONE' @@ -84,17 +80,15 @@ function groupLinesIntoBlocks(lines: TextLine[]): TextBlock[] { : 'unordered-list' : 'paragraph' - const canMerge = currentBlock && currentBlock.type === blockType - - if (canMerge) { - currentBlock!.lines.push(line) + const currentBlock = blocks.at(-1) + if (currentBlock?.type === blockType) { + currentBlock.lines.push(line) } else { - currentBlock = { + blocks.push({ type: blockType, lines: [line], attrs: line.attrs - } - blocks.push(currentBlock) + }) } } @@ -124,7 +118,11 @@ function optimizeRuns(runs: TextRun[]): TextRun[] { continue } - const prev = result[result.length - 1] + const prev = result.at(-1) + if (!prev) { + result.push(run) + continue + } const isWhitespace = /^[\s\u200B-\u200D\uFEFF]*$/.test(run.text) if (isWhitespace) { @@ -163,19 +161,20 @@ function optimizeRuns(runs: TextRun[]): TextRun[] { continue } - const prevKeys = Object.keys(prev.attrs) + const prevEntries = Object.entries(prev.attrs) const runKeys = Object.keys(run.attrs) - if (prevKeys.length !== runKeys.length) { + if (prevEntries.length !== runKeys.length) { result.push(run) continue } let attrsMatch = true - for (const key of prevKeys) { + for (const [key, prevValue] of prevEntries) { + const runValue = run.attrs[key] if ( - !(key in run.attrs) || - canonicalizeValue(key, prev.attrs[key]) !== canonicalizeValue(key, run.attrs[key]) + runValue === undefined || + canonicalizeValue(key, prevValue) !== canonicalizeValue(key, runValue) ) { attrsMatch = false break @@ -237,10 +236,9 @@ function createRun(node: TextNode, seg: StyledTextSegmentSubset, text: string): function applyStickySpace(runs: TextRun[]): TextRun[] { for (let i = 1; i < runs.length - 1; i++) { const curr = runs[i] - if (!curr.text.trim()) { - const prev = runs[i - 1] - const next = runs[i + 1] - + const prev = runs[i - 1] + const next = runs[i + 1] + if (curr && prev && next && !curr.text.trim()) { const commonMarks = new Set([...prev.marks].filter((m) => next.marks.has(m))) for (const m of commonMarks) { diff --git a/packages/extension/mcp/tools/code/text/style.ts b/packages/extension/mcp/tools/code/text/style.ts index 9123d3c4..2461c075 100644 --- a/packages/extension/mcp/tools/code/text/style.ts +++ b/packages/extension/mcp/tools/code/text/style.ts @@ -30,7 +30,8 @@ export function resolveRunAttrs( if (visibleSolid) { const val = formatHexAlpha(visibleSolid.raw.color, visibleSolid.raw.opacity ?? 1) - style.color = constructCssVar(visibleSolid.token, val) + const colorValue = constructCssVar(visibleSolid.token, val) + if (colorValue) style.color = colorValue } else if (fills.length === 0 || !hasVisiblePaint) { style.color = 'transparent' } @@ -48,7 +49,8 @@ export function resolveRunAttrs( if (fontWeight) { const wVal = inferFontWeight(seg.fontName?.style, seg.fontWeight) - style['font-weight'] = constructCssVar(fontWeight, wVal != null ? String(wVal) : undefined) + const weightValue = constructCssVar(fontWeight, wVal != null ? String(wVal) : undefined) + if (weightValue) style['font-weight'] = weightValue } else if (typeof seg.fontWeight === 'number') { style['font-weight'] = String(seg.fontWeight) } @@ -115,12 +117,13 @@ export function computeDominantStyle(runStyles: RunStyleEntry[]): Record = {} const threshold = totalWeight * 0.5 - for (const key in counts) { - const bucket = counts[key] + for (const [key, bucket] of Object.entries(counts)) { let bestValue: { raw: string; score: number } | undefined - for (const norm in bucket) { - const entry = bucket[norm] + for (const entry of Object.values(bucket)) { if (!bestValue || entry.score > bestValue.score) { bestValue = entry } @@ -155,7 +156,11 @@ export function omitCommon( const result: Record = {} for (const [key, value] of Object.entries(style)) { - if (!common[key] || canonicalizeValue(key, value) !== canonicalizeValue(key, common[key])) { + const commonValue = common[key] + if ( + commonValue === undefined || + canonicalizeValue(key, value) !== canonicalizeValue(key, commonValue) + ) { result[key] = value } } @@ -202,8 +207,6 @@ function mapTextCase(textCase?: TextCase): string | undefined { return map[textCase as string] } -function constructCssVar(token: TokenRef, fallback?: string): string -function constructCssVar(token: TokenRef | null | undefined, fallback: string): string function constructCssVar(token?: TokenRef | null, fallback?: string): string | undefined { if (token) return toFigmaVarExpr(token.name) return fallback?.trim() || undefined diff --git a/packages/extension/mcp/tools/code/tokens/extract.ts b/packages/extension/mcp/tools/code/tokens/extract.ts index d2e40439..596865a5 100644 --- a/packages/extension/mcp/tools/code/tokens/extract.ts +++ b/packages/extension/mcp/tools/code/tokens/extract.ts @@ -33,7 +33,8 @@ export function extractTokenNames(code: string, plainNames?: Set): Set getVariableByIdCached(id, cache)) .filter(Boolean) as Variable[] - const rawNames = variables.map((v) => getVariableRawName(v)) - const canonicalNames = await canonicalizeNames(rawNames, config, pluginCode) + const variablesWithRawNames = variables.map((variable) => ({ + variable, + rawName: getVariableRawName(variable) + })) + const canonicalNames = await canonicalizeNames( + variablesWithRawNames.map(({ rawName }) => rawName), + config, + pluginCode + ) const nameSet = new Set() const candidateNameById = new Map() - for (let i = 0; i < variables.length; i += 1) { - const canonical = canonicalNames[i] ?? normalizeFigmaVarName(rawNames[i]) + for (const [i, { variable, rawName }] of variablesWithRawNames.entries()) { + const canonical = canonicalNames[i] ?? normalizeFigmaVarName(rawName) nameSet.add(canonical) - candidateNameById.set(variables[i].id, canonical) + candidateNameById.set(variable.id, canonical) } const tokensByCanonical = await resolveTokenDefsByNames(nameSet, config, pluginCode, { diff --git a/packages/extension/mcp/tools/code/tree.ts b/packages/extension/mcp/tools/code/tree.ts index 524deb3f..a4d3d48b 100644 --- a/packages/extension/mcp/tools/code/tree.ts +++ b/packages/extension/mcp/tools/code/tree.ts @@ -1,19 +1,10 @@ -import { suggestDepthLimit } from '@/mcp/semantic-tree' +import { classifySemanticAsset, resolveSemanticTag, suggestDepthLimit } from '@/mcp/semantic-tree' import { logger } from '@/utils/log' import { toDecimalPlace } from '@/utils/number' import { toPascalCase } from '@/utils/string' import type { AutoLayoutHint, DataHint, NodeSnapshot, TreeStats, VisibleTree } from './model' -const VECTOR_LIKE_TYPES = new Set([ - 'VECTOR', - 'BOOLEAN_OPERATION', - 'STAR', - 'LINE', - 'ELLIPSE', - 'POLYGON' -]) - type ComponentPropertyValueLike = | { type: 'BOOLEAN'; value: boolean } | { type: 'TEXT'; value: string } @@ -106,7 +97,7 @@ export function buildVisibleTree(roots: SceneNode[]): VisibleTree { const snapshot: NodeSnapshot = { id: node.id, type: node.type, - tag: resolveTag(node), + tag: resolveSemanticTag(node), name: node.name ?? '', visible: node.visible, parentId, @@ -118,7 +109,7 @@ export function buildVisibleTree(roots: SceneNode[]): VisibleTree { height: toDecimalPlace(node.height) }, renderBounds: getRenderBounds(node), - assetKind: classifyAsset(node), + assetKind: classifySemanticAsset(node), node } @@ -167,30 +158,6 @@ export function buildVisibleTree(roots: SceneNode[]): VisibleTree { return { rootIds, nodes, order, stats } } -function resolveTag(node: SceneNode): string { - if (node.type === 'TEXT') { - return node.characters.includes('\n') ? 'p' : 'span' - } - if (VECTOR_LIKE_TYPES.has(node.type)) return 'svg' - if (node.type === 'RECTANGLE' && Array.isArray(node.fills)) { - const hasImageFill = node.fills.some((fill) => fill.type === 'IMAGE' && fill.visible !== false) - if (hasImageFill) return 'img' - } - return 'div' -} - -function classifyAsset(node: SceneNode): 'vector' | 'image' | undefined { - if (VECTOR_LIKE_TYPES.has(node.type)) return 'vector' - if (node.type === 'RECTANGLE' && Array.isArray(node.fills)) { - const hasImageFill = node.fills.some((fill) => fill.type === 'IMAGE' && fill.visible !== false) - if (hasImageFill) return 'image' - } - if (node.type === 'ELLIPSE' || node.type === 'POLYGON' || node.type === 'STAR') { - return 'vector' - } - return undefined -} - function getRenderBounds( node: SceneNode ): { x: number; y: number; width: number; height: number } | null { diff --git a/packages/extension/mcp/tools/design-system-catalog.ts b/packages/extension/mcp/tools/design-system-catalog.ts new file mode 100644 index 00000000..f1980e4e --- /dev/null +++ b/packages/extension/mcp/tools/design-system-catalog.ts @@ -0,0 +1,145 @@ +import type { + CanvasDesignReference, + CanvasStyleReference, + CanvasVariableReference, + CanvasVariableValue +} from '@tempad-dev/shared' + +export type CatalogComponentProperty = { + name: string + type: 'boolean' | 'instance' | 'text' | 'variant' + default?: string | boolean + options?: string[] + omittedOptions?: number +} + +export type CatalogComponent = { + kind: 'component' + ref: string + tag: string + name: string + reference: CanvasDesignReference + nativeReferences?: CanvasDesignReference[] + nativeSize: { width: number; height: number } + pageName: string + variantCount: number + properties: Record + definition: unknown +} + +type CatalogVariable = { + kind: 'variable' + ref: string + name: string + reference: CanvasVariableReference + resolvedType: 'BOOLEAN' | 'COLOR' | 'FLOAT' | 'STRING' + defaultValue?: CanvasVariableValue + definition: unknown +} + +export type CatalogCollection = { + kind: 'collection' + ref: string + name: string + reference: CanvasDesignReference + modes: Array<{ ref: string; id: string; name: string }> + defaultModeId: string + definition: unknown +} + +type CatalogMode = { + kind: 'mode' + ref: string + name: string + id: string + collectionRef: string + definition: unknown +} + +type CatalogStyle = { + kind: 'style' + ref: string + name: string + reference: CanvasStyleReference + styleType: 'EFFECT' | 'GRID' | 'PAINT' | 'TEXT' + definition: unknown +} + +type CatalogShader = { + kind: 'shader' + ref: string + name: string + id: string + shaderType: 'effect' | 'fill' + definition: unknown +} + +export type CatalogEntry = + | CatalogCollection + | CatalogComponent + | CatalogMode + | CatalogShader + | CatalogStyle + | CatalogVariable + +export type DesignSystemCatalog = { + componentReferences: Map + id: string + fileKey?: string + entries: Map + orderedRefs: string[] + tags: Map + warnings: string[] +} + +const catalogs = new Map() +const MAX_CATALOGS = 8 + +export function registerDesignSystemCatalog( + entries: CatalogEntry[], + fileKey?: string, + orderedRefs = entries.filter((entry) => entry.kind !== 'mode').map((entry) => entry.ref), + warnings: string[] = [] +): DesignSystemCatalog { + const id = `ds_${crypto.randomUUID()}` + const catalog = { + componentReferences: new Map( + entries + .filter((entry): entry is CatalogComponent => entry.kind === 'component') + .flatMap((entry) => [entry.reference, ...(entry.nativeReferences ?? [])]) + .flatMap((reference) => + [reference.id, reference.key] + .filter((value): value is string => value !== undefined) + .map((value) => [value, reference] as const) + ) + ), + id, + ...(fileKey ? { fileKey } : {}), + entries: new Map(entries.map((entry) => [entry.ref, entry])), + orderedRefs, + tags: new Map( + entries + .filter((entry): entry is CatalogComponent => entry.kind === 'component') + .map((entry) => [entry.tag, entry]) + ), + warnings: [...warnings] + } + catalogs.set(id, catalog) + while (catalogs.size > MAX_CATALOGS) { + catalogs.delete(catalogs.keys().next().value!) + } + return catalog +} + +export function requireDesignSystemCatalog( + id: string, + fileKey?: string | null +): DesignSystemCatalog { + const catalog = catalogs.get(id) + if (!catalog || (catalog.fileKey && catalog.fileKey !== fileKey)) { + throw new Error(`Unknown or expired design-system catalog: ${id}`) + } + catalogs.delete(id) + catalogs.set(id, catalog) + return catalog +} diff --git a/packages/extension/mcp/tools/design-system.ts b/packages/extension/mcp/tools/design-system.ts index 33db8246..226f0af7 100644 --- a/packages/extension/mcp/tools/design-system.ts +++ b/packages/extension/mcp/tools/design-system.ts @@ -1,60 +1,83 @@ import type { - DesignSystemComponent, - DesignSystemComponentProperty, - DesignSystemVariable, + CanvasFigmaEffect, + CanvasFigmaLayoutGrid, + CanvasFigmaPaint, + CanvasFigmaShaderPropertyValue, + CanvasVariableValue, + DesignSystemCatalogCollection, + DesignSystemCatalogComponent, + DesignSystemCatalogShader, + DesignSystemCatalogStyle, + DesignSystemCatalogVariable, GetDesignSystemParametersInput, GetDesignSystemResult } from '@tempad-dev/shared' -const MAX_COMPONENTS = 40 -const MAX_VARIABLES = 60 +import { + MCP_TOOL_INLINE_BUDGET_BYTES, + buildGetDesignSystemToolResult, + measureCallToolResultBytes, + utf8Bytes +} from '@tempad-dev/shared' -function normalizeSearchText(value: string): string { - return value - .toLowerCase() - .replaceAll(/[^\p{L}\p{N}]+/gu, ' ') - .trim() +import { getLocalStyles } from '../local-styles' +import { collectVariableAliasIds } from '../variable-references' +import { + CANVAS_KEY_NAMESPACE, + CANVAS_STYLE_KEY_NAME, + CANVAS_VARIABLE_COLLECTION_KEY_NAME, + CANVAS_VARIABLE_KEY_NAME, + CANVAS_VARIABLE_MODE_KEYS_NAME, + parseVariableModeKeys, + readAuthoringKey +} from './canvas/identity' +import { + registerDesignSystemCatalog, + requireDesignSystemCatalog, + type CatalogCollection, + type CatalogComponent, + type CatalogComponentProperty, + type CatalogEntry +} from './design-system-catalog' + +const TARGET_BYTES = 16 * 1024 +const MAX_SUMMARY_LENGTH = 240 +const MAX_DETAIL_TEXT_LENGTH = 2_000 +const MAX_CATALOG_PROPERTIES = 32 +const MAX_CATALOG_OPTIONS = 32 +const MAX_DETAIL_OPTIONS = 128 +const MAX_COMPONENT_VARIANTS = 128 +const MAX_ANATOMY_NODES = 64 +const MAX_ANATOMY_VISITS = 512 + +function boundedText(value: string | undefined, maxLength = MAX_DETAIL_TEXT_LENGTH) { + const text = value?.replaceAll(/\s+/g, ' ').trim() + if (!text || text.length <= maxLength) return text + return `${text.slice(0, maxLength - 1).trimEnd()}…` } -function queryTerms(query?: string): string[] { - return query ? normalizeSearchText(query).split(/\s+/).filter(Boolean) : [] +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 } -function scoreCandidate(name: string, searchText: string, terms: string[]): number { - if (!terms.length) return 1 - const normalizedName = normalizeSearchText(name) - let score = 0 - for (const term of terms) { - if (normalizedName === term) { - score += 20 - } else if (normalizedName.startsWith(term)) { - score += 10 - } else if (normalizedName.includes(term)) { - score += 6 - } else if (searchText.includes(term)) { - score += 2 - } - } - return score +function sortByName(items: T[]): T[] { + return items.toSorted((left, right) => compareText(left.name, right.name)) } -function rankAndLimit( - items: T[], - terms: string[], - getSearchText: (item: T) => string, - limit: number -): T[] { - return items - .map((item) => ({ - item, - score: scoreCandidate(item.name, normalizeSearchText(getSearchText(item)), terms) - })) - .filter((entry) => entry.score > 0) - .sort( - (left, right) => right.score - left.score || left.item.name.localeCompare(right.item.name) - ) - .slice(0, limit) - .map((entry) => entry.item) +function modeAuthoringKeys( + collection: VariableCollection, + warnings: string[] +): Map { + if (typeof collection.getSharedPluginData !== 'function') return new Map() + const raw = collection.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_VARIABLE_MODE_KEYS_NAME) + const keys = parseVariableModeKeys(raw, collection.modes) + if (!keys) { + if (!warnings.includes('Some variable authoring identities could not be read.')) { + warnings.push('Some variable authoring identities could not be read.') + } + return new Map() + } + return new Map([...keys].map(([key, id]) => [id, key])) } async function readOrNull(read: () => Promise): Promise { @@ -65,90 +88,181 @@ async function readOrNull(read: () => Promise): Promise { } } -function componentProperties( - definitions: ComponentPropertyDefinitions -): Record | undefined { +function describeVariableAlias(alias: VariableAlias): { id: string } { + return { id: alias.id } +} + +function describeVariableValue(value: VariableValue): CanvasVariableValue { + return typeof value === 'object' && 'type' in value + ? { variable: describeVariableAlias(value) } + : value +} + +function describeVariableValues( + values: Record +): Record { + return Object.fromEntries( + Object.entries(values).map(([modeId, value]) => [modeId, describeVariableValue(value)]) + ) +} + +function componentProperties(definitions: ComponentPropertyDefinitions) { const entries = Object.entries(definitions).map(([name, definition]) => { - const options = + const allOptions = definition.type === 'VARIANT' ? definition.variantOptions : definition.preferredValues?.map((value) => value.key) + const options = allOptions?.slice(0, MAX_DETAIL_OPTIONS) + const defaultVariableId = definition.boundVariables?.defaultValue?.id + const description = boundedText(definition.description) return [ name, { type: definition.type, defaultValue: definition.defaultValue, - ...(options?.length ? { options } : {}) + ...(options?.length ? { options } : {}), + ...(allOptions && allOptions.length > MAX_DETAIL_OPTIONS + ? { omittedOptions: allOptions.length - MAX_DETAIL_OPTIONS } + : {}), + ...(definition.preferredValues?.length + ? { preferredValues: definition.preferredValues.slice(0, MAX_DETAIL_OPTIONS) } + : {}), + ...(description ? { description } : {}), + ...(definition.slotSettings ? { slotSettings: definition.slotSettings } : {}), + ...(defaultVariableId ? { defaultVariableId } : {}) } ] as const }) return entries.length ? Object.fromEntries(entries) : undefined } -function describeComponent(component: ComponentNode): DesignSystemComponent { +function documentationUris( + resource: Pick +): string[] | undefined { + const uris = resource.documentationLinks.map(({ uri }) => uri) + return uris.length ? uris : undefined +} + +function describeComponent(component: ComponentNode, page: Pick) { const componentSet = component.parent?.type === 'COMPONENT_SET' ? component.parent : null const definitions = componentSet?.componentPropertyDefinitions ?? component.componentPropertyDefinitions const properties = componentProperties(definitions) - const description = component.description.trim() + const description = boundedText(component.description) + const descriptionMarkdown = boundedText(component.descriptionMarkdown) + const documentationLinks = documentationUris(component) + const componentSetDescription = boundedText(componentSet?.description) + const componentSetDescriptionMarkdown = boundedText(componentSet?.descriptionMarkdown) + const componentSetDocumentationLinks = componentSet ? documentationUris(componentSet) : undefined + const variantValues = component.variantProperties return { id: component.id, key: component.key, name: component.name, + pageId: page.id, + pageName: page.name, ...(description ? { description } : {}), - ...(componentSet ? { componentSetName: componentSet.name } : {}), + ...(descriptionMarkdown ? { descriptionMarkdown } : {}), + ...(documentationLinks ? { documentationLinks } : {}), + width: component.width, + height: component.height, + ...(componentSet + ? { + componentSetId: componentSet.id, + componentSetKey: componentSet.key, + componentSetName: componentSet.name, + ...(componentSetDescription ? { componentSetDescription } : {}), + ...(componentSetDescriptionMarkdown ? { componentSetDescriptionMarkdown } : {}), + ...(componentSetDocumentationLinks ? { componentSetDocumentationLinks } : {}), + ...(componentSet.defaultVariant.id === component.id ? { isDefaultVariant: true } : {}), + ...(variantValues ? { variantValues } : {}) + } + : {}), ...(properties ? { properties } : {}), remote: component.remote } } -async function collectComponents(warnings: string[]): Promise { - const localComponents = figma.currentPage.findAllWithCriteria({ - types: ['COMPONENT'] - }) - const byId = new Map(localComponents.map((component) => [component.id, component])) - - const instances = figma.currentPage.findAllWithCriteria({ - types: ['INSTANCE'] - }) - const mainComponents = await Promise.all( - instances.map((instance) => readOrNull(() => instance.getMainComponentAsync())) - ) - for (const component of mainComponents) { - if (component) byId.set(component.id, component) +function collectComponents(warnings: string[]) { + const components: ReturnType[] = [] + let unreadablePages = 0 + let pages: readonly PageNode[] + try { + pages = figma.root.children + } catch { + pages = [figma.currentPage] } - - if (!byId.size) { - warnings.push('No components were found on the current page.') + for (const page of pages) { + try { + components.push( + ...page + .findAllWithCriteria({ types: ['COMPONENT'] }) + .map((component) => describeComponent(component, page)) + ) + } catch { + unreadablePages += 1 + } + } + if (unreadablePages) { + const loadedPages = pages.length - unreadablePages + warnings.push( + `Component definitions were read from ${loadedPages} accessible ${loadedPages === 1 ? 'page' : 'pages'}; ${unreadablePages} ${unreadablePages === 1 ? 'page was' : 'pages were'} skipped rather than loaded.` + ) } - return [...byId.values()].map(describeComponent) + return components } -async function collectVariables(warnings: string[]): Promise { +async function collectVariables(referencedDefinitionIds: Set, warnings: string[]) { try { const [localVariables, localCollections] = await Promise.all([ figma.variables.getLocalVariablesAsync(), figma.variables.getLocalVariableCollectionsAsync() ]) const variablesById = new Map(localVariables.map((variable) => [variable.id, variable])) - const boundVariableIds = new Set() - for (const node of figma.currentPage.findAll()) { - if ('boundVariables' in node) { - collectVariableAliasIds(node.boundVariables, boundVariableIds) + const referencedVariableIds = new Set([ + ...referencedDefinitionIds, + ...localCollections.flatMap((collection) => collection.variableIds) + ]) + for (const variable of localVariables) { + collectVariableAliasIds(variable.valuesByMode, referencedVariableIds) + } + for (const collection of localCollections) { + if (collection.isExtension) { + collectVariableAliasIds( + (collection as unknown as ExtendedVariableCollection).variableOverrides, + referencedVariableIds + ) } } - const remoteVariables = await Promise.all( - [...boundVariableIds] - .filter((id) => !variablesById.has(id)) - .map((id) => readOrNull(() => figma.variables.getVariableByIdAsync(id))) + + const attemptedVariableIds = new Set(variablesById.keys()) + let pendingVariableIds = [...referencedVariableIds].filter( + (id) => !attemptedVariableIds.has(id) ) - for (const variable of remoteVariables) { - if (variable) variablesById.set(variable.id, variable) + let unreadableVariable = false + while (pendingVariableIds.length) { + pendingVariableIds.forEach((id) => attemptedVariableIds.add(id)) + const remoteVariables = await Promise.all( + pendingVariableIds.map((id) => readOrNull(() => figma.variables.getVariableByIdAsync(id))) + ) + const aliasIds = new Set() + for (const variable of remoteVariables) { + if (!variable) { + unreadableVariable = true + continue + } + variablesById.set(variable.id, variable) + collectVariableAliasIds(variable.valuesByMode, aliasIds) + } + pendingVariableIds = [...aliasIds].filter((id) => !attemptedVariableIds.has(id)) + } + if (unreadableVariable) { + warnings.push('Some referenced variables could not be read.') } const variables = [...variablesById.values()] const collectionsById = new Map( - localCollections.map((collection) => [collection.id, collection.name]) + localCollections.map((collection) => [collection.id, collection]) ) const remoteCollectionIds = [ ...new Set( @@ -163,89 +277,985 @@ async function collectVariables(warnings: string[]): Promise { + const description = variable.description.trim() + const scopes = variable.scopes?.map(String) + const variableAuthoringKey = readAuthoringKey(variable, CANVAS_VARIABLE_KEY_NAME) + const valuesByMode = describeVariableValues(variable.valuesByMode) + return { + id: variable.id, + key: variable.key, + ...(variableAuthoringKey ? { authoringKey: variableAuthoringKey } : {}), + name: variable.name, + collectionId: variable.variableCollectionId, + collectionName: + collectionsById.get(variable.variableCollectionId)?.name ?? 'Unknown collection', + ...(description ? { description } : {}), + remote: variable.remote, + resolvedType: variable.resolvedType, + ...(scopes?.length ? { scopes } : {}), + ...(Object.keys(valuesByMode).length ? { valuesByMode } : {}) + } + }), + collections: [...collectionsById.values()].map((collection) => { + const collectionAuthoringKey = readAuthoringKey( + collection, + CANVAS_VARIABLE_COLLECTION_KEY_NAME + ) + const modeKeys = modeAuthoringKeys(collection, warnings) + const extended = collection.isExtension + ? (collection as unknown as ExtendedVariableCollection) + : undefined + const variableOverrides = extended + ? Object.fromEntries( + Object.entries(extended.variableOverrides).map(([variableId, values]) => [ + variableId, + describeVariableValues(values) + ]) + ) + : {} + return { + id: collection.id, + ...(collection.key ? { key: collection.key } : {}), + ...(collectionAuthoringKey ? { authoringKey: collectionAuthoringKey } : {}), + name: collection.name, + remote: collection.remote, + ...(extended + ? { + isExtension: true as const, + parentVariableCollectionId: extended.parentVariableCollectionId, + rootVariableCollectionId: extended.rootVariableCollectionId + } + : {}), + modes: collection.modes.map((mode) => ({ + id: mode.modeId, + ...(modeKeys.get(mode.modeId) ? { authoringKey: modeKeys.get(mode.modeId) } : {}), + name: mode.name, + ...(extended + ? { + parentModeId: extended.modes.find( + (candidate) => candidate.modeId === mode.modeId + )!.parentModeId + } + : {}) + })), + defaultModeId: collection.defaultModeId, + ...(Object.keys(variableOverrides).length ? { variableOverrides } : {}) + } + }) } - return variables.map((variable) => { - const description = variable.description.trim() - const scopes = variable.scopes?.map(String) + } catch { + warnings.push('Variables could not be read in the current Figma context.') + return { variables: [], collections: [] } + } +} + +function describeBindings( + bindings: Partial> | undefined +): Partial> | undefined { + const entries = Object.entries(bindings ?? {}).map(([field, alias]) => [ + field, + describeVariableAlias(alias as VariableAlias) + ]) + return entries.length + ? (Object.fromEntries(entries) as Partial>) + : undefined +} + +function describeTransform( + transform: Transform +): [[number, number, number], [number, number, number]] { + return [ + [transform[0][0], transform[0][1], transform[0][2]], + [transform[1][0], transform[1][1], transform[1][2]] + ] +} + +function describeShaderProperties( + properties: Record | undefined +): Record | undefined { + const entries = Object.entries(properties ?? {}).map(([id, value]) => [ + id, + describeShaderValue(value) + ]) + return entries.length ? Object.fromEntries(entries) : undefined +} + +function describePaint(paint: Paint): CanvasFigmaPaint { + switch (paint.type) { + case 'SOLID': { + const { boundVariables, ...fields } = paint + const variable = boundVariables?.color return { - id: variable.id, - key: variable.key, - name: variable.name, - collectionName: collectionsById.get(variable.variableCollectionId) ?? 'Unknown collection', - ...(description ? { description } : {}), - remote: variable.remote, - resolvedType: variable.resolvedType, - ...(scopes?.length ? { scopes } : {}) + ...fields, + ...(variable ? { variables: { color: describeVariableAlias(variable) } } : {}) } - }) + } + case 'GRADIENT_LINEAR': + case 'GRADIENT_RADIAL': + case 'GRADIENT_ANGULAR': + case 'GRADIENT_DIAMOND': + return { + ...paint, + gradientTransform: describeTransform(paint.gradientTransform), + gradientStops: paint.gradientStops.map(({ boundVariables, ...stop }) => { + const variable = boundVariables?.color + return { + ...stop, + ...(variable ? { variables: { color: describeVariableAlias(variable) } } : {}) + } + }) + } + case 'IMAGE': + return { + ...paint, + ...(paint.imageTransform ? { imageTransform: describeTransform(paint.imageTransform) } : {}) + } + case 'VIDEO': + return { + ...paint, + ...(paint.videoTransform ? { videoTransform: describeTransform(paint.videoTransform) } : {}) + } + case 'PATTERN': + return { ...paint } + case 'SHADER': { + const { properties: nativeProperties, ...fields } = paint + const properties = describeShaderProperties(nativeProperties) + return { + ...fields, + ...(properties ? { properties } : {}) + } + } + } +} + +function describeEffect(effect: Effect): CanvasFigmaEffect { + switch (effect.type) { + case 'DROP_SHADOW': + case 'INNER_SHADOW': { + const { boundVariables, ...fields } = effect + const variables = describeBindings(boundVariables) + return { ...fields, ...(variables ? { variables } : {}) } + } + case 'LAYER_BLUR': + case 'BACKGROUND_BLUR': { + const { boundVariables, ...fields } = effect + const variable = boundVariables?.radius + return { + ...fields, + ...(variable ? { variables: { radius: describeVariableAlias(variable) } } : {}) + } + } + case 'NOISE': + case 'TEXTURE': + case 'GLASS': { + const { boundVariables: _boundVariables, ...fields } = effect + return fields + } + case 'SHADER': { + const { properties: nativeProperties, ...fields } = effect + const properties = describeShaderProperties(nativeProperties) + return { + ...fields, + ...(properties ? { properties } : {}) + } + } + } +} + +function describeLayoutGrid(grid: LayoutGrid): CanvasFigmaLayoutGrid { + if (grid.pattern === 'GRID') { + const { boundVariables, ...fields } = grid + const variable = boundVariables?.sectionSize + return { + ...fields, + ...(variable ? { variables: { sectionSize: describeVariableAlias(variable) } } : {}) + } + } + const { boundVariables, ...fields } = grid + const variables = describeBindings(boundVariables) + return { + ...fields, + count: grid.count === Infinity ? 'AUTO' : grid.count, + ...(variables ? { variables } : {}) + } +} + +function describeStyle(style: BaseStyle) { + const description = style.description.trim() + const descriptionMarkdown = style.descriptionMarkdown.trim() + const documentationLinks = documentationUris(style) + const styleAuthoringKey = readAuthoringKey(style, CANVAS_STYLE_KEY_NAME) + const metadata = { + id: style.id, + key: style.key, + ...(styleAuthoringKey ? { authoringKey: styleAuthoringKey } : {}), + name: style.name, + ...(description ? { description } : {}), + ...(descriptionMarkdown ? { descriptionMarkdown } : {}), + ...(documentationLinks ? { documentationLinks } : {}), + remote: style.remote + } + switch (style.type) { + case 'PAINT': + return { ...metadata, type: style.type, paints: style.paints.map(describePaint) } + case 'TEXT': { + const variables = describeBindings(style.boundVariables) + return { + ...metadata, + type: style.type, + fontName: style.fontName, + fontSize: style.fontSize, + textDecoration: style.textDecoration, + letterSpacing: style.letterSpacing, + lineHeight: style.lineHeight, + leadingTrim: style.leadingTrim, + paragraphIndent: style.paragraphIndent, + paragraphSpacing: style.paragraphSpacing, + listSpacing: style.listSpacing, + hangingPunctuation: style.hangingPunctuation, + hangingList: style.hangingList, + textCase: style.textCase, + ...(variables ? { variables } : {}) + } + } + case 'EFFECT': + return { ...metadata, type: style.type, effects: style.effects.map(describeEffect) } + case 'GRID': + return { + ...metadata, + type: style.type, + layoutGrids: style.layoutGrids.map(describeLayoutGrid) + } + } +} + +async function collectStyles(warnings: string[]): Promise { + try { + return await getLocalStyles() } catch { - warnings.push('Variables could not be read in the current Figma context.') + warnings.push('Styles could not be read in the current Figma context.') return [] } } -function collectVariableAliasIds(value: unknown, ids: Set): void { - if (Array.isArray(value)) { - value.forEach((item) => collectVariableAliasIds(item, ids)) - return +function describeShaderColor( + value: RGB | RGBA | VariableAlias +): RGB | RGBA | { variable: { id: string } } { + if ('type' in value) return { variable: describeVariableAlias(value) } + return value +} + +function describeShaderValue(value: ShaderPropertyValue): CanvasFigmaShaderPropertyValue { + if (typeof value !== 'object' || value === null) return value + if ('type' in value && value.type === 'VARIABLE_ALIAS') { + return { variable: describeVariableAlias(value) } + } + if ('color' in value) { + return { + ...value, + color: describeShaderColor(value.color) + } } - if (!value || typeof value !== 'object') return - const record = value as Record - if (record.type === 'VARIABLE_ALIAS' && typeof record.id === 'string') { - ids.add(record.id) - return + if ('stops' in value) { + return { + stops: value.stops.map((stop) => ({ + position: stop.position, + color: describeShaderColor(stop.color) + })) + } } - Object.values(record).forEach((item) => collectVariableAliasIds(item, ids)) + return value as CanvasFigmaShaderPropertyValue } -export async function handleGetDesignSystem( - args?: GetDesignSystemParametersInput -): Promise { +function describeShader(shader: Shader) { + const propertyEntries = Object.entries(shader.propertyDefinitions ?? {}).map( + ([id, definition]) => { + const description = definition.description?.trim() + return [ + id, + { + name: definition.name, + type: definition.type, + ...(description ? { description } : {}), + ...(definition.defaultValue === undefined + ? {} + : { defaultValue: describeShaderValue(definition.defaultValue) }) + } + ] as const + } + ) + return { + id: shader.id, + name: shader.name, + type: shader.type, + imported: shader.imported, + ...(propertyEntries.length ? { propertyDefinitions: Object.fromEntries(propertyEntries) } : {}) + } +} + +async function collectShaders(warnings: string[]): Promise { + try { + const shaders = await figma.listAvailableShaders() + return shaders + } catch { + warnings.push('Shaders could not be read in the current Figma context.') + return [] + } +} + +function collectStyleVariableIds(styles: BaseStyle[], ids: Set): void { + for (const style of styles) { + collectVariableAliasIds(style.boundVariables, ids) + switch (style.type) { + case 'PAINT': + collectVariableAliasIds(style.paints, ids) + break + case 'EFFECT': + collectVariableAliasIds(style.effects, ids) + break + case 'GRID': + collectVariableAliasIds(style.layoutGrids, ids) + break + } + } +} + +type DescribedComponent = Awaited>[number] +type DescribedVariable = Awaited>['variables'][number] +type DescribedStyle = ReturnType + +function toIdentifier(value: string, fallback: string, upper: boolean): string { + const words = value.match(/[A-Za-z][A-Za-z0-9]*/g) ?? [] + const identifier = words + .map((word, index) => { + const normalized = word[0]!.toUpperCase() + word.slice(1) + return upper || index > 0 ? normalized : normalized[0]!.toLowerCase() + normalized.slice(1) + }) + .join('') + return identifier || fallback +} + +function uniqueName(base: string, used: Set): string { + let value = base + let suffix = 2 + while (used.has(value)) value = `${base}${suffix++}` + used.add(value) + return value +} + +function groupComponents(components: DescribedComponent[]): Array<{ + item: DescribedComponent + name: string + variantCount: number + variants: DescribedComponent[] +}> { + const groups = new Map() + for (const component of components) { + const key = component.componentSetId ?? component.id + const group = groups.get(key) ?? [] + group.push(component) + groups.set(key, group) + } + return [...groups.values()].map((variants) => { + const item = variants.reduce((current, candidate) => + !current.isDefaultVariant && + (candidate.isDefaultVariant || compareText(candidate.name, current.name) < 0) + ? candidate + : current + ) + return { + item, + name: item.componentSetName ?? item.name, + variantCount: variants.length, + variants + } + }) +} + +function catalogComponentProperties( + component: DescribedComponent +): Record { + const properties: Record = {} + const used = new Set() + let index = 1 + for (const [nativeName, definition] of Object.entries(component.properties ?? {})) { + if (definition.type === 'SLOT') continue + const name = uniqueName( + toIdentifier(nativeName.split('#')[0]!, `property${index++}`, false), + used + ) + const type = { + BOOLEAN: 'boolean', + INSTANCE_SWAP: 'instance', + TEXT: 'text', + VARIANT: 'variant' + }[definition.type] as CatalogComponentProperty['type'] + properties[name] = { + name: nativeName, + type, + default: definition.defaultValue, + ...(definition.options?.length ? { options: definition.options } : {}), + ...(definition.omittedOptions ? { omittedOptions: definition.omittedOptions } : {}) + } + } + return properties +} + +function compactColor(value: RGB | RGBA): string { + const channel = (number: number): string => + Math.round(Math.max(0, Math.min(1, number)) * 255) + .toString(16) + .padStart(2, '0') + const alpha = 'a' in value ? channel(value.a) : '' + return `#${channel(value.r)}${channel(value.g)}${channel(value.b)}${alpha}`.toUpperCase() +} + +function styleSignature(style: DescribedStyle): string { + switch (style.type) { + case 'PAINT': + return style.paints.map((paint) => paint.type.toLowerCase()).join(' + ') || 'empty' + case 'TEXT': + return `${style.fontName.family} ${style.fontName.style}, ${style.fontSize}px` + case 'EFFECT': + return style.effects.map((effect) => effect.type.toLowerCase()).join(' + ') || 'empty' + case 'GRID': + return style.layoutGrids.map((grid) => grid.pattern.toLowerCase()).join(' + ') || 'empty' + } +} + +function compactEntry( + entry: CatalogEntry +): + | DesignSystemCatalogCollection + | DesignSystemCatalogComponent + | DesignSystemCatalogShader + | DesignSystemCatalogStyle + | DesignSystemCatalogVariable + | undefined { + switch (entry.kind) { + case 'component': { + const definition = entry.definition as DescribedComponent + const summary = boundedText( + definition.componentSetDescription ?? + definition.componentSetDescriptionMarkdown ?? + definition.description ?? + definition.descriptionMarkdown, + MAX_SUMMARY_LENGTH + ) + const propertyEntries = Object.entries(entry.properties) + return { + ref: entry.ref, + tag: entry.tag, + name: entry.name, + ...(summary ? { summary } : {}), + page: entry.pageName, + ...(entry.variantCount > 1 ? { variantCount: entry.variantCount } : {}), + nativeSize: entry.nativeSize, + props: Object.fromEntries( + propertyEntries.slice(0, MAX_CATALOG_PROPERTIES).map(([name, property]) => { + const label = property.name.split('#')[0]!.trim() + const needsLabel = toIdentifier(label, '', false) !== name + const defaultValue = + typeof property.default === 'string' + ? boundedText(property.default, 120) + : property.default + const omittedOptions = + (property.omittedOptions ?? 0) + + Math.max(0, (property.options?.length ?? 0) - MAX_CATALOG_OPTIONS) + return [ + name, + { + type: property.type, + ...(needsLabel ? { label } : {}), + ...(defaultValue === undefined ? {} : { default: defaultValue }), + ...(property.options?.length + ? { options: property.options.slice(0, MAX_CATALOG_OPTIONS) } + : {}), + ...(omittedOptions ? { omittedOptions } : {}) + } + ] + }) + ), + ...(propertyEntries.length > MAX_CATALOG_PROPERTIES + ? { omittedProps: propertyEntries.length - MAX_CATALOG_PROPERTIES } + : {}) + } + } + case 'variable': { + const definition = entry.definition as DescribedVariable + let defaultValue: string | number | boolean | undefined + if (entry.defaultValue === undefined || typeof entry.defaultValue !== 'object') { + defaultValue = entry.defaultValue + } else if (!('variable' in entry.defaultValue)) { + defaultValue = compactColor(entry.defaultValue) + } + return { + ref: entry.ref, + name: entry.name, + collection: + 'collectionName' in definition ? definition.collectionName : 'Unknown collection', + type: { + BOOLEAN: 'boolean', + COLOR: 'color', + FLOAT: 'number', + STRING: 'string' + }[entry.resolvedType] as DesignSystemCatalogVariable['type'], + ...('scopes' in definition && definition.scopes?.length + ? { scopes: definition.scopes } + : {}), + ...(defaultValue === undefined ? {} : { defaultValue }) + } + } + case 'collection': + return { + ref: entry.ref, + name: entry.name, + modes: entry.modes.map(({ ref, name }) => ({ ref, name })), + defaultModeRef: + entry.modes.find((mode) => mode.id === entry.defaultModeId)?.ref ?? + entry.modes[0]?.ref ?? + '' + } + case 'style': { + const definition = entry.definition as DescribedStyle + const summary = boundedText( + definition.description || definition.descriptionMarkdown, + MAX_SUMMARY_LENGTH + ) + return { + ref: entry.ref, + name: entry.name, + type: entry.styleType.toLowerCase() as DesignSystemCatalogStyle['type'], + signature: styleSignature(definition), + ...(summary ? { summary } : {}) + } + } + case 'shader': + return { + ref: entry.ref, + name: entry.name, + type: entry.shaderType + } + case 'mode': + return undefined + } +} + +function containingPage(node: BaseNode): PageNode | null { + let current: BaseNode | null = node + while (current && current.type !== 'PAGE') current = current.parent + return current?.type === 'PAGE' ? current : null +} + +async function resolveCatalogComponent(entry: CatalogComponent): Promise { + const node = entry.reference.id + ? await readOrNull(() => figma.getNodeByIdAsync(entry.reference.id!)) + : null + if (node?.type === 'COMPONENT') return node + if (node?.type === 'COMPONENT_SET') return node.defaultVariant + throw new Error(`Component definition "${entry.ref}" is no longer available.`) +} + +function describeComponentLayout(component: ComponentNode) { + if (component.layoutMode === 'NONE') return undefined + return { + mode: component.layoutMode, + wrap: component.layoutWrap, + primaryAxisAlignItems: component.primaryAxisAlignItems, + counterAxisAlignItems: component.counterAxisAlignItems, + primaryAxisSizingMode: component.primaryAxisSizingMode, + counterAxisSizingMode: component.counterAxisSizingMode, + itemSpacing: component.itemSpacing, + counterAxisSpacing: component.counterAxisSpacing, + paddingTop: component.paddingTop, + paddingRight: component.paddingRight, + paddingBottom: component.paddingBottom, + paddingLeft: component.paddingLeft + } +} + +function anatomyPath(parent: string, node: SceneNode): string { + const name = boundedText(node.name, 80) || node.type + return parent ? `${parent} / ${name}` : name +} + +async function describeComponentAnatomy(component: ComponentNode) { + const stack = component.children + .toReversed() + .map((node) => ({ node, path: anatomyPath('', node) })) + const candidates: Array<{ node: InstanceNode | SlotNode | TextNode; path: string }> = [] + let visited = 0 + let omitted = 0 + + while (stack.length && visited < MAX_ANATOMY_VISITS) { + const { node, path } = stack.pop()! + visited += 1 + if (node.type === 'TEXT' || node.type === 'INSTANCE' || node.type === 'SLOT') { + if (candidates.length < MAX_ANATOMY_NODES) candidates.push({ node, path }) + else omitted += 1 + } + // A nested instance is already a semantic unit; its private subtree belongs to its own component. + if ('children' in node && node.type !== 'INSTANCE') { + for (const child of node.children.toReversed()) { + stack.push({ node: child, path: anatomyPath(path, child) }) + } + } + } + + const nodes = await Promise.all( + candidates.map(async ({ node, path }): Promise> => { + const propertyReferences = node.componentPropertyReferences ?? undefined + if (node.type === 'TEXT') { + return { + type: 'text', + path, + text: boundedText(node.characters, 160), + ...(propertyReferences ? { propertyReferences } : {}) + } + } + if (node.type === 'SLOT') { + return { + type: 'slot', + path, + ...(propertyReferences ? { propertyReferences } : {}) + } + } + const mainComponent = await readOrNull(() => node.getMainComponentAsync()) + return { + type: 'instance', + path, + ...(mainComponent + ? { + component: { + name: + mainComponent.parent?.type === 'COMPONENT_SET' + ? mainComponent.parent.name + : mainComponent.name, + key: mainComponent.key + } + } + : {}), + ...(node.isExposedInstance ? { exposed: true } : {}), + ...(propertyReferences ? { propertyReferences } : {}) + } + }) + ) + + return { + nodes, + ...(omitted ? { omitted } : {}), + ...(stack.length ? { truncated: true } : {}) + } +} + +async function describeComponentDetail(entry: CatalogComponent) { + const component = await resolveCatalogComponent(entry) + const page = containingPage(component) ?? { + id: (entry.definition as DescribedComponent).pageId, + name: entry.pageName + } + const definition = describeComponent(component, page) + const componentSet = component.parent?.type === 'COMPONENT_SET' ? component.parent : null + const allVariants = componentSet + ? componentSet.children + .filter((node): node is ComponentNode => node.type === 'COMPONENT') + .toSorted((left, right) => { + if (left.id === componentSet.defaultVariant.id) return -1 + if (right.id === componentSet.defaultVariant.id) return 1 + return compareText(left.name, right.name) + }) + : [component] + const variants = allVariants.slice(0, MAX_COMPONENT_VARIANTS).map((variant) => ({ + id: variant.id, + key: variant.key, + name: variant.name, + width: variant.width, + height: variant.height, + ...(variant.variantProperties ? { properties: variant.variantProperties } : {}), + ...(variant.id === componentSet?.defaultVariant.id ? { default: true } : {}) + })) + const anatomy = await describeComponentAnatomy(component) + const layout = describeComponentLayout(component) + return { + ...definition, + ...(layout ? { layout } : {}), + variantCount: allVariants.length, + variants, + ...(allVariants.length > variants.length + ? { omittedVariants: allVariants.length - variants.length } + : {}), + anatomy, + previewNodeId: component.id + } +} + +async function exactCatalogResult(catalogId: string, ref: string): Promise { + const catalog = requireDesignSystemCatalog(catalogId, figma.fileKey) + const entry = catalog.entries.get(ref) + if (!entry) throw new Error(`Unknown design-system ref ${ref} in catalog ${catalogId}`) + const definition = + entry.kind === 'component' ? await describeComponentDetail(entry) : entry.definition + const result: GetDesignSystemResult = { + catalogId, + components: [], + variables: [], + collections: [], + styles: [], + details: { + ref: entry.ref, + kind: entry.kind, + definition + } + } + const bytes = measureCallToolResultBytes(buildGetDesignSystemToolResult(result)) + if (bytes > MCP_TOOL_INLINE_BUDGET_BYTES) { + throw new Error(`Design-system definition "${ref}" exceeds the 64 KiB inline result budget.`) + } + return result +} + +type CatalogDisplayKind = Exclude + +function orderEntries(entries: CatalogEntry[]): CatalogEntry[] { + const interleave = (kinds: ReadonlyArray): CatalogEntry[] => { + const groups = kinds.map((kind) => entries.filter((entry) => entry.kind === kind)) + const ordered: CatalogEntry[] = [] + for (let index = 0; ; index += 1) { + let added = false + for (const group of groups) { + const entry = group[index] + if (!entry) continue + ordered.push(entry) + added = true + } + if (!added) return ordered + } + } + return [ + ...interleave(['component', 'variable', 'style']), + ...interleave(['collection', 'shader']) + ] +} + +function buildCompactResult( + catalogId: string, + entries: CatalogEntry[], + warnings: string[], + cursor = 0 +): GetDesignSystemResult { + const selected: CatalogEntry[] = [] + const build = (): GetDesignSystemResult => { + const result: GetDesignSystemResult = { + catalogId, + components: [], + variables: [], + collections: [], + styles: [] + } + const shaders: DesignSystemCatalogShader[] = [] + for (const entry of selected) { + const compact = compactEntry(entry) + if (!compact) continue + if (entry.kind === 'component') { + result.components.push(compact as DesignSystemCatalogComponent) + } else if (entry.kind === 'variable') { + result.variables.push(compact as DesignSystemCatalogVariable) + } else if (entry.kind === 'collection') { + result.collections.push(compact as DesignSystemCatalogCollection) + } else if (entry.kind === 'style') { + result.styles.push(compact as DesignSystemCatalogStyle) + } else if (entry.kind === 'shader') { + shaders.push(compact as DesignSystemCatalogShader) + } + } + if (shaders.length) result.shaders = shaders + const nextCursor = cursor + selected.length + const remaining = entries.slice(nextCursor) + const counts: Record = Object.fromEntries( + ( + [ + ['components', 'component'], + ['variables', 'variable'], + ['collections', 'collection'], + ['styles', 'style'], + ['shaders', 'shader'] + ] as const + ) + .map(([label, kind]) => [label, remaining.filter((entry) => entry.kind === kind).length]) + .filter(([, count]) => count) + ) + if (remaining.length) result.nextCursor = nextCursor + if (Object.keys(counts).length) result.omitted = counts + if (warnings.length) result.warnings = warnings + return result + } + + for (const candidate of entries.slice(cursor)) { + selected.push(candidate) + if (utf8Bytes(build()) <= TARGET_BYTES) continue + if (selected.length > 1) selected.pop() + break + } + return build() +} + +function continueCatalog(catalogId: string, cursor: number): GetDesignSystemResult { + const catalog = requireDesignSystemCatalog(catalogId, figma.fileKey) + if (cursor >= catalog.orderedRefs.length) { + throw new Error(`Unknown design-system cursor ${cursor} in catalog ${catalogId}`) + } + const entries = catalog.orderedRefs.map((ref) => catalog.entries.get(ref)!) + return buildCompactResult(catalogId, entries, catalog.warnings, cursor) +} + +async function createCatalog(): Promise { const componentWarnings: string[] = [] const variableWarnings: string[] = [] - const terms = queryTerms(args?.query) - const [components, variables] = await Promise.all([ + const styleWarnings: string[] = [] + const shaderWarnings: string[] = [] + const [components, styles, availableShaders] = await Promise.all([ collectComponents(componentWarnings), - collectVariables(variableWarnings) + collectStyles(styleWarnings), + collectShaders(shaderWarnings) ]) - const warnings = [...componentWarnings, ...variableWarnings] - - const rankedComponents = rankAndLimit( - components, - terms, - (component) => - [ - component.name, - component.componentSetName, - component.description, - ...Object.keys(component.properties ?? {}) - ] - .filter(Boolean) - .join(' '), - MAX_COMPONENTS - ) - const rankedVariables = rankAndLimit( - variables, - terms, - (variable) => - [variable.name, variable.collectionName, variable.description, ...(variable.scopes ?? [])] - .filter(Boolean) - .join(' '), - MAX_VARIABLES + const referencedVariableIds = new Set() + for (const component of components) { + for (const property of Object.values(component.properties ?? {})) { + if (property.defaultVariableId) referencedVariableIds.add(property.defaultVariableId) + } + } + collectStyleVariableIds(styles, referencedVariableIds) + for (const shader of availableShaders) { + collectVariableAliasIds(shader.propertyDefinitions, referencedVariableIds) + } + const variableData = await collectVariables(referencedVariableIds, variableWarnings) + const variables = variableData.variables + const shaders = availableShaders.map(describeShader) + const warnings = [...componentWarnings, ...variableWarnings, ...styleWarnings, ...shaderWarnings] + const orderedComponents = sortByName(groupComponents(components)) + const orderedVariables = sortByName(variables) + const orderedCollections = sortByName(variableData.collections) + const orderedStyles = sortByName(styles.map(describeStyle)) + const orderedShaders = sortByName(shaders) + const entries: CatalogEntry[] = [] + const componentTags = new Set() + for (const [index, family] of orderedComponents.entries()) { + const component = family.item + const tag = uniqueName( + toIdentifier(component.componentSetName ?? component.name, `Component${index + 1}`, true), + componentTags + ) + entries.push({ + kind: 'component', + ref: `c${index + 1}`, + tag, + name: component.componentSetName ?? component.name, + reference: { id: component.id, key: component.key }, + nativeReferences: family.variants.map((variant) => ({ + id: variant.id, + key: variant.key + })), + nativeSize: { width: component.width, height: component.height }, + pageName: component.pageName, + variantCount: family.variantCount, + properties: catalogComponentProperties(component), + definition: component + }) + } + + for (const [index, collection] of orderedCollections.entries()) { + const ref = `k${index + 1}` + const modes = collection.modes.map((mode, modeIndex) => ({ + ref: `m${index + 1}_${modeIndex + 1}`, + id: mode.id, + name: mode.name + })) + const entry: CatalogCollection = { + kind: 'collection', + ref, + name: collection.name, + reference: { id: collection.id, ...(collection.key ? { key: collection.key } : {}) }, + modes, + defaultModeId: collection.defaultModeId, + definition: collection + } + entries.push( + entry, + ...modes.map( + (mode): CatalogEntry => ({ + kind: 'mode', + ref: mode.ref, + name: mode.name, + id: mode.id, + collectionRef: ref, + definition: { id: mode.id, name: mode.name } + }) + ) + ) + } + + for (const [index, variable] of orderedVariables.entries()) { + const collection = variableData.collections.find((item) => item.id === variable.collectionId) + const modeId = collection?.defaultModeId + entries.push({ + kind: 'variable', + ref: `v${index + 1}`, + name: variable.name, + reference: { id: variable.id, key: variable.key }, + resolvedType: variable.resolvedType, + ...(modeId ? { defaultValue: variable.valuesByMode?.[modeId] } : {}), + definition: variable + }) + } + + for (const [index, style] of orderedStyles.entries()) { + entries.push({ + kind: 'style', + ref: `s${index + 1}`, + name: style.name, + reference: { id: style.id, key: style.key }, + styleType: style.type, + definition: style + }) + } + for (const [index, shader] of orderedShaders.entries()) { + entries.push({ + kind: 'shader', + ref: `h${index + 1}`, + name: shader.name, + id: shader.id, + shaderType: shader.type, + definition: shader + }) + } + + const orderedEntries = orderEntries(entries.filter((entry) => entry.kind !== 'mode')) + const catalog = registerDesignSystemCatalog( + entries, + figma.fileKey ?? undefined, + orderedEntries.map((entry) => entry.ref), + warnings ) + return buildCompactResult(catalog.id, orderedEntries, warnings) +} - return { - page: { - id: figma.currentPage.id, - name: figma.currentPage.name - }, - components: rankedComponents, - variables: rankedVariables, - ...(warnings.length ? { warnings } : {}) +let pendingCatalog: Promise | undefined + +export async function handleGetDesignSystem( + args: GetDesignSystemParametersInput = {} +): Promise { + if (args.catalogId) { + return args.ref + ? exactCatalogResult(args.catalogId, args.ref) + : continueCatalog(args.catalogId, args.cursor!) } + pendingCatalog ??= createCatalog().finally(() => { + pendingCatalog = undefined + }) + return pendingCatalog } diff --git a/packages/extension/mcp/tools/screenshot.ts b/packages/extension/mcp/tools/screenshot.ts index 75f7887a..518e3ff3 100644 --- a/packages/extension/mcp/tools/screenshot.ts +++ b/packages/extension/mcp/tools/screenshot.ts @@ -1,28 +1,20 @@ import type { GetScreenshotResult } from '@tempad-dev/shared' -import { MCP_MAX_PAYLOAD_BYTES } from '@tempad-dev/shared' +import { MCP_MAX_ASSET_BYTES } from '@tempad-dev/shared' import { ensureAssetUploaded } from '@/mcp/assets' -// Limit raw PNG bytes so the base64 data URL stays under the transport cap. -const DATA_URL_PREFIX_LENGTH = 'data:image/png;base64,'.length -const MAX_BASE64_BYTES = Math.max(0, MCP_MAX_PAYLOAD_BYTES - DATA_URL_PREFIX_LENGTH) -const SCREENSHOT_MAX_BYTES = Math.floor((MAX_BASE64_BYTES * 3) / 4) const SCALE_STEPS = [1, 0.75, 0.5, 0.25] -async function exportAtScale(node: SceneNode, scale: number): Promise { - return node.exportAsync({ - format: 'PNG', - constraint: { type: 'SCALE', value: scale } - }) -} - export async function handleGetScreenshot(node: SceneNode): Promise { for (const scale of SCALE_STEPS) { - const bytes = await exportAtScale(node, scale) + const bytes = await node.exportAsync({ + format: 'PNG', + constraint: { type: 'SCALE', value: scale } + }) const { byteLength } = bytes - if (byteLength <= SCREENSHOT_MAX_BYTES) { + if (byteLength <= MCP_MAX_ASSET_BYTES) { const width = Math.round(node.width * scale) const height = Math.round(node.height * scale) const asset = await ensureAssetUploaded(bytes, 'image/png', { width, height }) @@ -39,6 +31,6 @@ export async function handleGetScreenshot(node: SceneNode): Promise +): StructureNode[] { if (!roots.length) return roots - const initial = compactByNodeLimit(roots, STRUCTURE_NODE_LIMIT_STEPS[0]) + const initial = compactByNodeLimit(roots, STRUCTURE_NODE_LIMIT_STEPS[0], authoringKeys) if (estimateToolResultBytes(initial) <= MCP_TOOL_INLINE_BUDGET_BYTES) { return initial } for (const nodeLimit of STRUCTURE_NODE_LIMIT_STEPS.slice(1)) { - const candidate = compactByNodeLimit(roots, nodeLimit) + const candidate = compactByNodeLimit(roots, nodeLimit, authoringKeys) if (estimateToolResultBytes(candidate) <= MCP_TOOL_INLINE_BUDGET_BYTES) { return candidate } @@ -47,12 +51,17 @@ function compactStructure(roots: StructureNode[]): StructureNode[] { return [] } -function compactByNodeLimit(roots: StructureNode[], nodeLimit: number): StructureNode[] { +function compactByNodeLimit( + roots: StructureNode[], + nodeLimit: number, + authoringKeys: ReadonlyMap +): StructureNode[] { let seen = 0 const visit = (node: StructureNode): StructureNode | undefined => { if (seen >= nodeLimit) return undefined seen += 1 + const authoringKey = authoringKeys.get(node.id) const compact: StructureNode = { id: sanitizeId(node.id, `node-${seen}`), @@ -61,7 +70,8 @@ function compactByNodeLimit(roots: StructureNode[], nodeLimit: number): Structur x: sanitizeNumber(node.x), y: sanitizeNumber(node.y), width: sanitizeNumber(node.width), - height: sanitizeNumber(node.height) + height: sanitizeNumber(node.height), + ...(authoringKey ? { authoringKey } : {}) } if (Array.isArray(node.children) && node.children.length && seen < nodeLimit) { @@ -86,6 +96,44 @@ function compactByNodeLimit(roots: StructureNode[], nodeLimit: number): Structur return compactRoots } +function collectAuthoringKeys( + roots: SceneNode[], + outline: StructureNode[], + nodeLimit: number +): Map { + const keys = new Map() + const remaining = new Set() + + const addIds = (nodes: StructureNode[]): boolean => { + for (const node of nodes) { + remaining.add(node.id) + if (remaining.size >= nodeLimit || (node.children && addIds(node.children))) return true + } + return false + } + addIds(outline) + if (!remaining.size) return keys + + const visit = (node: SceneNode): boolean => { + if (remaining.delete(node.id)) { + const key = readAuthoringKey(node, CANVAS_NODE_KEY_NAME) + if (key) keys.set(node.id, key) + if (!remaining.size) return true + } + if ('children' in node) { + for (const child of node.children) { + if (child.visible && visit(child)) return true + } + } + return false + } + + for (const root of roots) { + if (visit(root)) break + } + return keys +} + function sanitizeName(value: unknown): string { if (typeof value !== 'string') return '' const normalized = value.replace(/\s+/g, ' ').trim() diff --git a/packages/extension/mcp/tools/token/defs.ts b/packages/extension/mcp/tools/token/defs.ts index 89a6facc..c7808b03 100644 --- a/packages/extension/mcp/tools/token/defs.ts +++ b/packages/extension/mcp/tools/token/defs.ts @@ -23,7 +23,7 @@ type TokenModeValue = { aliasChain?: string[] } -type VariableAlias = { id?: string } | { type?: string; id?: string } +type VariableAlias = { id: string; type?: string } type VariableWithCollection = Variable & { variableCollectionId?: string; resolvedType?: string } type VariableCollectionInfo = { id?: string @@ -135,8 +135,7 @@ async function resolveTokens({ pluginCode ) - for (let i = 0; i < candidateVariables.length; i++) { - const v = candidateVariables[i] + for (const [i, v] of candidateVariables.entries()) { const canonical = canonicals[i] if (canonical && remaining.has(canonical)) { seeds.push(v) @@ -257,9 +256,11 @@ async function buildTokensFromVariables({ const primaryModeKey = primaryModeId ? modeKeyForCollection(collection, primaryModeId) : undefined + const fallbackModeId = modeIds[0] const resolvedValue = - (primaryModeKey && valueMap[primaryModeKey]) || - (modeIds.length ? valueMap[modeKeyForCollection(collection, modeIds[0])] : '') + (primaryModeKey ? valueMap[primaryModeKey] : undefined) || + (fallbackModeId ? valueMap[modeKeyForCollection(collection, fallbackModeId)] : '') || + '' const value: string | Record = modeIds.length <= 1 ? resolvedValue : valueMap diff --git a/packages/extension/mcp/tools/token/indexer.ts b/packages/extension/mcp/tools/token/indexer.ts index f9c9156e..8ce8215e 100644 --- a/packages/extension/mcp/tools/token/indexer.ts +++ b/packages/extension/mcp/tools/token/indexer.ts @@ -87,9 +87,8 @@ export async function canonicalizeNames( results.push(...transformed) } - return results.map((expr, idx) => { - const fallback = refs[idx] - return parseCanonicalFromExpr(expr ?? fallback.code, fallback.name) + return refs.map((fallback, idx) => { + return parseCanonicalFromExpr(results[idx] ?? fallback.code, fallback.name) }) } @@ -123,8 +122,7 @@ export async function getTokenIndex( pluginCode ) - for (let i = 0; i < variables.length; i++) { - const variable = variables[i] + for (const [i, variable] of variables.entries()) { const fallbackRaw = getVariableRawName(variable) const canonical = canonicals[i] ?? normalizeFigmaVarName(fallbackRaw) diff --git a/packages/extension/mcp/tools/token/mapping.ts b/packages/extension/mcp/tools/token/mapping.ts index 2742a649..febf9f18 100644 --- a/packages/extension/mcp/tools/token/mapping.ts +++ b/packages/extension/mcp/tools/token/mapping.ts @@ -140,7 +140,7 @@ function replaceKnownNames(value: string, entries: ReplaceEntry[], used: Set { const i = Number(index) - return Number.isFinite(i) ? placeholders[i] : _match + return Number.isFinite(i) ? (placeholders[i] ?? _match) : _match }) } diff --git a/packages/extension/mcp/variable-references.ts b/packages/extension/mcp/variable-references.ts new file mode 100644 index 00000000..c073ca31 --- /dev/null +++ b/packages/extension/mcp/variable-references.ts @@ -0,0 +1,13 @@ +export function collectVariableAliasIds(value: unknown, ids: Set): void { + if (Array.isArray(value)) { + value.forEach((item) => collectVariableAliasIds(item, ids)) + return + } + if (!value || typeof value !== 'object') return + const record = value as Record + if (record.type === 'VARIABLE_ALIAS' && typeof record.id === 'string') { + ids.add(record.id) + return + } + Object.values(record).forEach((item) => collectVariableAliasIds(item, ids)) +} diff --git a/packages/extension/package.json b/packages/extension/package.json index 28f4bbfc..1336831d 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -32,7 +32,7 @@ "@tempad-dev/plugins": "workspace:^0.6.2", "@tempad-dev/shared": "workspace:^0.1.0", "@vueuse/core": "^14.3.0", - "es-module-lexer": "2.3.1", + "es-module-lexer": "2.1.0", "overlayscrollbars": "^2.16.0", "p-wait-for": "^6.0.0", "stringify-object": "^7.0.0", diff --git a/packages/extension/scripts/check-rewrite.ts b/packages/extension/scripts/check-rewrite.ts index 6c67b1bf..77b5479f 100644 --- a/packages/extension/scripts/check-rewrite.ts +++ b/packages/extension/scripts/check-rewrite.ts @@ -106,7 +106,8 @@ async function runCheck() { replacementIndex, changed: replacementChanged } of scriptReplacementStats) { - const stat = replacementStats[groupIndex][replacementIndex] + const stat = replacementStats[groupIndex]?.[replacementIndex] + if (!stat) continue if (replacementChanged) { stat.hits.push(url) } else { @@ -147,6 +148,7 @@ async function runCheck() { reportLines.push('', 'FAIL: Some replacements were never applied.') missingReplacements.forEach(({ groupIndex, replacementIndex, noEffect }) => { const group = GROUPS[groupIndex] + if (!group) return const statusText = noEffect.length > 0 ? `no effect in ${noEffect.length} script(s)` : 'group never matched' reportLines.push( diff --git a/packages/extension/tests/components/select.browser.test.ts b/packages/extension/tests/components/select.browser.test.ts index 0db3aa6b..be8a159d 100644 --- a/packages/extension/tests/components/select.browser.test.ts +++ b/packages/extension/tests/components/select.browser.test.ts @@ -54,7 +54,9 @@ describe('Select', () => { await page.getByRole('combobox', { name: 'Agent' }).click() const controlRect = select.getBoundingClientRect() - const selectedRect = select.selectedOptions[0].getBoundingClientRect() + const selectedOption = select.selectedOptions[0] + if (!selectedOption) throw new Error('Expected a selected option') + const selectedRect = selectedOption.getBoundingClientRect() expect(Math.abs(selectedRect.top - controlRect.top)).toBeLessThanOrEqual(1) expect(Math.abs(selectedRect.left - controlRect.left)).toBeLessThanOrEqual(2) diff --git a/packages/extension/tests/composables/input.test.ts b/packages/extension/tests/composables/input.test.ts index 9b377c9d..72b95673 100644 --- a/packages/extension/tests/composables/input.test.ts +++ b/packages/extension/tests/composables/input.test.ts @@ -23,14 +23,16 @@ describe('composables/input', () => { expect(mocks.useEventListener).toHaveBeenCalledTimes(1) expect(mocks.useEventListener).toHaveBeenCalledWith(input, 'focus', expect.any(Function)) - const callback = mocks.useEventListener.mock.calls[0][2] as (e: Event) => void + const callback = mocks.useEventListener.mock.calls[0]?.[2] as ((e: Event) => void) | undefined + if (!callback) throw new Error('Expected focus callback') callback({ target: input } as unknown as Event) expect(input.select).toHaveBeenCalledTimes(1) }) it('handles null-ish event targets safely', () => { useSelectAll(null) - const callback = mocks.useEventListener.mock.calls[0][2] as (e: Event) => void + const callback = mocks.useEventListener.mock.calls[0]?.[2] as ((e: Event) => void) | undefined + if (!callback) throw new Error('Expected focus callback') expect(() => callback({ target: null } as unknown as Event)).not.toThrow() }) diff --git a/packages/extension/tests/composables/mcp.test.ts b/packages/extension/tests/composables/mcp.test.ts index 7e6130bc..c7c407cb 100644 --- a/packages/extension/tests/composables/mcp.test.ts +++ b/packages/extension/tests/composables/mcp.test.ts @@ -1,6 +1,10 @@ import type { BridgeToPageMessage, PageToBridgeMessage } from '@tempad-dev/shared' -import { TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, TEMPAD_MCP_BROWSER_SOURCE } from '@tempad-dev/shared' +import { + TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, + TEMPAD_MCP_BROWSER_SOURCE, + TEMPAD_MCP_ERROR_CODES +} from '@tempad-dev/shared' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MCP_LOCAL_HOST_PERMISSION_ERROR, MCP_PERMISSION_REQUEST_EVENT } from '@/mcp/permissions' @@ -13,7 +17,6 @@ const mocks = vi.hoisted(() => { } const runtimeMode = { value: 'standard' } const layoutReady = { value: true } - const canvasWritesOn = { value: false } const listeners: Array<(event: MessageEvent) => void> = [] const window = { dispatchEvent: vi.fn(), @@ -21,12 +24,12 @@ const mocks = vi.hoisted(() => { } return { - canvasWritesOn, layoutReady, listeners, options, - resetUploadedAssets: vi.fn(), + resetAssetCache: vi.fn(), runtimeMode, + setAssetDownloader: vi.fn(), setAssetServerUrl: vi.fn(), setAssetUploader: vi.fn(), window @@ -66,7 +69,8 @@ vi.mock('@vueuse/core', () => ({ })) vi.mock('@/mcp/assets', () => ({ - resetUploadedAssets: mocks.resetUploadedAssets, + resetAssetCache: mocks.resetAssetCache, + setAssetDownloader: mocks.setAssetDownloader, setAssetServerUrl: mocks.setAssetServerUrl, setAssetUploader: mocks.setAssetUploader })) @@ -80,7 +84,6 @@ vi.mock('@/mcp/runtime', () => ({ })) vi.mock('@/ui/state', () => ({ - canvasWritesOn: mocks.canvasWritesOn, layoutReady: mocks.layoutReady, options: mocks.options, runtimeMode: mocks.runtimeMode @@ -135,29 +138,27 @@ function receive(message: BridgeToPageMessage): void { describe('composables/mcp', () => { beforeEach(() => { mocks.options.value.mcpOn = true - mocks.canvasWritesOn.value = false mocks.runtimeMode.value = 'standard' mocks.layoutReady.value = true mocks.listeners.length = 0 mocks.window.dispatchEvent.mockReset() mocks.window.postMessage.mockReset() - mocks.resetUploadedAssets.mockReset() + mocks.resetAssetCache.mockReset() + mocks.setAssetDownloader.mockReset() mocks.setAssetServerUrl.mockReset() mocks.setAssetUploader.mockReset() vi.stubGlobal('window', mocks.window) vi.stubGlobal('location', { origin: ORIGIN }) }) - it('keeps MCP enabled but drops writes while retrying local-host permission', () => { + it('keeps MCP enabled while retrying local-host permission', () => { const mcp = useMcp() const sessionId = getPostedMessage('mcp.enable').sessionId - mocks.canvasWritesOn.value = true receive(bridgeState(sessionId, 'connecting', MCP_LOCAL_HOST_PERMISSION_ERROR)) expect(mcp.needsLocalHostPermission.value).toBe(true) expect(mocks.options.value.mcpOn).toBe(true) - expect(mocks.canvasWritesOn.value).toBe(false) expect( mocks.window.postMessage.mock.calls.some( ([payload]) => (payload as PageToBridgeMessage).type === 'mcp.disable' @@ -188,12 +189,95 @@ describe('composables/mcp', () => { ).toBe(false) }) - it('turns off session canvas writes when MCP cannot stay enabled', () => { - mocks.options.value.mcpOn = false - mocks.canvasWritesOn.value = true + it('routes asset downloads through the active browser session', async () => { + useMcp() + const sessionId = getPostedMessage('mcp.enable').sessionId + const download = mocks.setAssetDownloader.mock.calls[0]?.[0] as + | ((hash: string) => Promise<{ base64: string; mimeType: string; size: number }>) + | undefined + expect(download).toBeTypeOf('function') + + const pending = download!('a'.repeat(64)) + const request = getPostedMessage('mcp.downloadAsset') as Extract< + PageToBridgeMessage, + { type: 'mcp.downloadAsset' } + > + receive({ + payload: { base64: 'AQID', mimeType: 'image/png', size: 3 }, + requestId: request.requestId, + sessionId, + source: TEMPAD_MCP_BROWSER_SOURCE, + type: 'mcp.assetDownloadResult', + version: TEMPAD_MCP_BROWSER_PROTOCOL_VERSION + }) + await expect(pending).resolves.toEqual({ + base64: 'AQID', + mimeType: 'image/png', + size: 3 + }) + + mocks.window.postMessage.mockClear() + const failed = download!('b'.repeat(64)) + const failedRequest = getPostedMessage('mcp.downloadAsset') as Extract< + PageToBridgeMessage, + { type: 'mcp.downloadAsset' } + > + receive({ + error: { + code: TEMPAD_MCP_ERROR_CODES.ASSET_NOT_FOUND, + message: 'Asset not found.' + }, + requestId: failedRequest.requestId, + sessionId, + source: TEMPAD_MCP_BROWSER_SOURCE, + type: 'mcp.assetDownloadResult', + version: TEMPAD_MCP_BROWSER_PROTOCOL_VERSION + }) + + await expect(failed).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.ASSET_NOT_FOUND, + message: 'Asset not found.' + }) + }) + + it('routes asset uploads through the same request lifecycle', async () => { useMcp() + const sessionId = getPostedMessage('mcp.enable').sessionId + const upload = mocks.setAssetUploader.mock.calls[0]?.[0] as + | ((request: { + bytes: Uint8Array + hash: string + metadata?: { width?: number } + mimeType: string + }) => Promise) + | undefined + expect(upload).toBeTypeOf('function') + + const pending = upload!({ + bytes: new Uint8Array([1, 2, 3]), + hash: 'c'.repeat(64), + metadata: { width: 12 }, + mimeType: 'image/png' + }) + const request = getPostedMessage('mcp.uploadAsset') as Extract< + PageToBridgeMessage, + { type: 'mcp.uploadAsset' } + > + expect(request.payload).toEqual({ + base64: 'AQID', + hash: 'c'.repeat(64), + metadata: { width: 12 }, + mimeType: 'image/png' + }) + receive({ + requestId: request.requestId, + sessionId, + source: TEMPAD_MCP_BROWSER_SOURCE, + type: 'mcp.assetUploadResult', + version: TEMPAD_MCP_BROWSER_PROTOCOL_VERSION + }) - expect(mocks.canvasWritesOn.value).toBe(false) + await expect(pending).resolves.toBeUndefined() }) }) diff --git a/packages/extension/tests/mcp/assets.test.ts b/packages/extension/tests/mcp/assets.test.ts index 4afe7970..63ee853f 100644 --- a/packages/extension/tests/mcp/assets.test.ts +++ b/packages/extension/tests/mcp/assets.test.ts @@ -1,8 +1,4 @@ -import { - MCP_HASH_HEX_LENGTH, - MCP_MAX_ASSET_BYTES, - TEMPAD_MCP_ERROR_CODES -} from '@tempad-dev/shared' +import { MCP_MAX_ASSET_BYTES, TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@/utils/log', () => ({ @@ -15,8 +11,10 @@ vi.mock('@/utils/log', () => ({ })) import { + downloadAsset, ensureAssetUploaded, - resetUploadedAssets, + resetAssetCache, + setAssetDownloader, setAssetServerUrl, setAssetUploader } from '@/mcp/assets' @@ -25,7 +23,7 @@ const DIGEST_BYTES = new Uint8Array(Array.from({ length: 32 }, (_, index) => ind const DIGEST_HEX = Array.from(DIGEST_BYTES) .map((byte) => byte.toString(16).padStart(2, '0')) .join('') -const EXPECTED_HASH = DIGEST_HEX.slice(0, MCP_HASH_HEX_LENGTH) +const EXPECTED_HASH = DIGEST_HEX function mockCryptoDigest() { vi.stubGlobal('crypto', { @@ -36,13 +34,70 @@ function mockCryptoDigest() { } afterEach(() => { - resetUploadedAssets() + resetAssetCache() setAssetServerUrl(null) + setAssetDownloader(null) setAssetUploader(null) vi.unstubAllGlobals() }) describe('mcp/assets', () => { + it('downloads, verifies, and caches content-addressed assets', async () => { + mockCryptoDigest() + const downloader = vi.fn().mockResolvedValue({ + base64: 'AQID', + mimeType: 'image/png', + size: 3 + }) + setAssetDownloader(downloader) + + const first = await downloadAsset(EXPECTED_HASH) + const second = await downloadAsset(EXPECTED_HASH) + + expect(downloader).toHaveBeenCalledTimes(1) + expect(first).toEqual({ + bytes: new Uint8Array([1, 2, 3]), + mimeType: 'image/png' + }) + expect(second).toBe(first) + }) + + it('accepts legacy short hashes for cached downloads during migration', async () => { + mockCryptoDigest() + const downloader = vi.fn().mockResolvedValue({ + base64: 'AQID', + mimeType: 'image/png', + size: 3 + }) + setAssetDownloader(downloader) + + await expect(downloadAsset(EXPECTED_HASH.slice(0, 8))).resolves.toMatchObject({ + bytes: new Uint8Array([1, 2, 3]), + mimeType: 'image/png' + }) + }) + + it('rejects unavailable or invalid downloads without caching failures', async () => { + await expect(downloadAsset(EXPECTED_HASH)).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.ASSET_BRIDGE_UNAVAILABLE + }) + + mockCryptoDigest() + const downloader = vi.fn().mockResolvedValue({ + base64: 'AQID', + mimeType: 'image/png', + size: 4 + }) + setAssetDownloader(downloader) + await expect(downloadAsset(EXPECTED_HASH)).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.ASSET_HASH_MISMATCH + }) + await expect(downloadAsset(EXPECTED_HASH)).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.ASSET_HASH_MISMATCH + }) + expect(downloader).toHaveBeenCalledTimes(2) + }) + it('rejects oversized assets before hashing or uploading', async () => { const digest = vi.fn() vi.stubGlobal('crypto', { subtle: { digest } }) @@ -157,6 +212,73 @@ describe('mcp/assets', () => { expect(first).toEqual(second) }) + it('does not let a pre-reset upload mark a newer generation as complete', async () => { + mockCryptoDigest() + const resolvers: Array<() => void> = [] + const uploadMock = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }) + ) + setAssetUploader(uploadMock) + setAssetServerUrl('http://assets.local') + const bytes = new Uint8Array([1, 2, 3]) + + const stale = ensureAssetUploaded(bytes, 'image/png') + await vi.waitFor(() => expect(uploadMock).toHaveBeenCalledTimes(1)) + resetAssetCache() + const current = ensureAssetUploaded(bytes, 'image/png') + await vi.waitFor(() => expect(uploadMock).toHaveBeenCalledTimes(2)) + + resolvers[0]!() + await stale + let joinedCurrent = false + const joined = ensureAssetUploaded(bytes, 'image/png').then(() => { + joinedCurrent = true + }) + await Promise.resolve() + await Promise.resolve() + expect(joinedCurrent).toBe(false) + expect(uploadMock).toHaveBeenCalledTimes(2) + + resolvers[1]!() + await Promise.all([current, joined]) + }) + + it('does not let a stale failed download evict a newer cached promise', async () => { + mockCryptoDigest() + let rejectStale!: (error: Error) => void + let resolveCurrent!: (value: { base64: string; mimeType: string; size: number }) => void + const downloader = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectStale = reject + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCurrent = resolve + }) + ) + setAssetDownloader(downloader) + + const stale = downloadAsset(EXPECTED_HASH).catch((error) => error) + resetAssetCache() + const current = downloadAsset(EXPECTED_HASH) + rejectStale(new Error('stale failure')) + await stale + const joined = downloadAsset(EXPECTED_HASH) + + expect(joined).toBe(current) + expect(downloader).toHaveBeenCalledTimes(2) + resolveCurrent({ base64: 'AQID', mimeType: 'image/png', size: 3 }) + await expect(joined).resolves.toMatchObject({ mimeType: 'image/png' }) + }) + it('propagates uploader errors', async () => { mockCryptoDigest() const uploadMock = vi diff --git a/packages/extension/tests/mcp/broker/hub-client.test.ts b/packages/extension/tests/mcp/broker/hub-client.test.ts index 8f69f256..6c785ef9 100644 --- a/packages/extension/tests/mcp/broker/hub-client.test.ts +++ b/packages/extension/tests/mcp/broker/hub-client.test.ts @@ -1,3 +1,4 @@ +import { TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION } from '@tempad-dev/shared' import { afterEach, describe, expect, it, vi } from 'vitest' import { McpHubClient } from '@/mcp/broker/hub-client' @@ -68,7 +69,11 @@ function stateMessage(activeId: string | null = null) { function completeHandshake(socket: FakeWebSocket, activeId: string | null = null): void { socket.open() - socket.receive({ type: 'registered', id: 'gateway-1' }) + socket.receive({ + type: 'registered', + id: 'gateway-1', + protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION + }) socket.receive(stateMessage(activeId)) } @@ -198,7 +203,11 @@ describe('mcp/broker/hub-client', () => { ['malformed traffic', '{', 'Received malformed message from MCP server'], [ 'duplicate registration', - JSON.stringify({ type: 'registered', id: 'replacement' }), + JSON.stringify({ + type: 'registered', + id: 'replacement', + protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION + }), 'Received duplicate registration from MCP server' ], [ @@ -291,8 +300,16 @@ describe('mcp/broker/hub-client', () => { [ 'duplicate registration', [ - JSON.stringify({ type: 'registered', id: 'gateway-1' }), - JSON.stringify({ type: 'registered', id: 'gateway-2' }) + JSON.stringify({ + type: 'registered', + id: 'gateway-1', + protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION + }), + JSON.stringify({ + type: 'registered', + id: 'gateway-2', + protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION + }) ] ], ['duplicate state', [JSON.stringify(stateMessage()), JSON.stringify(stateMessage())]], @@ -303,7 +320,11 @@ describe('mcp/broker/hub-client', () => { [ 'a non-loopback asset URL', [ - JSON.stringify({ type: 'registered', id: 'gateway-1' }), + JSON.stringify({ + type: 'registered', + id: 'gateway-1', + protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION + }), JSON.stringify({ activeId: null, assetServerUrl: 'https://collector.example/assets', @@ -329,6 +350,37 @@ describe('mcp/broker/hub-client', () => { client.stop() }) + it.each([ + ['missing', undefined], + ['different', TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION + 1] + ])('reports a %s bridge protocol after probing candidates', async (_case, protocolVersion) => { + vi.stubGlobal('WebSocket', { OPEN: 1 }) + installHubProbe() + const sockets: FakeWebSocket[] = [] + const client = createClient(sockets) + + client.start() + await flushMicrotasks() + for (let index = 0; index < 3; index++) { + sockets[index]?.open() + sockets[index]?.receive({ + type: 'registered', + id: `gateway-${index}`, + ...(protocolVersion === undefined ? {} : { protocolVersion }) + }) + await flushMicrotasks() + } + + expect(client.getSnapshot()).toMatchObject({ + errorMessage: expect.stringContaining('protocol mismatch'), + status: 'error' + }) + expect(client.getSnapshot().errorMessage).toContain( + 'Update the extension and MCP server together' + ) + client.stop() + }) + it('ignores stale events from a replaced socket', async () => { vi.stubGlobal('WebSocket', { OPEN: 1 }) installHubProbe() diff --git a/packages/extension/tests/mcp/broker/service-worker.test.ts b/packages/extension/tests/mcp/broker/service-worker.test.ts index b8f5369d..33c29318 100644 --- a/packages/extension/tests/mcp/broker/service-worker.test.ts +++ b/packages/extension/tests/mcp/broker/service-worker.test.ts @@ -1,6 +1,7 @@ import type { ToolCallMessage } from '@tempad-dev/shared' import { + MCP_MAX_ASSET_BYTES, TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, TEMPAD_MCP_BROWSER_SOURCE, TEMPAD_MCP_ERROR_CODES, @@ -18,6 +19,8 @@ import { MCP_LOCAL_HOST_ORIGIN } from '@/mcp/permissions' +const ASSET_HASH = '039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81' + type Listener = (payload: T) => void type BrokerInternals = { handlePermissionMessage: (type: McpPermissionMessageType) => Promise<{ granted: boolean }> @@ -93,7 +96,7 @@ function assetUpload(sessionId = 'session-1') { return { payload: { base64: 'AQID', - hash: 'abcdef12', + hash: ASSET_HASH, metadata: { height: 20, themeable: true, width: 10 }, mimeType: 'image/png' }, @@ -105,6 +108,17 @@ function assetUpload(sessionId = 'session-1') { } } +function assetDownload(sessionId = 'session-1') { + return { + payload: { hash: ASSET_HASH }, + requestId: 'download-1', + sessionId, + source: TEMPAD_MCP_BROWSER_SOURCE, + type: 'mcp.downloadAsset', + version: TEMPAD_MCP_BROWSER_PROTOCOL_VERSION + } +} + function routeToolCall(broker: McpServiceWorkerBroker, id = 'call-1'): void { const internals = broker as unknown as BrokerInternals internals.routeToolCall({ @@ -389,7 +403,7 @@ describe('mcp/broker/service-worker', () => { session.message(assetUpload()) await flushMicrotasks() - expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:9000/assets/abcdef12', { + expect(fetchMock).toHaveBeenCalledWith(`http://127.0.0.1:9000/assets/${ASSET_HASH}`, { body: expect.any(Blob), headers: { 'Content-Type': 'image/png', @@ -412,6 +426,114 @@ describe('mcp/broker/service-worker', () => { }) }) + it('downloads and verifies hash-addressed assets for the owning session', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(new Uint8Array([1, 2, 3]), { + headers: { 'Content-Type': 'image/png' }, + status: 200 + }) + ) + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch) + const broker = new McpServiceWorkerBroker( + createHubClient({ assetServerUrl: 'http://127.0.0.1:9000' }) + ) + const session = createPort('https://www.figma.com/design/abc/File') + + broker.handlePort(session.port) + session.message(pageMessage('mcp.enable')) + session.postMessage.mockClear() + session.message(assetDownload()) + await flushMicrotasks() + await flushMicrotasks() + await vi.waitFor(() => expect(session.postMessage).toHaveBeenCalled()) + + expect(fetchMock).toHaveBeenCalledWith(`http://127.0.0.1:9000/assets/${ASSET_HASH}`, { + method: 'GET' + }) + expect(session.postMessage).toHaveBeenLastCalledWith({ + payload: { + base64: 'AQID', + mimeType: 'image/png', + size: 3 + }, + requestId: 'download-1', + sessionId: 'session-1', + source: TEMPAD_MCP_BROWSER_SOURCE, + type: 'mcp.assetDownloadResult', + version: TEMPAD_MCP_BROWSER_PROTOCOL_VERSION + }) + }) + + it('returns a coded error when a downloaded asset is missing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(null, { status: 404 })) as unknown as typeof fetch + ) + const broker = new McpServiceWorkerBroker( + createHubClient({ assetServerUrl: 'http://127.0.0.1:9000' }) + ) + const session = createPort('https://www.figma.com/design/abc/File') + + broker.handlePort(session.port) + session.message(pageMessage('mcp.enable')) + session.postMessage.mockClear() + session.message(assetDownload()) + await flushMicrotasks() + await flushMicrotasks() + + expect(session.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + error: { + code: TEMPAD_MCP_ERROR_CODES.ASSET_NOT_FOUND, + message: expect.stringContaining('was not found') + }, + requestId: 'download-1', + type: 'mcp.assetDownloadResult' + }) + ) + }) + + it('stops streaming assets once the bridge byte limit is exceeded', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MCP_MAX_ASSET_BYTES)) + controller.enqueue(new Uint8Array([1])) + controller.close() + } + }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(stream, { + headers: { 'Content-Type': 'image/png' }, + status: 200 + }) + ) as unknown as typeof fetch + ) + const broker = new McpServiceWorkerBroker( + createHubClient({ assetServerUrl: 'http://127.0.0.1:9000' }) + ) + const session = createPort('https://www.figma.com/design/abc/File') + + broker.handlePort(session.port) + session.message(pageMessage('mcp.enable')) + session.postMessage.mockClear() + session.message(assetDownload()) + + await vi.waitFor(() => + expect(session.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + error: { + code: TEMPAD_MCP_ERROR_CODES.ASSET_TOO_LARGE, + message: expect.stringContaining('bridge limit') + }, + requestId: 'download-1', + type: 'mcp.assetDownloadResult' + }) + ) + ) + }) + it('returns an asset upload error when the hub has no asset server URL', async () => { const broker = new McpServiceWorkerBroker(createHubClient()) const session = createPort('https://www.figma.com/design/abc/File') diff --git a/packages/extension/tests/mcp/runtime.test.ts b/packages/extension/tests/mcp/runtime.test.ts index 9e1a2f77..ee8b675b 100644 --- a/packages/extension/tests/mcp/runtime.test.ts +++ b/packages/extension/tests/mcp/runtime.test.ts @@ -72,14 +72,16 @@ describe('mcp/runtime', () => { setFigmaGetNodeById(null) const runtime = await importRuntime() - expect(Object.keys(runtime.MCP_TOOL_HANDLERS)).toEqual([ - 'apply_canvas', - 'get_code', - 'get_design_system', - 'get_token_defs', - 'get_screenshot', - 'get_structure' - ]) + expect(new Set(Object.keys(runtime.MCP_TOOL_HANDLERS))).toEqual( + new Set([ + 'apply_canvas', + 'get_code', + 'get_design_system', + 'get_token_defs', + 'get_screenshot', + 'get_structure' + ]) + ) expect(typeof (globalThis as { window?: unknown }).window).toBe('undefined') }, 15000) @@ -91,13 +93,7 @@ describe('mcp/runtime', () => { const runtime = await importRuntime() const tools = (window as Window & { tempadTools: Record }).tempadTools - expect(tools.existing).toBe(existing) - expect(tools.apply_canvas).toBe(runtime.MCP_TOOL_HANDLERS.apply_canvas) - expect(tools.get_code).toBe(runtime.WINDOW_TEMPAD_TOOL_HANDLERS.get_code) - expect(tools.get_design_system).toBe(runtime.MCP_TOOL_HANDLERS.get_design_system) - expect(tools.get_token_defs).toBe(runtime.MCP_TOOL_HANDLERS.get_token_defs) - expect(tools.get_screenshot).toBe(runtime.MCP_TOOL_HANDLERS.get_screenshot) - expect(tools.get_structure).toBe(runtime.MCP_TOOL_HANDLERS.get_structure) + expect(tools).toEqual({ existing, ...runtime.WINDOW_TEMPAD_TOOL_HANDLERS }) }, 15000) it('initializes window.tempadTools when window exists without existing tools', async () => { @@ -107,12 +103,7 @@ describe('mcp/runtime', () => { const runtime = await importRuntime() const tools = (window as Window & { tempadTools: Record }).tempadTools - expect(tools.apply_canvas).toBe(runtime.MCP_TOOL_HANDLERS.apply_canvas) - expect(tools.get_code).toBe(runtime.WINDOW_TEMPAD_TOOL_HANDLERS.get_code) - expect(tools.get_design_system).toBe(runtime.MCP_TOOL_HANDLERS.get_design_system) - expect(tools.get_token_defs).toBe(runtime.MCP_TOOL_HANDLERS.get_token_defs) - expect(tools.get_screenshot).toBe(runtime.MCP_TOOL_HANDLERS.get_screenshot) - expect(tools.get_structure).toBe(runtime.MCP_TOOL_HANDLERS.get_structure) + expect(tools).toEqual(runtime.WINDOW_TEMPAD_TOOL_HANDLERS) }) it('routes get_code to tool implementation with resolved node and options', async () => { @@ -147,14 +138,17 @@ describe('mcp/runtime', () => { expect(result).toEqual({ blocks: [] }) }) - it('rejects unknown bridge tool names at the runtime boundary', async () => { - setFigmaGetNodeById(null) - const runtime = await importRuntime() - - await expect(runtime.runMcpTool('missing', {})).rejects.toThrow( - 'No handler registered for tool "missing".' - ) - }) + it.each(['missing', 'toString'])( + 'rejects unknown bridge tool name "%s" at the runtime boundary', + async (name) => { + setFigmaGetNodeById(null) + const runtime = await importRuntime() + + await expect(runtime.runMcpTool(name, {})).rejects.toThrow( + `No handler registered for tool "${name}".` + ) + } + ) it('routes window get_code debug overrides only through tempadTools exposure', async () => { const node = createSceneNode('node-1') diff --git a/packages/extension/tests/mcp/semantic-tree.test.ts b/packages/extension/tests/mcp/semantic-tree.test.ts index 1accb867..f614211b 100644 --- a/packages/extension/tests/mcp/semantic-tree.test.ts +++ b/packages/extension/tests/mcp/semantic-tree.test.ts @@ -7,6 +7,12 @@ import { type SemanticNode } from '@/mcp/semantic-tree' +function first(items: readonly T[]): T { + const [item] = items + if (item === undefined) throw new Error('Expected a non-empty array') + return item +} + function createNode( type: SceneNode['type'], id: string, @@ -78,16 +84,17 @@ describe('mcp/semantic-tree', () => { expect(tree.stats.totalNodes).toBe(2) expect(tree.roots).toHaveLength(1) - expect(tree.roots[0].id).toBe('instance-1') - expect(tree.roots[0].depth).toBe(0) - expect(tree.roots[0].tag).toBe('div') - expect(tree.roots[0].dataHint).toBeDefined() - expect(tree.roots[0].dataHint?.['data-hint-design-component']).toContain('ButtonGroup') - expect(tree.roots[0].dataHint?.['data-hint-design-component']).toContain('[Size=Large]') - expect(tree.roots[0].dataHint?.['data-hint-design-component']).toContain('[disabled=off]') - expect(tree.roots[0].dataHint?.['data-hint-design-component']).toContain('[text=Submit]') - expect(tree.roots[0].dataHint?.['data-hint-auto-layout']).toBeUndefined() - expect(tree.roots[0].autoLayout).toEqual({ + const treeRoot = first(tree.roots) + expect(treeRoot.id).toBe('instance-1') + expect(treeRoot.depth).toBe(0) + expect(treeRoot.tag).toBe('div') + expect(treeRoot.dataHint).toBeDefined() + expect(treeRoot.dataHint?.['data-hint-design-component']).toContain('ButtonGroup') + expect(treeRoot.dataHint?.['data-hint-design-component']).toContain('[Size=Large]') + expect(treeRoot.dataHint?.['data-hint-design-component']).toContain('[disabled=off]') + expect(treeRoot.dataHint?.['data-hint-design-component']).toContain('[text=Submit]') + expect(treeRoot.dataHint?.['data-hint-auto-layout']).toBeUndefined() + expect(treeRoot.autoLayout).toEqual({ direction: 'row', gap: 8, alignPrimary: 'CENTER', @@ -95,10 +102,11 @@ describe('mcp/semantic-tree', () => { padding: { top: 4, right: 6, bottom: 8, left: 10 } }) - expect(tree.roots[0].children).toHaveLength(1) - expect(tree.roots[0].children[0].id).toBe('text-1') - expect(tree.roots[0].children[0].tag).toBe('p') - expect(tree.roots[0].children[0].layout).toBe('absolute') + expect(treeRoot.children).toHaveLength(1) + const treeChild = first(treeRoot.children) + expect(treeChild.id).toBe('text-1') + expect(treeChild.tag).toBe('p') + expect(treeChild.layout).toBe('absolute') }) it('adds inferred auto-layout hint when inferred metadata exists without explicit layout mode', () => { @@ -110,9 +118,22 @@ describe('mcp/semantic-tree', () => { }) const tree = buildSemanticTree([root]) + const treeRoot = first(tree.roots) + + expect(treeRoot.dataHint?.['data-hint-auto-layout']).toBe('inferred') + expect(treeRoot.autoLayout).toBeUndefined() + }) + + it('classifies a video-filled rectangle as a media asset', () => { + const video = createNode('RECTANGLE', 'video-1', { + fills: [{ type: 'VIDEO', videoHash: 'video-hash', visible: true }] + }) + + const node = first(buildSemanticTree([video]).roots) - expect(tree.roots[0].dataHint?.['data-hint-auto-layout']).toBe('inferred') - expect(tree.roots[0].autoLayout).toBeUndefined() + expect(node.tag).toBe('img') + expect(node.isAsset).toBe(true) + expect(node.assetKind).toBe('image') }) it('caps nodes at depth limit and reports capped ids', () => { @@ -134,7 +155,7 @@ describe('mcp/semantic-tree', () => { expect(tree.stats.capped).toBe(true) expect(tree.cappedNodeIds).toContain('child-1') - const cappedChild = tree.roots[0].children[0] + const cappedChild = first(first(tree.roots).children) expect(cappedChild.id).toBe('child-1') expect(cappedChild.capped).toBe(true) expect(cappedChild.children).toEqual([]) diff --git a/packages/extension/tests/mcp/tools/canvas-assets.test.ts b/packages/extension/tests/mcp/tools/canvas-assets.test.ts new file mode 100644 index 00000000..cba09ae5 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-assets.test.ts @@ -0,0 +1,75 @@ +import type { CanvasAssets } from '@tempad-dev/shared' + +import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' +import { describe, expect, it } from 'vitest' + +import { resolveCanvasAssets, resolvedSvgAsset } from '@/mcp/tools/canvas/assets' + +function svgAssets(svg: string): CanvasAssets { + return { icon: { type: 'SVG', svg } } +} + +function colors(color?: string): Map> { + return new Map([['icon', new Set([color])]]) +} + +describe('mcp/tools/canvas SVG assets', () => { + it('keeps local SVG structure while resolving currentColor deterministically', async () => { + const assets = await resolveCanvasAssets( + svgAssets( + '' + ), + colors('#336699') + ) + const resolved = resolvedSvgAsset(assets, 'icon', '#336699') + + expect(resolved).toMatchObject({ height: 24, type: 'SVG', width: 24 }) + expect(resolved?.svg).toContain('stop-color="#336699"') + expect(resolved?.svg).toContain('fill="url(#g)"') + expect(resolved?.digest).toMatch(/^[a-f0-9]{64}$/) + }) + + it.each([ + ['', TEMPAD_MCP_ERROR_CODES.SVG_INVALID], + [ + '', + TEMPAD_MCP_ERROR_CODES.SVG_INVALID + ], + [ + '', + TEMPAD_MCP_ERROR_CODES.SVG_EXTERNAL_REFERENCE + ], + [ + '', + TEMPAD_MCP_ERROR_CODES.SVG_INVALID + ], + ['', TEMPAD_MCP_ERROR_CODES.SVG_INVALID] + ])('rejects unsafe or invalid SVG input', async (svg, code) => { + await expect(resolveCanvasAssets(svgAssets(svg), colors())).rejects.toMatchObject({ code }) + }) + + it('rejects unresolved currentColor and excessive element counts', async () => { + await expect( + resolveCanvasAssets( + svgAssets(''), + colors() + ) + ).rejects.toMatchObject({ code: TEMPAD_MCP_ERROR_CODES.SVG_INVALID }) + + await expect( + resolveCanvasAssets( + svgAssets(`${''.repeat(500)}`), + colors() + ) + ).rejects.toMatchObject({ code: TEMPAD_MCP_ERROR_CODES.SVG_TOO_COMPLEX }) + }) + + it('does not sanitize SVG declarations that are outside the referenced result', async () => { + const assets = await resolveCanvasAssets( + svgAssets(''), + new Map() + ) + + expect(resolvedSvgAsset(assets, 'icon', undefined)).toBeUndefined() + }) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-markup.test.ts b/packages/extension/tests/mcp/tools/canvas-markup.test.ts new file mode 100644 index 00000000..e332954c --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-markup.test.ts @@ -0,0 +1,2364 @@ +import type { + CanvasResolvedApplyParameters, + CanvasBinding, + CanvasFigmaProperties +} from '@tempad-dev/shared' + +import { describe, expect, it } from 'vitest' + +import type { ParsedCanvasTreeInput } from '@/mcp/tools/canvas/model' + +import { parseCanvasMarkup } from '@/mcp/tools/canvas/markup' + +function parse( + markup: string, + overrides: Omit, 'markup'> = {} +): ParsedCanvasTreeInput { + return parseCanvasMarkup({ + mode: 'create', + markup, + ...overrides + } as CanvasResolvedApplyParameters) as ParsedCanvasTreeInput +} + +const BLEND_MODE_CLASSES = [ + ['mix-blend-pass-through', 'PASS_THROUGH'], + ['mix-blend-normal', 'NORMAL'], + ['mix-blend-darken', 'DARKEN'], + ['mix-blend-multiply', 'MULTIPLY'], + ['mix-blend-plus-darker', 'LINEAR_BURN'], + ['mix-blend-color-burn', 'COLOR_BURN'], + ['mix-blend-lighten', 'LIGHTEN'], + ['mix-blend-screen', 'SCREEN'], + ['mix-blend-plus-lighter', 'LINEAR_DODGE'], + ['mix-blend-color-dodge', 'COLOR_DODGE'], + ['mix-blend-overlay', 'OVERLAY'], + ['mix-blend-soft-light', 'SOFT_LIGHT'], + ['mix-blend-hard-light', 'HARD_LIGHT'], + ['mix-blend-difference', 'DIFFERENCE'], + ['mix-blend-exclusion', 'EXCLUSION'], + ['mix-blend-hue', 'HUE'], + ['mix-blend-saturation', 'SATURATION'], + ['mix-blend-color', 'COLOR'], + ['mix-blend-luminosity', 'LUMINOSITY'] +] as const satisfies ReadonlyArray + +describe('canvas markup', () => { + it('normalizes supported layout, appearance, and text classes', () => { + const result = parse(` +
+ + Settings & profile + +
+ `) + + expect(result.root).toMatchObject({ + key: 'card', + type: 'FRAME', + size: { + width: 320, + height: 200, + horizontal: 'FIXED', + vertical: 'FIXED' + }, + grow: false, + layout: { + mode: 'VERTICAL', + gap: 12, + padding: { top: 16, right: 16, bottom: 16, left: 16 }, + primaryAlign: 'SPACE_BETWEEN', + counterAlign: 'CENTER' + }, + appearance: { + fill: '#FFFFFF', + stroke: '#D0D0D0', + strokeWeight: 1, + cornerRadius: 12, + opacity: 0.9 + } + }) + expect(result.root.children?.[0]).toMatchObject({ + key: 'title', + type: 'TEXT', + size: { horizontal: 'FILL', vertical: 'HUG' }, + appearance: { fill: '#202020', opacity: 1 }, + text: { + characters: 'Settings & profile', + fontFamily: 'Inter', + fontStyle: 'Semi Bold', + fontSize: 18, + lineHeight: { unit: 'PIXELS', value: 24 }, + letterSpacing: { unit: 'PIXELS', value: 0.5 }, + alignHorizontal: 'CENTER', + autoResize: 'HEIGHT' + } + }) + }) + + it('normalizes native Tailwind scales when they map exactly to Figma', () => { + const result = parse(` +
+ Native utilities +
+ `) + + expect(result.root).toMatchObject({ + size: { width: 384, height: 192 }, + layout: { + gap: 14, + padding: { top: 16, right: 24, bottom: 16, left: 24 } + }, + appearance: { + fill: '#FFFFFF', + stroke: '#000000', + strokeWeight: 2, + cornerRadius: 16, + opacity: 0.9 + } + }) + expect(result.root.children?.[0]).toMatchObject({ + text: { + fontStyle: 'Extra Bold', + fontSize: 18, + lineHeight: { unit: 'PIXELS', value: 28 }, + letterSpacing: { unit: 'PERCENT', value: 2.5 } + }, + appearance: { fill: '#000000' } + }) + }) + + it('normalizes native size, position, border-side, radius, and text defaults', () => { + const result = parse(` +
+
+ Copy +
+ `) + + expect(result.root).toMatchObject({ + size: { width: 320, height: 256 }, + appearance: { + stroke: '#FFFFFF', + strokeTopWeight: 1, + strokeRightWeight: 2, + strokeBottomWeight: 0, + strokeLeftWeight: 2, + topLeftRadius: 12, + topRightRadius: 12, + bottomRightRadius: 0, + bottomLeftRadius: 0 + } + }) + expect(result.root.children?.[0]).toMatchObject({ + size: { width: 24, height: 24 }, + position: { x: -8, y: 1 } + }) + expect(result.root.children?.[1]).toMatchObject({ + size: { width: 160 }, + text: { + fontSize: 14, + lineHeight: { unit: 'PERCENT', value: 125 }, + letterSpacing: { unit: 'PERCENT', value: 5 } + } + }) + + const defaultLeading = parse( + '
Copy
' + ) + expect(defaultLeading.root.children?.[0]?.text).toMatchObject({ + fontSize: 14, + lineHeight: { unit: 'PIXELS', value: 20 } + }) + }) + + it('supports size-full where both fill axes are valid', () => { + const result = parse( + '
' + ) + + expect(result.root.children?.[0]?.size).toMatchObject({ + horizontal: 'FILL', + vertical: 'FILL' + }) + }) + + it('preserves supported CSS hex forms for native solid paints', () => { + const result = parse(` +
+ Copy +
+ `) + + expect(result.root.appearance).toMatchObject({ fill: '#fff', stroke: '#ABCDEF80' }) + expect(result.root.children?.[0]?.appearance).toMatchObject({ fill: '#0008' }) + }) + + it('decodes numeric entities and rejects malformed or inherited names', () => { + const result = parse( + '
AB
' + ) + expect(result.root.children?.[0]?.text?.characters).toBe('AB') + + expect(() => + parse( + '
AA;
' + ) + ).toThrow('Unsupported HTML entity "AA;".') + expect(() => + parse( + '
&__proto__;
' + ) + ).toThrow('Unsupported HTML entity "&__proto__;".') + }) + + it('keeps Figma component, variable, and style identities outside markup syntax', () => { + const result = parse( + ` +
+
+
+ `, + { + bindings: { + root: { + variables: { + fill: { key: 'surface-key' }, + gap: { id: 'VariableID:spacing' } + }, + styles: { + effect: { key: 'raised-style-key' }, + grid: { id: 'StyleID:grid' } + } + }, + button: { + component: { key: 'button-key' }, + componentProperties: { Label: 'Save', Disabled: false } + } + } + } + ) + + expect(result.root.variables).toEqual({ + fill: { key: 'surface-key' }, + gap: { id: 'VariableID:spacing' } + }) + expect(result.root.styles).toEqual({ + effect: { key: 'raised-style-key' }, + grid: { id: 'StyleID:grid' } + }) + expect(result.root.children?.[0]).toMatchObject({ + type: 'INSTANCE', + component: { key: 'button-key' }, + componentProperties: { Label: 'Save', Disabled: false }, + appearance: { opacity: 0.8 } + }) + }) + + it('treats prototype-like stable keys as ordinary binding keys', () => { + const bindings = Object.create(null) as Record + bindings.__proto__ = { figma: { name: 'Prototype layer' } } + + const result = parse('
', { + bindings + }) + + expect(result.root).toMatchObject({ + key: '__proto__', + displayName: 'Prototype layer' + }) + }) + + it('trims native node ids and omits update defaults', () => { + const result = parse( + '
Copy
', + { mode: 'update', targetNodeId: '1:2' } + ) + const copy = result.root.children?.[0] + + expect(result.root.nodeId).toBe('1:2') + expect(result.root).not.toHaveProperty('displayName') + expect(result.root).not.toHaveProperty('grow') + expect(result.root.appearance).not.toHaveProperty('opacity') + expect(copy).not.toHaveProperty('displayName') + expect(copy?.appearance).not.toHaveProperty('opacity') + expect(copy?.text).not.toHaveProperty('alignVertical') + expect(copy?.text).not.toHaveProperty('fontSize') + expect(copy?.text).not.toHaveProperty('alignHorizontal') + }) + + it('supports explicit inline binding removal and rejects unknown binding attributes', () => { + const result = parse( + '
' + ) + + expect(result.root).toMatchObject({ + variables: { opacity: null }, + styles: { fill: null } + }) + expect(() => + parse('
') + ).toThrow('Unsupported attribute "data-var-unknown"') + }) + + it('supports explicit update identities and preserves stable keys', () => { + const result = parse( + ` +
+ Copy +
+ `, + { mode: 'update', targetNodeId: '1:2' } + ) + + expect(result.root.nodeId).toBe('1:2') + expect(result.root.children?.[0]).toMatchObject({ + key: 'copy', + nodeId: '1:3', + grow: true, + size: { horizontal: 'FILL', vertical: 'HUG' }, + text: { autoResize: 'HEIGHT' } + }) + }) + + it('allows a supported non-frame root only for update', () => { + const markup = '
' + const bindings = { + button: { + component: { id: 'component:1' }, + figma: { instance: { scaleFactor: 1.25 } } + } + } satisfies Record + + expect( + parse(markup, { mode: 'update', targetNodeId: 'instance:1', bindings }).root + ).toMatchObject({ + type: 'INSTANCE', + component: { id: 'component:1' }, + figma: { instance: { scaleFactor: 1.25 } } + }) + expect(() => parse(markup, { bindings })).toThrow( + /Create mode requires a frame, section, group, boolean-operation, component, or component-set canvas root/ + ) + }) + + it('normalizes wrapping, bounded sizing, clipping, and absolute auto-layout children', () => { + const result = parse(` +
+ One + Two +
+
+ `) + + expect(result.root).toMatchObject({ + layout: { + mode: 'HORIZONTAL', + gap: 12, + counterGap: 20, + wrap: 'WRAP', + counterAlignContent: 'SPACE_BETWEEN', + strokesIncluded: true + }, + appearance: { clipsContent: true } + }) + expect(result.root.children?.[0]).toMatchObject({ + grow: true, + size: { + minWidth: 80, + maxWidth: 160, + horizontal: 'FILL' + } + }) + expect(result.root.children?.[1]).toMatchObject({ + grow: true, + size: { horizontal: 'FILL' } + }) + expect(result.root.children?.[2]).toMatchObject({ + position: { x: -4, y: 8 } + }) + }) + + it('normalizes explicitly positioned children in a freeform frame', () => { + const relativeTransform: Transform = [ + [1, 0.6, 24], + [0, 0.8, -12] + ] + const result = parse( + ` +
+
+
+
+ `, + { + bindings: { + transformed: { figma: { relativeTransform } } + } + } + ) + + expect(result.root.layout).toEqual({ mode: 'NONE' }) + expect(result.root.children?.[0]?.position).toEqual({ x: -4, y: 8 }) + expect(result.root.children?.[1]?.figma?.relativeTransform).toEqual(relativeTransform) + }) + + it('normalizes native sections and nested freeform content', () => { + const result = parse( + ` +
+
+
+
+
+
+ `, + { + bindings: { + review: { figma: { section: { contentsHidden: true } } }, + variants: { figma: { section: {} } } + } + } + ) + + expect(result.root).toMatchObject({ + type: 'SECTION', + size: { width: 1200, height: 900 }, + appearance: { + fill: '#F5F5F5', + stroke: '#CCCCCC', + strokeWeight: 2, + cornerRadius: 24 + }, + figma: { section: { contentsHidden: true } } + }) + expect(result.root.children?.[1]).toMatchObject({ + type: 'SECTION', + position: { x: 480, y: 80 }, + figma: { section: {} } + }) + expect(result.root.children?.[1]?.children?.[0]?.position).toEqual({ x: 40, y: 80 }) + }) + + it.each([ + [ + 'inside a frame', + '
', + { section: { figma: { section: {} } } }, + /only be a canvas root or a direct child of a section/ + ], + [ + 'with Auto Layout', + '
', + { root: { figma: { section: {} } } }, + /Layout class "flex"/ + ], + [ + 'with non-fixed sizing', + '
', + { root: { figma: { section: {} } } }, + /requires fixed width and height/ + ], + [ + 'with opacity', + '
', + { root: { figma: { section: {} } } }, + /Opacity and blend modes/ + ], + [ + 'with rotation', + '
', + { root: { figma: { section: {} } } }, + /Rotation classes/ + ], + [ + 'with effects', + '
', + { + root: { + figma: { + section: {}, + effects: [{ type: 'LAYER_BLUR', radius: 4 }] + } + } + }, + /Direct effects are not supported/ + ], + [ + 'with a mask', + '
', + { root: { figma: { section: {}, mask: { type: 'ALPHA' } } } }, + /Masks are not supported/ + ], + [ + 'with a stroke cap', + '
', + { root: { figma: { section: {}, stroke: { cap: 'ROUND' } } } }, + /Stroke caps and miter limits/ + ] + ])('rejects a section %s', (_case, markup, bindings, error) => { + expect(() => + parse(markup, { + bindings: bindings as CanvasResolvedApplyParameters['bindings'] + }) + ).toThrow(error) + }) + + it('normalizes intrinsic groups and non-destructive boolean operations', () => { + const result = parse( + ` +
+
+
+
+
+ Icon +
+ `, + { + bindings: { + icon: { + figma: { + group: true, + effects: [{ type: 'LAYER_BLUR', blurType: 'NORMAL', radius: 2 }] + } + }, + cutout: { + figma: { + booleanOperation: 'SUBTRACT', + name: 'Cutout' + } + }, + base: { figma: { shape: { type: 'RECTANGLE' } } }, + hole: { figma: { shape: { type: 'ELLIPSE' } } } + } + } + ) + + expect(result.root).toMatchObject({ + type: 'GROUP', + size: { horizontal: 'HUG', vertical: 'HUG' }, + layout: { mode: 'NONE' }, + blendMode: 'MULTIPLY', + appearance: { opacity: 0.8 }, + figma: { + group: true, + effects: [{ type: 'LAYER_BLUR', blurType: 'NORMAL', radius: 2 }] + } + }) + expect(result.root.children?.[0]).toMatchObject({ + type: 'BOOLEAN_OPERATION', + displayName: 'Cutout', + position: { x: 0, y: 0 }, + size: { horizontal: 'HUG', vertical: 'HUG' }, + appearance: { + fill: '#112233', + stroke: '#445566', + strokeWeight: 2, + cornerRadius: 8 + }, + figma: { booleanOperation: 'SUBTRACT' } + }) + expect(result.root.children?.[0]?.children?.map(({ type }) => type)).toEqual([ + 'RECTANGLE', + 'ELLIPSE' + ]) + }) + + it.each([ + [ + 'a fixed-size group', + '
', + { root: { figma: { group: true } } }, + /requires intrinsic w-fit and h-fit/ + ], + [ + 'an empty group', + '
', + { root: { figma: { group: true } } }, + /requires at least one child/ + ], + [ + 'group fill appearance', + '
', + { root: { figma: { group: true } } }, + /Appearance class/ + ], + [ + 'a one-child boolean operation', + '
', + { + root: { figma: { booleanOperation: 'UNION' } }, + shape: { figma: { shape: { type: 'RECTANGLE' } } } + }, + /requires at least two children/ + ], + [ + 'a frame inside a boolean operation', + '
', + { + root: { figma: { booleanOperation: 'UNION' } }, + shape: { figma: { shape: { type: 'RECTANGLE' } } } + }, + /can contain only text, basic shapes, or nested boolean operations/ + ], + [ + 'overflow on a boolean operation', + '
', + { + root: { figma: { booleanOperation: 'UNION' } }, + a: { figma: { shape: { type: 'RECTANGLE' } } }, + b: { figma: { shape: { type: 'RECTANGLE' } } } + }, + /Overflow classes/ + ] + ])('rejects %s', (_case, markup, bindings, error) => { + expect(() => + parse(markup, { + bindings: bindings as CanvasResolvedApplyParameters['bindings'] + }) + ).toThrow(error) + }) + + it('normalizes authored components and variant sets as frame containers', () => { + const result = parse( + ` +
+
+ Continue +
+
+ Continue +
+
+ `, + { + bindings: { + 'button-set': { + figma: { + component: { + type: 'COMPONENT_SET', + descriptionMarkdown: '**Button** variants', + documentationLink: 'https://example.com/button' + } + } + }, + default: { + figma: { + name: 'State=Default', + component: { type: 'COMPONENT' } + } + }, + hover: { + figma: { + name: 'State=Hover', + component: { type: 'COMPONENT' } + } + } + } + } + ) + + expect(result.root).toMatchObject({ + type: 'COMPONENT_SET', + size: { width: 480, height: 160 }, + layout: { mode: 'NONE' }, + figma: { + component: { + type: 'COMPONENT_SET', + descriptionMarkdown: '**Button** variants', + documentationLink: 'https://example.com/button' + } + } + }) + expect(result.root.children?.map(({ type, displayName }) => ({ type, displayName }))).toEqual([ + { type: 'COMPONENT', displayName: 'State=Default' }, + { type: 'COMPONENT', displayName: 'State=Hover' } + ]) + expect(result.root.children?.[0]).toMatchObject({ + layout: { mode: 'HORIZONTAL' }, + position: { x: 24, y: 24 } + }) + }) + + it('normalizes component sublayer references and slots as frame containers', () => { + const result = parse( + ` +
+ Card title +
+ Default content +
+
+ `, + { + bindings: { + card: { + figma: { + component: { + type: 'COMPONENT', + properties: { + title: { + type: 'TEXT', + name: 'Title', + defaultValue: 'Card title' + }, + 'show-title': { + type: 'BOOLEAN', + name: 'Show title', + defaultValue: true + } + } + } + } + }, + title: { + figma: { + componentPropertyReferences: { + characters: 'title', + visible: 'show-title' + } + } + }, + content: { + figma: { + slot: { + property: { + name: 'Content', + settings: { minChildren: 0, maxChildren: 4 } + } + } + } + } + } + } + ) + + expect(result.root.children?.[0]).toMatchObject({ + type: 'TEXT', + figma: { + componentPropertyReferences: { + characters: 'title', + visible: 'show-title' + } + } + }) + expect(result.root.children?.[1]).toMatchObject({ + type: 'SLOT', + layout: { mode: 'VERTICAL', gap: 8 }, + figma: { + slot: { + property: { + name: 'Content', + settings: { minChildren: 0, maxChildren: 4 } + } + } + } + }) + }) + + it.each([ + [ + 'an empty component set', + '
', + { root: { figma: { component: { type: 'COMPONENT_SET' } } } }, + /requires at least one component child/ + ], + [ + 'a non-component variant', + '
', + { root: { figma: { component: { type: 'COMPONENT_SET' } } } }, + /can contain only component nodes/ + ], + [ + 'a nested authored component', + '
', + { + root: { figma: { component: { type: 'COMPONENT' } } }, + nested: { figma: { component: { type: 'COMPONENT' } } } + }, + /cannot be nested inside another component/ + ], + [ + 'an authored component span', + 'Label', + { root: { figma: { component: { type: 'COMPONENT' } } } }, + /requires a div/ + ], + [ + 'a slot outside a component', + '
', + { + slot: { + figma: { + slot: { property: { name: 'Content' } } + } + } + }, + /must be nested inside an authored component/ + ], + [ + 'a slot canvas root', + '
', + { + root: { + figma: { + slot: { property: { name: 'Content' } } + } + } + }, + /must be nested inside an authored component/ + ], + [ + 'a mainComponent reference on a frame', + '
', + { + root: { + figma: { + componentPropertyReferences: { mainComponent: 'icon' } + } + } + }, + /requires an instance/ + ] + ])('rejects %s', (_case, markup, bindings, error) => { + expect(() => + parse(markup, { + bindings: bindings as CanvasResolvedApplyParameters['bindings'] + }) + ).toThrow(error) + }) + + it.each([ + ['linear in-flow', 'flex flex-row', ''], + ['grid in-flow', 'grid grid-cols-1 grid-rows-1', ''], + ['linear absolute', 'flex flex-row', 'absolute left-[24px] top-[12px]'] + ])( + 'preserves axes-only native transforms on %s Auto Layout children', + (_case, layout, position) => { + const relativeTransform: Transform = [ + [1, 0.6, 0], + [0, 0.8, 0] + ] + const result = parse( + `
`, + { bindings: { child: { figma: { relativeTransform } } } } + ) + + expect(result.root.children?.[0]?.figma?.relativeTransform).toEqual(relativeTransform) + } + ) + + it('rejects ambiguous or inapplicable native relative transforms', () => { + const binding: CanvasBinding = { + figma: { + relativeTransform: [ + [1, 0, 24], + [0, 1, 12] + ] + } + } + const child = (classes = '') => + `
` + + expect(() => + parse( + '
', + { bindings: { child: binding } } + ) + ).toThrow(/must use zero translation in Auto Layout/) + expect(() => parse(child('rotate-[20deg]'), { bindings: { child: binding } })).toThrow( + /cannot be combined with a rotation class/ + ) + expect(() => + parse(child('absolute left-[0px] top-[0px]'), { bindings: { child: binding } }) + ).toThrow(/cannot be combined with position classes/) + }) + + it('preserves typed linear Auto Layout, layout grids, and guides', () => { + const figma: CanvasFigmaProperties = { + autoLayout: { + itemSpacing: -12, + counterAxisSpacing: null, + itemReverseZIndex: true + }, + layoutGrids: [ + { + pattern: 'COLUMNS', + alignment: 'MIN', + gutterSize: 16, + count: 12, + variables: { gutterSize: { id: 'variable:gutter' } } + }, + { pattern: 'GRID', sectionSize: 8 } + ], + guides: [ + { axis: 'X', offset: 24 }, + { axis: 'Y', offset: 40 } + ] + } + const result = parse( + '
', + { bindings: { root: { figma } } } + ) + + expect(result.root.figma).toEqual(figma) + expect(result.root.layout).toMatchObject({ + mode: 'HORIZONTAL', + gap: 0, + counterGap: 0, + wrap: 'WRAP' + }) + }) + + it.each([ + [ + 'Auto Layout state on a plain frame', + '
', + { autoLayout: { itemSpacing: -8 } }, + {}, + /require a flex frame/ + ], + [ + 'Auto Layout state on a grid frame', + '
', + { autoLayout: { itemSpacing: -8 } }, + {}, + /require a flex frame/ + ], + [ + 'counter spacing without wrapping', + '
', + { autoLayout: { counterAxisSpacing: 8 } }, + {}, + /requires flex-wrap/ + ], + [ + 'main gap from classes and Figma state', + '
', + { autoLayout: { itemSpacing: -8 } }, + {}, + /Main-axis spacing/ + ], + [ + 'counter gap from classes and Figma state', + '
', + { autoLayout: { counterAxisSpacing: 12 } }, + {}, + /Counter-axis spacing/ + ], + [ + 'synchronized and variable counter spacing', + '
', + { autoLayout: { counterAxisSpacing: null } }, + { variables: { counterAxisSpacing: { id: 'variable:gap' } } }, + /cannot be combined/ + ], + [ + 'direct layout grids and grid style', + '
', + { layoutGrids: [] }, + { styles: { grid: { id: 'style:grid' } } }, + /Direct layout grids/ + ], + [ + 'guides on text', + '
Text
', + { guides: [{ axis: 'X', offset: 0 }] }, + {}, + /not supported on TEXT/ + ] + ] as Array<[string, string, CanvasFigmaProperties, Omit, RegExp]>)( + 'rejects ambiguous or inapplicable native layout state: %s', + (_, markup, figma, extra, error) => { + const key = markup.includes('data-key="target"') ? 'target' : 'root' + expect(() => + parse(markup, { + bindings: { + [key]: { ...extra, figma } + } + }) + ).toThrow(error) + } + ) + + it('normalizes manual grid tracks, placement, spans, and child alignment', () => { + const result = parse(` +
+
+ Overview +
+
+ `) + + expect(result.root.layout).toEqual({ + autoRows: false, + mode: 'GRID', + columns: [{ type: 'FLEX', value: 1 }, { type: 'FIXED', value: 240 }, { type: 'HUG' }], + rows: [ + { type: 'FIXED', value: 80 }, + { type: 'FLEX', value: 1 } + ], + rowGap: 16, + columnGap: 24, + padding: { top: 20, right: 20, bottom: 20, left: 20 }, + itemsPositioning: 'MANUAL', + strokesIncluded: false + }) + expect(result.root.children?.map((child) => child.gridChild)).toEqual([ + { + row: 0, + column: 0, + rowSpan: 2, + columnSpan: 1, + horizontalAlign: 'AUTO', + verticalAlign: 'AUTO' + }, + { + row: 0, + column: 1, + rowSpan: 1, + columnSpan: 2, + horizontalAlign: 'CENTER', + verticalAlign: 'MIN' + }, + { + row: 1, + column: 1, + rowSpan: 1, + columnSpan: 1, + horizontalAlign: 'AUTO', + verticalAlign: 'AUTO' + } + ]) + }) + + it('normalizes row auto-flow grids with automatic rows', () => { + const result = parse(` +
+
+
+
+ `) + + expect(result.root.layout).toMatchObject({ + mode: 'GRID', + columns: [ + { type: 'FLEX', value: 1 }, + { type: 'FLEX', value: 1 } + ], + rowGap: 12, + columnGap: 12, + itemsPositioning: 'ROW_AUTO_FLOW' + }) + expect(result.root.layout).not.toHaveProperty('rows') + expect(result.root.children?.[0]?.gridChild).toEqual({ + rowSpan: 1, + columnSpan: 2, + horizontalAlign: 'AUTO', + verticalAlign: 'AUTO' + }) + }) + + it('normalizes manually positioned grids with automatic rows', () => { + const result = parse(` +
+
+
+
+
+ `) + + expect(result.root.layout).toMatchObject({ + mode: 'GRID', + itemsPositioning: 'MANUAL' + }) + expect(result.root.layout).not.toHaveProperty('rows') + expect(result.root.children?.map((child) => child.gridChild)).toEqual([ + { + row: 0, + column: 1, + rowSpan: 2, + columnSpan: 1, + horizontalAlign: 'AUTO', + verticalAlign: 'AUTO' + }, + { + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 1, + horizontalAlign: 'AUTO', + verticalAlign: 'AUTO' + }, + { + row: 1, + column: 0, + rowSpan: 1, + columnSpan: 1, + horizontalAlign: 'AUTO', + verticalAlign: 'AUTO' + } + ]) + }) + + it('normalizes shared layer state and typed Figma-only properties', () => { + const result = parse( + '', + { + bindings: { + root: { + figma: { + locked: true, + aspectRatioLocked: true + } + } + } + } + ) + + expect(result.root).toMatchObject({ + visible: false, + blendMode: 'MULTIPLY', + rotation: -450, + figma: { + locked: true, + aspectRatioLocked: true + } + }) + }) + + it('normalizes all native basic shapes and their exact geometry', () => { + const shapes = { + rectangle: { type: 'RECTANGLE' as const }, + line: { type: 'LINE' as const }, + ellipse: { + type: 'ELLIPSE' as const, + arc: { startAngle: -45, endAngle: 270, innerRadius: 0.5 } + }, + polygon: { type: 'POLYGON' as const, pointCount: 6 }, + star: { type: 'STAR' as const, pointCount: 7, innerRadius: 0.6 } + } + const result = parse( + ` +
+
+
+
+
+
+
+ `, + { + bindings: Object.fromEntries( + Object.entries(shapes).map(([key, shape]) => [key, { figma: { shape } }]) + ) + } + ) + + expect(result.root.children?.map((child) => child.type)).toEqual([ + 'RECTANGLE', + 'LINE', + 'ELLIPSE', + 'POLYGON', + 'STAR' + ]) + expect(result.root.children?.map((child) => child.figma?.shape)).toEqual(Object.values(shapes)) + expect(result.root.children?.[0]).toMatchObject({ + appearance: { + fill: '#FF0000', + stroke: '#000000', + strokeWeight: 2, + cornerRadius: 8 + } + }) + expect(result.root.children?.[1]).toMatchObject({ + size: { width: 120, height: 0, horizontal: 'FIXED', vertical: 'FIXED' }, + appearance: { stroke: '#00FF00', strokeWeight: 3 } + }) + }) + + it('normalizes individual border and corner classes independently of class order', () => { + const result = parse( + ` +
+ `, + { + bindings: { + root: { + figma: { + stroke: { + align: 'OUTSIDE', + cap: 'ARROW_LINES', + join: 'BEVEL', + miterLimit: 6, + dashPattern: [8, 4] + }, + corners: { smoothing: 0.75 } + } + } + } + } + ) + + expect(result.root.appearance).toMatchObject({ + stroke: '#112233', + strokeTopWeight: 1, + strokeRightWeight: 2, + strokeBottomWeight: 2, + strokeLeftWeight: 4, + topLeftRadius: 8, + topRightRadius: 8, + bottomRightRadius: 16, + bottomLeftRadius: 8 + }) + expect(result.root.figma).toMatchObject({ + stroke: { + align: 'OUTSIDE', + cap: 'ARROW_LINES', + join: 'BEVEL', + miterLimit: 6, + dashPattern: [8, 4] + }, + corners: { smoothing: 0.75 } + }) + }) + + it('preserves ordered native effects in the typed Figma extension', () => { + const effects = [ + { + type: 'DROP_SHADOW' as const, + color: { r: 0, g: 0, b: 0, a: 0.2 }, + offset: { x: 0, y: 4 }, + radius: 8 + }, + { + type: 'LAYER_BLUR' as const, + blurType: 'NORMAL' as const, + radius: 12 + } + ] + const result = parse('
', { + bindings: { root: { figma: { effects } } } + }) + + expect(result.root.figma?.effects).toEqual(effects) + }) + + it('preserves direct paint stacks without compiling fallback paints', () => { + const fills: NonNullable = [ + { + type: 'SOLID', + color: { r: 1, g: 0, b: 0 }, + variables: { color: { id: 'VariableID:fill' } } + } + ] + const strokes: NonNullable = [ + { + type: 'GRADIENT_LINEAR', + gradientTransform: [ + [1, 0, 0], + [0, 1, 0] + ], + gradientStops: [ + { position: 0, color: { r: 0, g: 0, b: 0, a: 1 } }, + { position: 1, color: { r: 1, g: 1, b: 1, a: 1 } } + ] + } + ] + const result = parse( + '
Text
', + { + bindings: { + root: { figma: { fills, strokes } }, + text: { figma: { fills } } + } + } + ) + + expect(result.root.figma).toMatchObject({ fills, strokes }) + expect(result.root.appearance).not.toHaveProperty('fill') + expect(result.root.appearance).not.toHaveProperty('stroke') + expect(result.root.children?.[0]?.appearance).not.toHaveProperty('fill') + }) + + it('rejects direct paint stacks combined with another source for that paint', () => { + const fills: NonNullable = [ + { type: 'SOLID', color: { r: 1, g: 0, b: 0 } } + ] + const markup = '
' + + expect(() => + parse(markup, { + bindings: { + root: { + styles: { fill: { id: 'style:fill' } }, + figma: { fills } + } + } + }) + ).toThrow(/Direct fill paints and a fill style/) + expect(() => + parse(markup, { + bindings: { + root: { + variables: { fill: { id: 'variable:fill' } }, + figma: { fills } + } + } + }) + ).toThrow(/Direct fill paints and a fill variable/) + expect(() => + parse('
', { + bindings: { root: { figma: { fills } } } + }) + ).toThrow(/Direct fill paints and a literal fill/) + expect(() => + parse( + '
', + { bindings: { root: { figma: { strokes: fills } } } } + ) + ).toThrow(/Direct stroke paints and a literal stroke/) + }) + + it('rejects effect-style conflicts and statically invalid shadow spread', () => { + const shadow = { + type: 'DROP_SHADOW' as const, + color: { r: 0, g: 0, b: 0, a: 0.2 }, + offset: { x: 0, y: 4 }, + radius: 8, + spread: 2 + } + expect(() => + parse('
', { + bindings: { + root: { + styles: { effect: { id: 'style:effect' } }, + figma: { effects: [shadow] } + } + } + }) + ).toThrow(/Direct effects and an effect style/) + + expect(() => + parse( + '
Text
', + { bindings: { text: { figma: { effects: [shadow] } } } } + ) + ).toThrow(/Shadow spread is not supported on TEXT/) + }) + + it('uses typed geometry and variable-bound sides as complete literal fallbacks', () => { + const result = parse( + ` +
+
+
+
+
+ `, + { + bindings: { + shape: { + figma: { + shape: { type: 'RECTANGLE' }, + stroke: { weights: { top: 1, right: 2, bottom: 3, left: 4 } }, + corners: { + radii: { topLeft: 5, topRight: 6, bottomRight: 7, bottomLeft: 8 } + } + } + }, + variable: { + variables: { + strokeRightWeight: { id: 'VariableID:stroke' }, + topLeftRadius: { id: 'VariableID:radius' } + } + }, + component: { + component: { id: 'ComponentID:button' }, + figma: { + stroke: { weights: { top: 1, right: 2, bottom: 3, left: 4 } }, + corners: { + radii: { topLeft: 5, topRight: 6, bottomRight: 7, bottomLeft: 8 } + } + } + } + } + } + ) + + expect(result.root.children?.[0]?.appearance).toMatchObject({ + strokeTopWeight: 1, + strokeRightWeight: 2, + strokeBottomWeight: 3, + strokeLeftWeight: 4, + topLeftRadius: 5, + topRightRadius: 6, + bottomRightRadius: 7, + bottomLeftRadius: 8 + }) + expect(result.root.children?.[1]?.appearance).toMatchObject({ + strokeTopWeight: 2, + strokeRightWeight: 2, + strokeBottomWeight: 2, + strokeLeftWeight: 2, + topLeftRadius: 8, + topRightRadius: 8, + bottomRightRadius: 8, + bottomLeftRadius: 8 + }) + expect(result.root.children?.[2]).toMatchObject({ + type: 'INSTANCE', + appearance: { + strokeTopWeight: 1, + strokeRightWeight: 2, + strokeBottomWeight: 3, + strokeLeftWeight: 4, + topLeftRadius: 5, + topRightRadius: 6, + bottomRightRadius: 7, + bottomLeftRadius: 8 + } + }) + }) + + it.each([ + [ + 'individual stroke class on ellipse', + 'border-t-[1px] border-[#000000]', + { shape: { type: 'ELLIPSE' } }, + /Individual stroke weights/ + ], + [ + 'individual typed stroke on star', + 'border-[#000000]', + { + shape: { type: 'STAR' }, + stroke: { weights: { top: 1, right: 1, bottom: 1, left: 1 } } + }, + /Individual stroke weights/ + ], + [ + 'individual corner class on polygon', + 'rounded-tl-[4px]', + { shape: { type: 'POLYGON' } }, + /Individual corner classes/ + ], + [ + 'individual typed corners on ellipse', + '', + { + shape: { type: 'ELLIPSE' }, + corners: { radii: { topLeft: 1, topRight: 1, bottomRight: 1, bottomLeft: 1 } } + }, + /Individual corner radii/ + ], + [ + 'corner smoothing on line', + '', + { shape: { type: 'LINE' }, corners: { smoothing: 0.5 } }, + /Figma corner properties/ + ], + [ + 'class and typed stroke weight', + 'border-[1px] border-[#000000]', + { stroke: { weight: 2 } }, + /both classes and Figma properties/ + ], + [ + 'class and typed corner radius', + 'rounded-[4px]', + { corners: { radius: 8 } }, + /both classes and Figma properties/ + ] + ])('rejects unsupported or ambiguous stroke/corner state: %s', (_name, classes, figma, error) => { + const lineHeight = 'shape' in figma && figma.shape?.type === 'LINE' ? 0 : 40 + expect(() => + parse( + `
`, + { + bindings: { + target: { figma: figma as CanvasFigmaProperties } + } + } + ) + ).toThrow(error) + }) + + it.each([ + [ + 'shape on a span', + '
x
', + { shape: { type: 'ELLIPSE' } }, + /requires a childless div/ + ], + [ + 'shape children', + '
x
', + { shape: { type: 'RECTANGLE' } }, + /must be childless/ + ], + [ + 'shape layout', + '
', + { shape: { type: 'RECTANGLE' } }, + /Layout class/ + ], + [ + 'shape clipping', + '
', + { shape: { type: 'RECTANGLE' } }, + /Overflow classes/ + ], + [ + 'shape hug sizing', + '
', + { shape: { type: 'RECTANGLE' } }, + /cannot use hug sizing/ + ], + [ + 'nonzero line height', + '
', + { shape: { type: 'LINE' } }, + /requires h-\[0px\]/ + ], + [ + 'zero line width', + '
', + { shape: { type: 'LINE' } }, + /width of at least 0.01px/ + ], + [ + 'line corner radius', + '
', + { shape: { type: 'LINE' } }, + /does not support corner radius/ + ], + [ + 'line aspect ratio', + '
', + { shape: { type: 'LINE' }, aspectRatioLocked: true }, + /does not support aspect-ratio locking/ + ], + [ + 'line growing on its zero-height axis', + '
', + { shape: { type: 'LINE' } }, + /cannot grow on a vertical axis/ + ], + [ + 'zero rectangle width', + '
', + { shape: { type: 'RECTANGLE' } }, + /must be at least 0.01px/ + ], + [ + 'shape grid style', + '
', + { shape: { type: 'ELLIPSE' } }, + /Style field "grid"/, + { styles: { grid: { id: 'style:grid' } } } + ], + [ + 'shape layout variable', + '
', + { shape: { type: 'ELLIPSE' } }, + /Variable field "gap"/, + { variables: { gap: { id: 'variable:gap' } } } + ] + ] as Array<[string, string, CanvasFigmaProperties, RegExp, Partial?]>)( + 'rejects %s', + (_, markup, figma, message, extra) => { + expect(() => + parse(markup, { + bindings: { + shape: { ...extra, figma } + } + }) + ).toThrow(message) + } + ) + + it('requires shape paint bindings and stroke styles to have literal fallbacks', () => { + const markup = + '
' + expect(() => + parse(markup, { + bindings: { + shape: { + variables: { fill: { id: 'variable:fill' } }, + figma: { shape: { type: 'RECTANGLE' } } + } + } + }) + ).toThrow(/requires a solid bg/) + expect(() => + parse(markup, { + bindings: { + shape: { + styles: { stroke: { id: 'style:stroke' } }, + figma: { shape: { type: 'RECTANGLE' } } + } + } + }) + ).toThrow(/requires border/) + expect(() => + parse(markup, { + bindings: { + shape: { + styles: { stroke: { id: 'style:stroke' } }, + figma: { shape: { type: 'RECTANGLE' }, stroke: { weight: 2 } } + } + } + }) + ).not.toThrow() + expect(() => + parse( + '
', + { + bindings: { + shape: { + variables: { + stroke: { id: 'variable:stroke' }, + strokeWeight: { id: 'variable:weight' } + }, + figma: { shape: { type: 'RECTANGLE' } } + } + } + } + ) + ).not.toThrow() + expect(() => + parse( + '
', + { + bindings: { + shape: { + variables: { height: { id: 'variable:height' } }, + figma: { shape: { type: 'LINE' } } + } + } + } + ) + ).toThrow(/cannot bind or constrain its zero height/) + }) + + it('normalizes whole-node text layout and truncation without flattening units', () => { + const result = parse(` +
+ Two lines of copy +
+ `) + + expect(result.root.children?.[0]?.text).toMatchObject({ + lineHeight: { unit: 'PERCENT', value: 150 }, + letterSpacing: { unit: 'PERCENT', value: 2 }, + alignHorizontal: 'JUSTIFIED', + textCase: 'UPPER', + textDecoration: 'UNDERLINE', + textTruncation: 'ENDING', + maxLines: 2 + }) + }) + + it.each([ + ['lowercase', { textCase: 'LOWER' }], + ['capitalize', { textCase: 'TITLE' }], + ['no-underline', { textDecoration: 'NONE' }], + ['truncate', { textTruncation: 'ENDING', maxLines: 1 }] + ])('maps the %s text class', (className, expected) => { + const result = parse( + `
Copy
` + ) + + expect(result.root.children?.[0]?.text).toMatchObject(expected) + }) + + it('keeps Figma-only whole-node text properties and text variables typed', () => { + const text = { + fontName: { family: 'IBM Plex Sans', style: 'Medium' }, + verticalAlign: 'BOTTOM' as const, + case: 'SMALL_CAPS_FORCED' as const, + paragraphIndent: 12, + paragraphSpacing: 16, + listSpacing: 8, + hangingPunctuation: true, + hangingList: true, + leadingTrim: 'CAP_HEIGHT' as const, + hyperlink: { type: 'URL' as const, value: 'https://example.com' } + } + const variables = { + characters: { id: 'VariableID:content' }, + visible: { id: 'VariableID:visible' }, + fontWeight: { id: 'VariableID:weight' }, + paragraphIndent: { id: 'VariableID:indent' }, + paragraphSpacing: { id: 'VariableID:spacing' } + } + const result = parse( + '
Copy
', + { + bindings: { + copy: { + variables, + figma: { text } + } + } + } + ) + + expect(result.root.children?.[0]).toMatchObject({ + variables, + figma: { text }, + text: { + fontFamily: 'IBM Plex Sans', + fontStyle: 'Medium' + } + }) + }) + + it.each([ + { + className: 'font-sans', + binding: {} + }, + { + className: '', + binding: { variables: { fontFamily: { id: 'VariableID:family' } } } + }, + { + className: '', + binding: { styles: { text: { id: 'StyleID:text' } } } + } + ])('rejects ambiguous exact whole-node font sources %#', ({ className, binding }) => { + expect(() => + parse(`Copy`, { + bindings: { + copy: { + ...binding, + figma: { + text: { fontName: { family: 'IBM Plex Sans', style: 'Medium' } } + } + } + } + }) + ).toThrow('cannot use both') + }) + + it('preserves exact text and typed rich-text ranges', () => { + const ranges = [ + { + start: 0, + end: 6, + fontName: { family: 'Inter', style: 'Bold' }, + fills: [ + { + type: 'SOLID' as const, + color: { r: 1, g: 0, b: 0 }, + variables: { color: { id: 'variable:text-color' } } + } + ], + hyperlink: { type: 'URL' as const, value: 'https://example.com' } + }, + { + start: 6, + end: 14, + listOptions: { type: 'UNORDERED' as const }, + indentation: 1, + variables: { fontSize: { id: 'variable:text-size' } } + } + ] + const result = parse( + '
Line 1\n Line 2
', + { + bindings: { + copy: { + figma: { + text: { ranges } + } + } + } + } + ) + + expect(result.root.children?.[0]?.text?.characters).toBe('Line 1\n Line 2') + expect(result.root.children?.[0]?.figma?.text?.ranges).toEqual(ranges) + }) + + it('preserves decoded non-breaking spaces under normal HTML whitespace', () => { + const result = parse( + '
A  B
' + ) + + expect(result.root.children?.[0]?.text?.characters).toBe('A\u00a0\u00a0B') + }) + + it('uses UTF-16 text-range offsets and rejects out-of-bounds ranges', () => { + const markup = + '
👍
' + expect( + parse(markup, { + bindings: { + copy: { + figma: { + text: { + ranges: [{ start: 0, end: 2, fontSize: 18 }] + } + } + } + } + }).root.children?.[0]?.figma?.text?.ranges + ).toHaveLength(1) + + expect(() => + parse(markup, { + bindings: { + copy: { + figma: { + text: { + ranges: [{ start: 0, end: 3, fontSize: 18 }] + } + } + } + } + }) + ).toThrow(/beyond its 2 UTF-16 code units/) + }) + + it.each(BLEND_MODE_CLASSES)('maps %s to Figma %s', (className, blendMode) => { + expect( + parse(`
`).root.blendMode + ).toBe(blendMode) + }) + + it('accepts grid gap variables only on grid containers', () => { + const result = parse( + '
', + { + bindings: { + grid: { + variables: { + gridRowGap: { id: 'VariableID:row-gap' }, + gridColumnGap: { id: 'VariableID:column-gap' } + } + } + } + } + ) + + expect(result.root.variables).toEqual({ + gridRowGap: { id: 'VariableID:row-gap' }, + gridColumnGap: { id: 'VariableID:column-gap' } + }) + expect(() => + parse('
', { + bindings: { + root: { + variables: { gridRowGap: { id: 'VariableID:row-gap' } } + } + } + }) + ).toThrow(/requires grid layout/) + }) + + it.each([ + ['unknown element', '
'], + ['unknown attribute', '
'], + ['unknown class', '
'], + ['conflicting classes', '
'], + ['multiple roots', '
'], + ['direct div text', '
not allowed
'], + [ + 'nested span element', + '
x
' + ], + [ + 'unknown blend mode', + '
' + ], + [ + 'conflicting visibility', + '' + ], + [ + 'invalid line clamp', + '
Copy
' + ] + ])('rejects %s', (_, markup) => { + expect(() => parse(markup)).toThrow() + }) + + it.each([ + [ + 'non-fixed root', + '
', + /root requires fixed/ + ], + [ + 'unpositioned freeform child', + '
Copy
', + /freeform container requires/ + ], + [ + 'flex without direction', + '
', + /one flex direction/ + ], + [ + 'main-axis full size', + '
Copy
', + /use grow/ + ], + [ + 'incomplete border', + '
', + /both stroke weight and paint/ + ], + [ + 'invalid text auto sizing', + '
Copy
', + /w-fit only together/ + ], + [ + 'font size below the Figma minimum', + '
Copy
', + /at least 1px/ + ], + [ + 'cross-axis gap without wrap', + '
', + /requires flex-wrap/ + ], + [ + 'content distribution without wrap', + '
', + /requires flex-wrap/ + ], + [ + 'absolute child without both offsets', + '
', + /requires left.*top/ + ], + [ + 'offset without absolute positioning', + '
', + /require absolute/ + ], + [ + 'fill sizing on absolute child', + '
', + /cannot use grow/ + ], + [ + 'inverted size bounds', + '
', + /cannot exceed/ + ], + [ + 'grid without columns', + '
', + /requires grid-cols/ + ], + [ + 'mixed flex and grid', + '
', + /cannot combine/ + ], + [ + 'partial grid position', + '
', + /both row-start and col-start/ + ], + [ + 'overlapping grid children', + '
', + /unoccupied grid area/ + ], + [ + 'explicit auto-flow position', + '
', + /cannot use explicit placement/ + ], + [ + 'grid child class outside grid', + '
', + /requires an in-flow grid child/ + ], + [ + 'flex track on hug grid axis', + '
', + /cannot contain flexible column/ + ], + [ + 'grow in grid', + '
', + /not supported in grid/ + ], + [ + 'fixed auto-flow grid overflow', + '
', + /does not fit/ + ], + [ + 'unsupported grid track', + '
', + /Invalid grid track/ + ], + [ + 'automatic row limit overflow', + '
', + /does not fit/ + ] + ])('rejects %s', (_, markup, message) => { + expect(() => parse(markup)).toThrow(message) + }) + + it('allows flexible aspect-ratio locking except on auto-resizing text', () => { + const result = parse( + '
LabelCopy
', + { + bindings: { + media: { + figma: { aspectRatioLocked: true } + }, + badge: { + figma: { aspectRatioLocked: true } + }, + label: { + figma: { aspectRatioLocked: true } + }, + copy: { + figma: { aspectRatioLocked: false } + } + } + } + ) + expect(result.root.children?.[0]).toMatchObject({ + size: { horizontal: 'FILL', vertical: 'FIXED' }, + figma: { aspectRatioLocked: true } + }) + expect(result.root.children?.[1]).toMatchObject({ + size: { horizontal: 'HUG', vertical: 'HUG' }, + figma: { aspectRatioLocked: true } + }) + expect(result.root.children?.[2]).toMatchObject({ + size: { horizontal: 'FILL', vertical: 'FILL' }, + text: { autoResize: 'NONE' }, + figma: { aspectRatioLocked: true } + }) + expect(result.root.children?.[3]).toMatchObject({ + size: { horizontal: 'FILL', vertical: 'HUG' }, + figma: { aspectRatioLocked: false } + }) + + expect(() => + parse( + '
Copy
', + { + bindings: { + copy: { + figma: { aspectRatioLocked: true } + } + } + } + ) + ).toThrow(/auto-resizing text/) + }) + + it('requires typed Figma text state on spans and rejects duplicate case sources', () => { + expect(() => + parse('
', { + bindings: { + root: { + figma: { text: { verticalAlign: 'CENTER' } } + } + } + }) + ).toThrow(/require a span/) + expect(() => + parse( + '
Copy
', + { + bindings: { + copy: { + figma: { text: { case: 'SMALL_CAPS' } } + } + } + } + ) + ).toThrow(/cannot use both/) + }) + + it('rejects ambiguous identities and bindings before reconciliation', () => { + expect(() => + parse( + '
Copy
' + ) + ).toThrow(/Duplicate data-key/) + expect(() => + parse('
', { + bindings: { + missing: { + variables: { fill: { key: 'color-key' } } + } + } + }) + ).toThrow(/no matching data-key/) + expect(() => + parse('
', { + mode: 'update', + targetNodeId: '1:2' + }) + ).toThrow(/must match targetNodeId/) + expect(() => + parse('
') + ).toThrow(/Create mode cannot/) + expect(() => + parse('
', { + mode: 'update', + targetNodeId: '1:2', + removeKeys: ['root'] + }) + ).toThrow(/cannot be both present and removed/) + expect( + parse('
', { + mode: 'update', + targetNodeId: '1:2', + removeKeys: ['old/child'] + }).removeKeys + ).toEqual(['old/child']) + }) + + it('preserves variable clears and mode overrides without requiring obsolete layout state', () => { + const result = parse('
', { + bindings: { + root: { + variables: { + fill: null, + gap: null, + minWidth: null + }, + variableModes: { + 'collection:theme': 'mode:dark', + 'collection:density': null + } + } + } + }) + + expect(result.root.variables).toEqual({ + fill: null, + gap: null, + minWidth: null + }) + expect(result.root.variableModes).toEqual({ + 'collection:theme': 'mode:dark', + 'collection:density': null + }) + }) + + it('rejects incompatible component and variable bindings', () => { + expect(() => + parse( + '
', + { + bindings: { + button: { component: { key: 'button-key' } } + } + } + ) + ).toThrow(/not supported on component/) + expect(() => + parse('
', { + bindings: { + root: { + variables: { fill: { key: 'color-key' } } + } + } + }) + ).toThrow(/solid .* fallback/) + expect(() => + parse( + '
Copy
', + { + bindings: { + copy: { + variables: { gap: { key: 'spacing-key' } } + } + } + } + ) + ).toThrow(/not supported on TEXT/) + expect(() => + parse( + '
Copy
', + { + bindings: { + copy: { + variables: { gap: null } + } + } + } + ) + ).toThrow(/not supported on TEXT/) + expect(() => + parse( + '
Copy
', + { + bindings: { + copy: { + variables: { strokeWeight: { key: 'weight-key' } } + } + } + } + ) + ).not.toThrow() + expect(() => + parse( + '
', + { + bindings: { + shape: { + variables: { strokeTopWeight: { key: 'weight-key' } }, + figma: { shape: { type: 'ELLIPSE' } } + } + } + } + ) + ).toThrow(/not supported on ELLIPSE/) + expect(() => + parse('
', { + bindings: { + root: { + variables: { minWidth: { key: 'spacing-key' } } + } + } + }) + ).toThrow(/requires text or auto layout/) + expect(() => + parse( + '
', + { + bindings: { + child: { + variables: { width: { key: 'width-key' } } + } + } + } + ) + ).toThrow(/Width variable .* fixed width fallback/) + expect(() => + parse( + '
', + { + bindings: { + child: { + variables: { height: { key: 'height-key' } } + } + } + } + ) + ).toThrow(/Height variable .* fixed height fallback/) + }) + + it('rejects incompatible style bindings', () => { + expect(() => + parse('
', { + bindings: { + root: { + styles: { text: { key: 'heading-style' } } + } + } + }) + ).toThrow(/not supported on FRAME/) + expect(() => + parse('
', { + bindings: { + root: { + styles: { stroke: { key: 'border-style' } } + } + } + }) + ).toThrow(/requires border/) + expect(() => + parse('
', { + bindings: { + root: { + variables: { fill: { key: 'surface-variable' } }, + styles: { fill: { key: 'surface-style' } } + } + } + }) + ).toThrow(/cannot be combined/) + expect(() => + parse('
', { + bindings: { + root: { + variables: { fill: null }, + styles: { fill: { key: 'surface-style' } } + } + } + }) + ).toThrow(/cannot be combined/) + }) + + it('enforces the shared node and depth limits', () => { + const children = Array.from( + { length: 99 }, + (_, index) => `${index}` + ).join('') + expect(() => + parse(`
${children}
`) + ).not.toThrow() + expect(() => + parse( + `
${children}overflow
` + ) + ).toThrow(/at most 100/) + + let nested = 'End' + for (let depth = 11; depth >= 2; depth -= 1) { + nested = `
${nested}
` + } + expect(() => + parse(`
${nested}
`) + ).not.toThrow() + expect(() => + parse( + `
${nested}
` + ) + ).toThrow(/at most 12 levels/) + + const excessiveMarkup = `${'
'.repeat(5_000)}${'
'.repeat(5_000)}` + expect(() => parse(excessiveMarkup)).toThrow(/at most 12 levels/) + }) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-resolve.test.ts b/packages/extension/tests/mcp/tools/canvas-resolve.test.ts new file mode 100644 index 00000000..7c82ca2d --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-resolve.test.ts @@ -0,0 +1,317 @@ +import type { ApplyCanvasParameters } from '@tempad-dev/shared' + +import { ApplyCanvasParametersSchema } from '@tempad-dev/shared' +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' + +import { formatSchemaError } from '@/mcp/tools/canvas/errors' +import { parseCanvasMarkup } from '@/mcp/tools/canvas/markup' +import { resolveCanvasInput } from '@/mcp/tools/canvas/resolve' +import { registerDesignSystemCatalog } from '@/mcp/tools/design-system-catalog' + +const AUTHORING_REFERENCE_DIR = new URL( + '../../../../../agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/', + import.meta.url +) + +function referenceExamples(file: string): unknown[] { + const markdown = readFileSync(new URL(file, AUTHORING_REFERENCE_DIR), 'utf8') + return [...markdown.matchAll(/```json\n([\s\S]*?)\n```/g)].map((match) => JSON.parse(match[1]!)) +} + +function catalog() { + return registerDesignSystemCatalog([ + { + kind: 'component', + ref: 'c1', + tag: 'Button', + name: 'Button', + reference: { id: 'component:button', key: 'component-key' }, + nativeSize: { width: 120, height: 40 }, + pageName: 'Components', + variantCount: 1, + properties: { + label: { name: 'Label#1:2', type: 'text', default: 'Continue' }, + disabled: { name: 'Disabled', type: 'boolean', default: false }, + tone: { + name: 'Tone', + type: 'variant', + default: 'Primary', + options: ['Primary', 'Secondary'] + } + }, + definition: {} + }, + { + kind: 'variable', + ref: 'v1', + name: 'Text size', + reference: { id: 'variable:size', key: 'variable-key' }, + resolvedType: 'FLOAT', + defaultValue: 16, + definition: {} + }, + { + kind: 'collection', + ref: 'k1', + name: 'Theme', + reference: { id: 'collection:theme', key: 'collection-key' }, + modes: [{ ref: 'm1_1', id: 'mode:dark', name: 'Dark' }], + defaultModeId: 'mode:dark', + definition: {} + }, + { + kind: 'mode', + ref: 'm1_1', + name: 'Dark', + id: 'mode:dark', + collectionRef: 'k1', + definition: {} + }, + { + kind: 'style', + ref: 's1', + name: 'Body', + reference: { id: 'style:body', key: 'style-key' }, + styleType: 'TEXT', + definition: {} + }, + { + kind: 'shader', + ref: 'h1', + name: 'Aurora', + id: 'shader:aurora', + shaderType: 'effect', + definition: {} + } + ]) +} + +describe('mcp/tools/canvas catalog resolution', () => { + it.each(['canvas-html.md', 'variables.md', 'styles.md', 'component-authoring.md'])( + 'keeps every complete %s recipe executable', + (file) => { + const examples = referenceExamples(file) + expect(examples.length).toBeGreaterThan(0) + for (const example of examples) { + const input = ApplyCanvasParametersSchema.parse(example) + const resolved = resolveCanvasInput(input) + expect(() => parseCanvasMarkup(resolved.input, resolved.catalog)).not.toThrow() + } + } + ) + + it('keeps the complete design-system reuse recipe executable', () => { + const designSystem = catalog() + const examples = referenceExamples('design-system-reuse.md') + expect(examples.length).toBeGreaterThan(0) + + for (const example of examples) { + const input = ApplyCanvasParametersSchema.parse({ + ...(example as Record), + catalogId: designSystem.id + }) + const resolved = resolveCanvasInput(input) + expect(() => parseCanvasMarkup(resolved.input, resolved.catalog)).not.toThrow() + } + }) + + it('returns bounded validation feedback with actionable paths', () => { + const parsed = z + .object({ root: z.object({ items: z.array(z.string()) }) }) + .safeParse({ root: { items: [1, 2, 3, 4, 5] } }) + if (parsed.success) throw new Error('Expected validation to fail.') + + const message = formatSchemaError(parsed.error) + + expect(message).toContain('root.items[0]:') + expect(message).toContain('root.items[3]:') + expect(message).not.toContain('root.items[4]:') + expect(message).toContain('1 more validation issue omitted.') + }) + + it('resolves short refs and compiles catalog tags into native instances', () => { + const designSystem = catalog() + const input = ApplyCanvasParametersSchema.parse({ + mode: 'create', + catalogId: designSystem.id, + markup: + '
', + native: { + root: { variableModes: { k1: 'm1_1' } } + } + }) + + const resolved = resolveCanvasInput(input) + expect(resolved.input.bindings).toMatchObject({ + root: { variableModes: { 'collection:theme': 'mode:dark' } } + }) + + const parsed = parseCanvasMarkup(resolved.input, resolved.catalog) + if (parsed.root === null) throw new Error('Expected a canvas tree.') + expect(parsed.root.children?.[0]).toMatchObject({ + key: 'save', + type: 'INSTANCE', + size: { width: 120, height: 40 }, + component: { id: 'component:button', key: 'component-key' }, + variables: { opacity: { id: 'variable:size', key: 'variable-key' } }, + componentProperties: { + 'Label#1:2': 'Save', + Disabled: false, + Tone: 'Primary' + } + }) + expect(parsed.root.children?.[1]).toMatchObject({ + key: 'copy', + variables: { fontSize: { id: 'variable:size', key: 'variable-key' } }, + styles: { text: { id: 'style:body', key: 'style-key' } } + }) + }) + + it('accepts advertised native instance-swap ids and keys', () => { + const designSystem = registerDesignSystemCatalog([ + { + kind: 'component', + ref: 'c1', + tag: 'Button', + name: 'Button', + reference: { id: 'component:button', key: 'button-key' }, + nativeSize: { width: 120, height: 40 }, + pageName: 'Components', + variantCount: 1, + properties: { + icon: { + name: 'Icon', + type: 'instance', + default: 'component:icon-alt', + options: ['icon-alt-key'] + } + }, + definition: {} + }, + { + kind: 'component', + ref: 'c2', + tag: 'Icon', + name: 'Icon', + reference: { id: 'component:icon-default', key: 'icon-default-key' }, + nativeReferences: [{ id: 'component:icon-alt', key: 'icon-alt-key' }], + nativeSize: { width: 24, height: 24 }, + pageName: 'Components', + variantCount: 2, + properties: {}, + definition: {} + } + ]) + const input = ApplyCanvasParametersSchema.parse({ + mode: 'create', + catalogId: designSystem.id, + markup: + '
' + }) + const resolved = resolveCanvasInput(input) + const parsed = parseCanvasMarkup(resolved.input, resolved.catalog) + + expect(parsed.root?.children?.map((child) => child.componentProperties)).toEqual([ + { Icon: 'component:icon-alt' }, + { Icon: 'component:icon-alt' } + ]) + + const inheritedProperty = resolveCanvasInput( + ApplyCanvasParametersSchema.parse({ + mode: 'create', + catalogId: designSystem.id, + markup: '